summary refs log tree commit diff
path: root/syweb
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--syweb/__init__.py0
-rw-r--r--syweb/webclient/CAPTCHA_SETUP (renamed from webclient/CAPTCHA_SETUP)0
-rw-r--r--syweb/webclient/README (renamed from webclient/README)0
-rw-r--r--syweb/webclient/app-controller.js (renamed from webclient/app-controller.js)20
-rw-r--r--syweb/webclient/app-directive.js (renamed from webclient/app-directive.js)43
-rw-r--r--syweb/webclient/app-filter.js (renamed from webclient/app-filter.js)4
-rwxr-xr-xsyweb/webclient/app.css (renamed from webclient/app.css)227
-rw-r--r--syweb/webclient/app.js (renamed from webclient/app.js)8
-rw-r--r--syweb/webclient/bootstrap.css (renamed from webclient/bootstrap.css)0
-rw-r--r--syweb/webclient/components/fileInput/file-input-directive.js (renamed from webclient/components/fileInput/file-input-directive.js)0
-rw-r--r--syweb/webclient/components/fileUpload/file-upload-service.js (renamed from webclient/components/fileUpload/file-upload-service.js)7
-rw-r--r--syweb/webclient/components/matrix/commands-service.js164
-rw-r--r--syweb/webclient/components/matrix/event-handler-service.js570
-rw-r--r--syweb/webclient/components/matrix/event-stream-service.js (renamed from webclient/components/matrix/event-stream-service.js)19
-rw-r--r--syweb/webclient/components/matrix/matrix-call.js (renamed from webclient/components/matrix/matrix-call.js)186
-rw-r--r--syweb/webclient/components/matrix/matrix-filter.js172
-rw-r--r--syweb/webclient/components/matrix/matrix-phone-service.js (renamed from webclient/components/matrix/matrix-phone-service.js)2
-rw-r--r--syweb/webclient/components/matrix/matrix-service.js (renamed from webclient/components/matrix/matrix-service.js)140
-rw-r--r--syweb/webclient/components/matrix/model-service.js213
-rw-r--r--syweb/webclient/components/matrix/notification-service.js104
-rw-r--r--syweb/webclient/components/matrix/presence-service.js (renamed from webclient/components/matrix/presence-service.js)0
-rw-r--r--syweb/webclient/components/matrix/recents-service.js99
-rw-r--r--syweb/webclient/components/utilities/utilities-service.js (renamed from webclient/components/utilities/utilities-service.js)0
-rw-r--r--syweb/webclient/favicon.ico (renamed from webclient/favicon.ico)bin198 -> 198 bytes
-rw-r--r--syweb/webclient/home/home-controller.js (renamed from webclient/home/home-controller.js)22
-rw-r--r--syweb/webclient/home/home.html (renamed from webclient/home/home.html)0
-rw-r--r--syweb/webclient/img/attach.pngbin0 -> 473 bytes
-rw-r--r--syweb/webclient/img/close.png (renamed from webclient/img/close.png)bin397 -> 397 bytes
-rw-r--r--syweb/webclient/img/default-profile.png (renamed from webclient/img/default-profile.png)bin1722 -> 1722 bytes
-rw-r--r--syweb/webclient/img/gradient.png (renamed from webclient/img/gradient.png)bin194 -> 194 bytes
-rw-r--r--syweb/webclient/img/green_phone.png (renamed from webclient/img/green_phone.png)bin434 -> 434 bytes
-rw-r--r--syweb/webclient/img/logo-small.png (renamed from webclient/img/logo-small.png)bin910 -> 910 bytes
-rw-r--r--syweb/webclient/img/logo.png (renamed from webclient/img/logo.png)bin4060 -> 4060 bytes
-rw-r--r--syweb/webclient/img/settings.pngbin0 -> 864 bytes
-rw-r--r--syweb/webclient/img/video.pngbin0 -> 604 bytes
-rw-r--r--syweb/webclient/img/voice.pngbin0 -> 659 bytes
-rw-r--r--syweb/webclient/index.html (renamed from webclient/index.html)19
-rw-r--r--syweb/webclient/js/angular-animate.js (renamed from webclient/js/angular-animate.js)0
-rw-r--r--syweb/webclient/js/angular-animate.min.js (renamed from webclient/js/angular-animate.min.js)0
-rwxr-xr-xsyweb/webclient/js/angular-mocks.js (renamed from webclient/js/angular-mocks.js)308
-rw-r--r--syweb/webclient/js/angular-peity.js69
-rw-r--r--syweb/webclient/js/angular-route.js (renamed from webclient/js/angular-route.js)0
-rw-r--r--syweb/webclient/js/angular-route.min.js (renamed from webclient/js/angular-route.min.js)0
-rw-r--r--syweb/webclient/js/angular-sanitize.js (renamed from webclient/js/angular-sanitize.js)0
-rw-r--r--syweb/webclient/js/angular-sanitize.min.js (renamed from webclient/js/angular-sanitize.min.js)0
-rw-r--r--syweb/webclient/js/angular.js (renamed from webclient/js/angular.js)0
-rw-r--r--syweb/webclient/js/angular.min.js (renamed from webclient/js/angular.min.js)0
-rwxr-xr-xsyweb/webclient/js/autofill-event.js (renamed from webclient/js/autofill-event.js)0
-rw-r--r--syweb/webclient/js/elastic.js216
-rw-r--r--syweb/webclient/js/jquery-1.8.3.min.js (renamed from webclient/js/jquery-1.8.3.min.js)0
-rw-r--r--syweb/webclient/js/jquery.peity.min.js13
-rw-r--r--syweb/webclient/js/ng-infinite-scroll-matrix.js (renamed from webclient/js/ng-infinite-scroll-matrix.js)0
-rw-r--r--syweb/webclient/js/ui-bootstrap-tpls-0.11.2.js (renamed from webclient/js/ui-bootstrap-tpls-0.11.2.js)0
-rw-r--r--syweb/webclient/login/login-controller.js (renamed from webclient/login/login-controller.js)0
-rw-r--r--syweb/webclient/login/login.html (renamed from webclient/login/login.html)0
-rw-r--r--syweb/webclient/login/register-controller.js (renamed from webclient/login/register-controller.js)2
-rw-r--r--syweb/webclient/login/register.html (renamed from webclient/login/register.html)0
-rw-r--r--syweb/webclient/media/busy.mp3 (renamed from webclient/media/busy.mp3)bin24834 -> 24834 bytes
-rw-r--r--syweb/webclient/media/busy.ogg (renamed from webclient/media/busy.ogg)bin13960 -> 13960 bytes
-rw-r--r--syweb/webclient/media/callend.mp3 (renamed from webclient/media/callend.mp3)bin12971 -> 12971 bytes
-rw-r--r--syweb/webclient/media/callend.ogg (renamed from webclient/media/callend.ogg)bin13932 -> 13932 bytes
-rw-r--r--syweb/webclient/media/ring.mp3 (renamed from webclient/media/ring.mp3)bin19662 -> 19662 bytes
-rw-r--r--syweb/webclient/media/ring.ogg (renamed from webclient/media/ring.ogg)bin20636 -> 20636 bytes
-rw-r--r--syweb/webclient/media/ringback.mp3 (renamed from webclient/media/ringback.mp3)bin18398 -> 18398 bytes
-rw-r--r--syweb/webclient/media/ringback.ogg (renamed from webclient/media/ringback.ogg)bin8352 -> 8352 bytes
-rw-r--r--syweb/webclient/mobile.css (renamed from webclient/mobile.css)18
-rw-r--r--syweb/webclient/recents/recents-controller.js53
-rw-r--r--syweb/webclient/recents/recents-filter.js (renamed from webclient/recents/recents-filter.js)23
-rw-r--r--syweb/webclient/recents/recents.html (renamed from webclient/recents/recents.html)20
-rw-r--r--syweb/webclient/room/room-controller.js (renamed from webclient/room/room-controller.js)427
-rw-r--r--syweb/webclient/room/room-directive.js (renamed from webclient/room/room-directive.js)109
-rw-r--r--syweb/webclient/room/room.html266
-rw-r--r--syweb/webclient/settings/settings-controller.js (renamed from webclient/settings/settings-controller.js)0
-rw-r--r--syweb/webclient/settings/settings.html (renamed from webclient/settings/settings.html)0
-rw-r--r--syweb/webclient/test/README (renamed from webclient/test/README)30
-rw-r--r--syweb/webclient/test/e2e/home.spec.js (renamed from webclient/test/e2e/home.spec.js)0
-rw-r--r--syweb/webclient/test/karma.conf.js (renamed from webclient/test/karma.conf.js)45
-rw-r--r--syweb/webclient/test/protractor.conf.js (renamed from webclient/test/protractor.conf.js)0
-rw-r--r--syweb/webclient/test/unit/commands-service.spec.js143
-rw-r--r--syweb/webclient/test/unit/event-handler-service.spec.js117
-rw-r--r--syweb/webclient/test/unit/filters.spec.js635
-rw-r--r--syweb/webclient/test/unit/matrix-service.spec.js504
-rw-r--r--syweb/webclient/test/unit/model-service.spec.js30
-rw-r--r--syweb/webclient/test/unit/notification-service.spec.js78
-rw-r--r--syweb/webclient/test/unit/recents-service.spec.js153
-rw-r--r--syweb/webclient/test/unit/register-controller.spec.js84
-rw-r--r--syweb/webclient/test/unit/user-controller.spec.js (renamed from webclient/test/unit/user-controller.spec.js)0
-rw-r--r--syweb/webclient/user/user-controller.js (renamed from webclient/user/user-controller.js)0
-rw-r--r--syweb/webclient/user/user.html (renamed from webclient/user/user.html)0
89 files changed, 4622 insertions, 740 deletions
diff --git a/syweb/__init__.py b/syweb/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/syweb/__init__.py
diff --git a/webclient/CAPTCHA_SETUP b/syweb/webclient/CAPTCHA_SETUP
index ebc8a5f3b0..ebc8a5f3b0 100644
--- a/webclient/CAPTCHA_SETUP
+++ b/syweb/webclient/CAPTCHA_SETUP
diff --git a/webclient/README b/syweb/webclient/README
index ef79b25708..ef79b25708 100644
--- a/webclient/README
+++ b/syweb/webclient/README
diff --git a/webclient/app-controller.js b/syweb/webclient/app-controller.js
index e4b7cd286f..582c075e3d 100644
--- a/webclient/app-controller.js
+++ b/syweb/webclient/app-controller.js
@@ -21,18 +21,12 @@ limitations under the License.
 'use strict';
 
 angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'eventStreamService'])
-.controller('MatrixWebClientController', ['$scope', '$location', '$rootScope', '$timeout', '$animate', 'matrixService', 'mPresence', 'eventStreamService', 'eventHandlerService', 'matrixPhoneService',
-                               function($scope, $location, $rootScope, $timeout, $animate, matrixService, mPresence, eventStreamService, eventHandlerService, matrixPhoneService) {
+.controller('MatrixWebClientController', ['$scope', '$location', '$rootScope', '$timeout', 'matrixService', 'mPresence', 'eventStreamService', 'eventHandlerService', 'matrixPhoneService', 'modelService',
+                               function($scope, $location, $rootScope, $timeout, matrixService, mPresence, eventStreamService, eventHandlerService, matrixPhoneService, modelService) {
          
     // Check current URL to avoid to display the logout button on the login page
     $scope.location = $location.path();
 
-    // disable nganimate for the local and remote video elements because ngAnimate appears
-    // to be buggy and leaves animation classes on the video elements causing them to show
-    // when they should not (their animations are pure CSS3)
-    $animate.enabled(false, angular.element('#localVideo'));
-    $animate.enabled(false, angular.element('#remoteVideo'));
-    
     // Update the location state when the ng location changed
     $rootScope.$on('$routeChangeSuccess', function (event, current, previous) {
         $scope.location = $location.path();
@@ -112,12 +106,12 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
         if (!$rootScope.currentCall) {
             // This causes the still frame to be flushed out of the video elements,
             // avoiding a flash of the last frame of the previous call when starting the next
-            angular.element('#localVideo')[0].load();
-            angular.element('#remoteVideo')[0].load();
+            if (angular.element('#localVideo')[0].load) angular.element('#localVideo')[0].load();
+            if (angular.element('#remoteVideo')[0].load) angular.element('#remoteVideo')[0].load();
             return;
         }
 
-        var roomMembers = angular.copy($rootScope.events.rooms[$rootScope.currentCall.room_id].members);
+        var roomMembers = angular.copy(modelService.getRoom($rootScope.currentCall.room_id).current_room_state.members);
         delete roomMembers[matrixService.config().user_id];
 
         $rootScope.currentCall.user_id = Object.keys(roomMembers)[0];
@@ -187,8 +181,8 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
         }
         call.onError = $scope.onCallError;
         call.onHangup = $scope.onCallHangup;
-        call.localVideoElement = angular.element('#localVideo')[0];
-        call.remoteVideoElement = angular.element('#remoteVideo')[0];
+        call.localVideoSelector  = '#localVideo';
+        call.remoteVideoSelector  = '#remoteVideo';
         $rootScope.currentCall = call;
     });
 
diff --git a/webclient/app-directive.js b/syweb/webclient/app-directive.js
index 75283598ab..c1ba0af3a9 100644
--- a/webclient/app-directive.js
+++ b/syweb/webclient/app-directive.js
@@ -40,4 +40,45 @@ angular.module('matrixWebClient')
             }            
         }
     };
-}]);
\ No newline at end of file
+}])
+.directive('asjson', function() {
+    return {
+        restrict: 'A',
+        require: 'ngModel',
+        link: function (scope, element, attrs, ngModelCtrl) {
+            function isValidJson(model) {
+                var flag = true;
+                try {
+                    angular.fromJson(model);
+                } catch (err) {
+                    flag = false;
+                }
+                return flag;
+            };
+
+            function string2JSON(text) {
+                try {
+                    var j = angular.fromJson(text);
+                    ngModelCtrl.$setValidity('json', true);
+                    return j;
+                } catch (err) {
+                    //returning undefined results in a parser error as of angular-1.3-rc.0, and will not go through $validators
+                    //return undefined
+                    ngModelCtrl.$setValidity('json', false);
+                    return text;
+                }
+            };
+
+            function JSON2String(object) {
+                return angular.toJson(object, true);
+            };
+
+            //$validators is an object, where key is the error
+            //ngModelCtrl.$validators.json = isValidJson;
+
+            //array pipelines
+            ngModelCtrl.$parsers.push(string2JSON);
+            ngModelCtrl.$formatters.push(JSON2String);
+        }
+    }
+});
diff --git a/webclient/app-filter.js b/syweb/webclient/app-filter.js
index 39ea1d637d..65da0d312d 100644
--- a/webclient/app-filter.js
+++ b/syweb/webclient/app-filter.js
@@ -29,10 +29,10 @@ angular.module('matrixWebClient')
             return s + "s";
         }
         if (t < 60 * 60) {
-            return m + "m "; //  + s + "s";
+            return m + "m"; //  + s + "s";
         }
         if (t < 24 * 60 * 60) {
-            return h + "h "; // + m + "m";
+            return h + "h"; // + m + "m";
         }
         return d + "d "; // + h + "h";
     };
diff --git a/webclient/app.css b/syweb/webclient/app.css
index 20a13aad81..25f7208a11 100755
--- a/webclient/app.css
+++ b/syweb/webclient/app.css
@@ -66,18 +66,15 @@ textarea, input {
     margin-left: 4px;
     margin-right: 4px;
     margin-top: 8px;
+    transition: transform linear 0.5s;
+    transition: -webkit-transform linear 0.5s;
 }
 
-#callEndedIcon {
-    transition:all linear 0.5s;
-}
-
-#callEndedIcon {
+.callIcon.ended {
     transform: rotateZ(45deg);
-}
-
-#callEndedIcon.ng-hide {
-    transform: rotateZ(0deg);
+    -webkit-transform: rotateZ(45deg);
+    filter: hue-rotate(-90deg);
+    -webkit-filter: hue-rotate(-90deg);
 }
 
 #callPeerImage {
@@ -136,17 +133,17 @@ textarea, input {
     transition: left linear 500ms, top linear 500ms, width linear 500ms, height linear 500ms;
 }
 
-#localVideo.mini {
+.mini #localVideo {
     top: 0px;
     left: 130px;
 }
 
-#localVideo.large {
+.large #localVideo {
     top: 70px;
     left: 20px;
 }
 
-#localVideo.ended {
+.ended #localVideo {
     -webkit-filter: grayscale(1);
     filter: grayscale(1);
 }
@@ -157,19 +154,19 @@ textarea, input {
     transition: left linear 500ms, top linear 500ms, width linear 500ms, height linear 500ms;
 }
 
-#remoteVideo.mini {
+.mini #remoteVideo {
     left: 260px;
     top: 0px;
     width: 128px;
 }
 
-#remoteVideo.large {
+.large #remoteVideo {
     left: 0px;
     top: 50px;
     width: 100%;
 }
 
-#remoteVideo.ended {
+.ended #remoteVideo {
     -webkit-filter: grayscale(1);
     filter: grayscale(1);
 }
@@ -318,7 +315,7 @@ textarea, input {
     position: absolute;
     bottom: 0px;
     width: 100%;
-    height: 100px;
+    height: 70px;
     background-color: #f8f8f8;
     border-top: #aaa 1px solid;
 }
@@ -326,7 +323,9 @@ textarea, input {
 #controls {
     max-width: 1280px;
     padding: 12px;
+    padding-right: 42px;
     margin: auto;
+    position: relative;
 }
 
 #buttonsCell {
@@ -343,7 +342,19 @@ textarea, input {
 
 #mainInput {
     width: 100%;
-    resize: none;
+    padding: 5px;
+    resize: vertical;
+}
+
+#attachButton {
+    position: absolute;
+    cursor: pointer;
+    margin-top: 3px;
+    right: 0px;
+    background: url('img/attach.png');
+    width: 25px;
+    height: 25px;
+    border: 0px;
 }
 
 .blink {
@@ -415,18 +426,72 @@ textarea, input {
 .roomHeaderInfo {
     text-align: right;
     float: right;
-    margin-top: 15px;
+    margin-top: 0px;
+    margin-right: 30px;
+}
+
+/*** Room Info Dialog ***/
+
+.room-info {
+    border-collapse: collapse;
+    width: 100%;
+}
+
+.room-info-event {
+    border-bottom: 1pt solid black;
+}
+
+.room-info-event-meta {
+    padding-top: 1em;
+    padding-bottom: 1em;
+}
+
+.room-info-event-content {
+    padding-top: 1em;
+    padding-bottom: 1em;
+}
+
+.monospace {
+    font-family: monospace;
+}
+
+.redact-button {
+    float: left
+}
+
+.room-info-textarea-content {
+    height: auto;
+    width: 100%;
+    resize: vertical;
+}
+
+/*** Control Buttons ***/
+#controlButtons {
+    float: right;
+    margin-right: -4px;
+    padding-bottom: 6px;
+}
+
+.controlButton {
+    cursor: pointer;
+    border: 0px;
+    width: 30px;
+    height: 30px;
+    margin-left: 3px;
+    margin-right: 3px;
 }
 
 /*** Participant list ***/
 
 #usersTableWrapper {
     float: right;
-    width: 120px;
+    clear: right;
+    width: 101px;
     height: 100%;
     overflow-y: auto;
 }
 
+/*
 #usersTable {
     width: 100%;
     border-collapse: collapse;
@@ -442,36 +507,66 @@ textarea, input {
     position: relative;
     background-color: #000;
 }
+*/
 
-.userAvatar .userAvatarImage {
-    position: absolute;
-    top: 0px;
+.userAvatar {
+}
+
+.userAvatarFrame {
+    border-radius: 46px;
+    width: 80px;
+    margin: auto;
+    position: relative;
+    border: 3px solid #aaa;
+    background-color: #aaa;
+}
+
+.userAvatarImage {
+    border-radius: 40px;
+    text-align: center;
     object-fit: cover;
-    width: 100%;
+    display: block;
 }
 
+/*
 .userAvatar .userAvatarGradient {
     position: absolute;
     bottom: 20px;
     width: 100%;
 }
+*/
 
-.userAvatar .userName {
-    position: absolute;
-    color: #fff;
-    margin: 2px;
-    bottom: 0px;
+.userName {
+    margin-top: 3px;
+    margin-bottom: 6px;
+    text-align: center;
     font-size: 12px;
-    word-break: break-all;
+    word-wrap: break-word;
 }
 
-.userAvatar .userPowerLevel {
+.userPowerLevel {
     position: absolute;
+    bottom: -1px;
+    height: 1px;
+    background-color: #f00;
+}
+
+.userPowerLevelBar {
+    display: inline;
+    position: absolute;
+    width: 2px;
+    height: 10px;
+/*    border: 1px solid #000;
+*/    background-color: #aaa;
+}
+
+.userPowerLevelMeter {
+    position: relative;
     bottom: 0px;
-    height: 2px;
     background-color: #f00;
 }
 
+/*
 .userPresence {
     text-align: center;
     font-size: 12px;
@@ -479,12 +574,15 @@ textarea, input {
     background-color: #aaa;
     border-bottom: 1px #ddd solid;
 }
+*/
 
 .online {
+    border-color: #38AF00;
     background-color: #38AF00;
 }
 
 .unavailable {
+    border-color: #FFCC00;
     background-color: #FFCC00;
 }
 
@@ -507,18 +605,21 @@ textarea, input {
         
 #messageTable td {
     padding: 0px;
+/*    border: 1px solid #888; */
 }
 
 .leftBlock {
-    width: 14em;
+    width: 7em;
     word-wrap: break-word;
     vertical-align: top;
     background-color: #fff;
-    color: #888;
+    color: #aaa;
     font-weight: medium;
     font-size: 12px;
     text-align: right;
+/*
     border-top: 1px #ddd solid;
+*/
 }
 
 .rightBlock {
@@ -529,13 +630,24 @@ textarea, input {
 }        
 
 .sender, .timestamp {
-    padding-right: 1em;
-    padding-left: 1em;
-    padding-top: 3px;
+/*    padding-top: 3px;
+*/}
+
+.timestamp {
+    font-size: 10px;
+    color: #ccc;
+    height: 13px;
+    margin-top: 4px;
+    transition-property: opacity;
+    transition-duration: 0.3s;
 }
 
 .sender {
-    margin-bottom: -3px;
+    font-size: 12px;
+/*    
+    margin-top: 5px;
+    margin-bottom: -9px;
+*/
 }
 
 .avatar {
@@ -546,7 +658,11 @@ textarea, input {
 }
 
 .avatarImage {
+    position: relative;
+    top: 5px;
     object-fit: cover;
+    border-radius: 32px;
+    margin-top: 4px;
 }
         
 .emote {
@@ -560,6 +676,7 @@ textarea, input {
 }
 
 .image {
+    border: 1px solid #888;
     display: block;
     max-width:320px;
     max-height:320px;
@@ -572,19 +689,23 @@ textarea, input {
 }
 
 .bubble {
+/*
     background-color: #eee;
     border: 1px solid #d8d8d8;
-    display: inline-block;
     margin-bottom: -1px;
-    max-width: 90%;
-    font-size: 14px;
-    word-wrap: break-word;
     padding-top: 7px;
     padding-bottom: 5px;
+    -webkit-text-size-adjust:100%
+    vertical-align: middle;
+*/
+    display: inline-block;
+    max-width: 90%;
     padding-left: 1em;
     padding-right: 1em;
-    vertical-align: middle;
-    -webkit-text-size-adjust:100%
+    padding-top: 2px;
+    padding-bottom: 2px;
+    font-size: 14px;
+    word-wrap: break-word;
 }
 
 .bubble img {
@@ -592,8 +713,8 @@ textarea, input {
     max-height: auto;
 }
 
-.differentUser td {
-    padding-bottom: 5px ! important;
+.differentUser .msg {
+    padding-top: 14px ! important;
 }
 
 .mine {
@@ -604,13 +725,15 @@ textarea, input {
 .text.membership .bubble,
 .mine .text.emote .bubble,
 .mine .text.membership .bubble
-  {
+{
     background-color: transparent ! important;    
     border: 0px ! important;
 }
 
 .mine .text .bubble {
+/*
     background-color: #f8f8ff ! important;    
+*/
     text-align: left ! important;
 }
 
@@ -670,6 +793,8 @@ textarea, input {
     overflow: hidden;
     white-space: nowrap;
     text-overflow: ellipsis;
+    padding-left: 0.5em;
+    padding-right: 0.5em;
 }
 
 .recentsRoom {
@@ -684,6 +809,14 @@ textarea, input {
     background-color: #eee;
 }
 
+.recentsRoomUnread {
+    background-color: #fee;
+}
+
+.recentsRoomBing {
+    background-color: #eef;
+}
+
 .recentsRoomName {
     font-size: 16px;
     padding-top: 7px;
@@ -720,7 +853,7 @@ textarea, input {
     padding-right: 10px;
     margin-right: 10px;
     height: 100%;
-    border-right: 1px solid #ddd;
+/*    border-right: 1px solid #ddd; */
     overflow-y: auto;
 }
 
diff --git a/webclient/app.js b/syweb/webclient/app.js
index 099e2170a0..9e5b85820d 100644
--- a/webclient/app.js
+++ b/syweb/webclient/app.js
@@ -16,7 +16,6 @@ limitations under the License.
 
 var matrixWebClient = angular.module('matrixWebClient', [
     'ngRoute',
-    'ngAnimate',
     'MatrixWebClientController',
     'LoginController',
     'RegisterController',
@@ -30,8 +29,13 @@ var matrixWebClient = angular.module('matrixWebClient', [
     'MatrixCall',
     'eventStreamService',
     'eventHandlerService',
+    'notificationService',
+    'recentsService',
+    'modelService',
+    'commandsService',
     'infinite-scroll',
-    'ui.bootstrap'
+    'ui.bootstrap',
+    'monospaced.elastic'
 ]);
 
 matrixWebClient.config(['$routeProvider', '$provide', '$httpProvider',
diff --git a/webclient/bootstrap.css b/syweb/webclient/bootstrap.css
index 7ebcb2a007..7ebcb2a007 100644
--- a/webclient/bootstrap.css
+++ b/syweb/webclient/bootstrap.css
diff --git a/webclient/components/fileInput/file-input-directive.js b/syweb/webclient/components/fileInput/file-input-directive.js
index 9c849a140f..9c849a140f 100644
--- a/webclient/components/fileInput/file-input-directive.js
+++ b/syweb/webclient/components/fileInput/file-input-directive.js
diff --git a/webclient/components/fileUpload/file-upload-service.js b/syweb/webclient/components/fileUpload/file-upload-service.js
index e0f67b2c6c..b544e29509 100644
--- a/webclient/components/fileUpload/file-upload-service.js
+++ b/syweb/webclient/components/fileUpload/file-upload-service.js
@@ -64,7 +64,8 @@ angular.module('mFileUpload', ['matrixService', 'mUtilities'])
         var imageMessage = {
             msgtype: "m.image",
             url: undefined,
-            body: {
+            body: "Image",
+            info: {
                 size: undefined,
                 w: undefined,
                 h: undefined,
@@ -90,7 +91,7 @@ angular.module('mFileUpload', ['matrixService', 'mUtilities'])
                         function(url) {
                             // Update message metadata
                             imageMessage.url = url;
-                            imageMessage.body = {
+                            imageMessage.info = {
                                 size: imageFile.size,
                                 w: size.width,
                                 h: size.height,
@@ -101,7 +102,7 @@ angular.module('mFileUpload', ['matrixService', 'mUtilities'])
                             // reuse the original image info for thumbnail data
                             if (!imageMessage.thumbnail_url) {
                                 imageMessage.thumbnail_url = imageMessage.url;
-                                imageMessage.thumbnail_info = imageMessage.body;
+                                imageMessage.thumbnail_info = imageMessage.info;
                             }
 
                             // We are done
diff --git a/syweb/webclient/components/matrix/commands-service.js b/syweb/webclient/components/matrix/commands-service.js
new file mode 100644
index 0000000000..3c516ad1e4
--- /dev/null
+++ b/syweb/webclient/components/matrix/commands-service.js
@@ -0,0 +1,164 @@
+/*
+Copyright 2014 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+/*
+This service contains logic for parsing and performing IRC style commands.
+*/
+angular.module('commandsService', [])
+.factory('commandsService', ['$q', '$location', 'matrixService', 'modelService', function($q, $location, matrixService, modelService) {
+
+    // create a rejected promise with the given message
+    var reject = function(msg) {
+        var deferred = $q.defer();
+        deferred.reject({
+            data: {
+                error: msg
+            }
+        });
+        return deferred.promise;
+    };
+    
+    // Change your nickname
+    var doNick = function(room_id, args) {
+        if (args) {
+            return matrixService.setDisplayName(args);                     
+        }
+        return reject("Usage: /nick <display_name>");
+    };
+    
+    // Join a room
+    var doJoin = function(room_id, args) {
+        if (args) {
+            var matches = args.match(/^(\S+)$/);
+            if (matches) {
+                var room_alias = matches[1];
+                $location.url("room/" + room_alias);
+                // NB: We don't need to actually do the join, since that happens
+                // automatically if we are not joined onto a room already when
+                // the page loads.
+                return reject("Joining "+room_alias);
+            }
+        }
+        return reject("Usage: /join <room_alias>");
+    };
+    
+    // Kick a user from the room with an optional reason
+    var doKick = function(room_id, args) {
+        if (args) {
+            var matches = args.match(/^(\S+?)( +(.*))?$/);
+            if (matches) {
+                return matrixService.kick(room_id, matches[1], matches[3]);
+            }
+        }
+        return reject("Usage: /kick <userId> [<reason>]");
+    };
+    
+    // Ban a user from the room with an optional reason
+    var doBan = function(room_id, args) {
+        if (args) {
+            var matches = args.match(/^(\S+?)( +(.*))?$/);
+            if (matches) {
+                return matrixService.ban(room_id, matches[1], matches[3]);
+            }
+        }
+        return reject("Usage: /ban <userId> [<reason>]");
+    };
+    
+    // Unban a user from the room
+    var doUnban = function(room_id, args) {
+        if (args) {
+            var matches = args.match(/^(\S+)$/);
+            if (matches) {
+                // Reset the user membership to "leave" to unban him
+                return matrixService.unban(room_id, matches[1]);
+            }
+        }
+        return reject("Usage: /unban <userId>");
+    };
+    
+    // Define the power level of a user
+    var doOp = function(room_id, args) {
+        if (args) {
+            var matches = args.match(/^(\S+?)( +(\d+))?$/);
+            var powerLevel = 50; // default power level for op
+            if (matches) {
+                var user_id = matches[1];
+                if (matches.length === 4 && undefined !== matches[3]) {
+                    powerLevel = parseInt(matches[3]);
+                }
+                if (powerLevel !== NaN) {
+                    var powerLevelEvent = modelService.getRoom(room_id).current_room_state.state("m.room.power_levels");
+                    return matrixService.setUserPowerLevel(room_id, user_id, powerLevel, powerLevelEvent);
+                }
+            }
+        }
+        return reject("Usage: /op <userId> [<power level>]");
+    };
+    
+    // Reset the power level of a user
+    var doDeop = function(room_id, args) {
+        if (args) {
+            var matches = args.match(/^(\S+)$/);
+            if (matches) {
+                var powerLevelEvent = modelService.getRoom(room_id).current_room_state.state("m.room.power_levels");
+                return matrixService.setUserPowerLevel(room_id, args, undefined, powerLevelEvent);
+            }
+        }
+        return reject("Usage: /deop <userId>");
+    };
+
+
+    var commands = {
+        "nick": doNick,
+        "join": doJoin,
+        "kick": doKick,
+        "ban": doBan,
+        "unban": doUnban,
+        "op": doOp,
+        "deop": doDeop
+    };
+    
+    return {
+    
+        /**
+         * Process the given text for commands and perform them.
+         * @param {String} roomId The room in which the input was performed.
+         * @param {String} input The raw text input by the user.
+         * @return {Promise} A promise of the pending command, or null if the 
+         *                   input is not a command.
+         */
+        processInput: function(roomId, input) {
+            // trim any trailing whitespace, as it can confuse the parser for 
+            // IRC-style commands
+            input = input.replace(/\s+$/, "");
+            if (input[0] === "/" && input[1] !== "/") {
+                var bits = input.match(/^(\S+?)( +(.*))?$/);
+                var cmd = bits[1].substring(1);
+                var args = bits[3];
+                if (commands[cmd]) {
+                    return commands[cmd](roomId, args);
+                }
+                return reject("Unrecognised IRC-style command: " + cmd); 
+            }
+            return null; // not a command
+        }
+    
+    };
+
+}]);
+
diff --git a/syweb/webclient/components/matrix/event-handler-service.js b/syweb/webclient/components/matrix/event-handler-service.js
new file mode 100644
index 0000000000..efe7bf234c
--- /dev/null
+++ b/syweb/webclient/components/matrix/event-handler-service.js
@@ -0,0 +1,570 @@
+/*
+Copyright 2014 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+/*
+This service handles what should happen when you get an event. This service does
+not care where the event came from, it only needs enough context to be able to 
+process them. Events may be coming from the event stream, the REST API (via 
+direct GETs or via a pagination stream API), etc.
+
+Typically, this service will store events and broadcast them to any listeners
+(e.g. controllers) via $broadcast. 
+*/
+angular.module('eventHandlerService', [])
+.factory('eventHandlerService', ['matrixService', '$rootScope', '$q', '$timeout', '$filter', 'mPresence', 'notificationService', 'modelService',
+function(matrixService, $rootScope, $q, $timeout, $filter, mPresence, notificationService, modelService) {
+    var ROOM_CREATE_EVENT = "ROOM_CREATE_EVENT";
+    var MSG_EVENT = "MSG_EVENT";
+    var MEMBER_EVENT = "MEMBER_EVENT";
+    var PRESENCE_EVENT = "PRESENCE_EVENT";
+    var POWERLEVEL_EVENT = "POWERLEVEL_EVENT";
+    var CALL_EVENT = "CALL_EVENT";
+    var NAME_EVENT = "NAME_EVENT";
+    var TOPIC_EVENT = "TOPIC_EVENT";
+    var RESET_EVENT = "RESET_EVENT";    // eventHandlerService has been resetted
+
+    // used for dedupping events - could be expanded in future...
+    // FIXME: means that we leak memory over time (along with lots of the rest
+    // of the app, given we never try to reap memory yet)
+    var eventMap = {};
+
+    var initialSyncDeferred;
+
+    var reset = function() {
+        initialSyncDeferred = $q.defer();
+
+        eventMap = {};
+    };
+    reset();
+
+    var resetRoomMessages = function(room_id) {
+        var room = modelService.getRoom(room_id);
+        room.events = [];
+    };
+    
+    // Generic method to handle events data
+    var handleRoomStateEvent = function(event, isLiveEvent, addToRoomMessages) {
+        var room = modelService.getRoom(event.room_id);
+        if (addToRoomMessages) {
+            // some state events are displayed as messages, so add them.
+            room.addMessageEvent(event, !isLiveEvent);
+        }
+        
+        if (isLiveEvent) {
+            // update the current room state with the latest state
+            room.current_room_state.storeStateEvent(event);
+        }
+        else {
+            var eventTs = event.origin_server_ts;
+            var storedEvent = room.current_room_state.getStateEvent(event.type, event.state_key);
+            if (storedEvent) {
+                if (storedEvent.origin_server_ts < eventTs) {
+                    // the incoming event is newer, use it.
+                    room.current_room_state.storeStateEvent(event);
+                }
+            }
+        }
+        // TODO: handle old_room_state
+    };
+    
+    var handleRoomCreate = function(event, isLiveEvent) {
+        $rootScope.$broadcast(ROOM_CREATE_EVENT, event, isLiveEvent);
+    };
+
+    var handleRoomAliases = function(event, isLiveEvent) {
+        modelService.createRoomIdToAliasMapping(event.room_id, event.content.aliases[0]);
+    };
+    
+    var containsBingWord = function(event) {
+        if (!event.content || !event.content.body) {
+            return false;
+        }
+    
+        return notificationService.containsBingWord(
+            matrixService.config().user_id,
+            matrixService.config().display_name,
+            matrixService.config().bingWords,
+            event.content.body
+        );
+    };
+    
+    var displayNotification = function(event) {
+        if (window.Notification && event.user_id != matrixService.config().user_id) {
+            var member = modelService.getMember(event.room_id, event.user_id);
+            var displayname = $filter("mUserDisplayName")(event.user_id, event.room_id);
+            var message;
+            var shouldBing = false;
+            
+            if (event.type === "m.room.message") {
+                shouldBing = containsBingWord(event);
+                message = event.content.body;
+                if (event.content.msgtype === "m.emote") {
+                    message = "* " + displayname + " " + message;
+                }
+                else if (event.content.msgtype === "m.image") {
+                    message = displayname + " sent an image.";
+                }
+            }
+            else if (event.type == "m.room.member") {
+                // Notify when another user joins only
+                if (event.state_key !== matrixService.config().user_id  && "join" === event.content.membership) {
+                    member = modelService.getMember(event.room_id, event.state_key);
+                    displayname = $filter("mUserDisplayName")(event.state_key, event.room_id);
+                    message = displayname + " joined";
+                    shouldBing = true;
+                }
+                else {
+                    return;
+                }
+            }
+
+            // Ideally we would notify only when the window is hidden (i.e. document.hidden = true).
+            //
+            // However, Chrome on Linux and OSX currently returns document.hidden = false unless the window is
+            // explicitly showing a different tab.  So we need another metric to determine hiddenness - we
+            // simply use idle time.  If the user has been idle enough that their presence goes to idle, then
+            // we also display notifs when things happen.
+            //
+            // This is far far better than notifying whenever anything happens anyway, otherwise you get spammed
+            // to death with notifications when the window is in the foreground, which is horrible UX (especially
+            // if you have not defined any bingers and so get notified for everything).
+            var isIdle = (document.hidden || matrixService.presence.unavailable === mPresence.getState());
+            
+            // We need a way to let people get notifications for everything, if they so desire.  The way to do this
+            // is to specify zero bingwords.
+            var bingWords = matrixService.config().bingWords;
+            if (bingWords === undefined || bingWords.length === 0) {
+                shouldBing = true;
+            }
+            
+            if (shouldBing && isIdle) {
+                console.log("Displaying notification for "+JSON.stringify(event));
+
+                var roomTitle = $filter("mRoomName")(event.room_id);
+                
+                notificationService.showNotification(
+                    displayname + " (" + roomTitle + ")",
+                    message,
+                    member ? member.event.content.avatar_url : undefined,
+                    function() {
+                        console.log("notification.onclick() room=" + event.room_id);
+                        $rootScope.goToPage('room/' + event.room_id); 
+                    }
+                );
+            }
+        }
+    };
+
+    var handleMessage = function(event, isLiveEvent) {
+        // Check for empty event content
+        var hasContent = false;
+        for (var prop in event.content) {
+            hasContent = true;
+            break;
+        }
+        if (!hasContent) {
+            // empty json object is a redacted event, so ignore.
+            return;
+        }
+        
+        // =======================
+        
+        var room = modelService.getRoom(event.room_id);
+        
+        if (event.user_id !== matrixService.config().user_id) {
+            room.addMessageEvent(event, !isLiveEvent);
+            displayNotification(event);
+        }
+        else {
+            // we may have locally echoed this, so we should replace the event
+            // instead of just adding.
+            room.addOrReplaceMessageEvent(event, !isLiveEvent);
+        }
+        
+        // TODO send delivery receipt if isLiveEvent
+        
+        $rootScope.$broadcast(MSG_EVENT, event, isLiveEvent);
+    };
+    
+    var handleRoomMember = function(event, isLiveEvent, isStateEvent) {
+        var room = modelService.getRoom(event.room_id);
+        
+        // did something change?
+        var memberChanges = undefined;
+        if (!isStateEvent) {
+            // could be a membership change, display name change, etc.
+            // Find out which one.
+            if ((event.prev_content === undefined && event.content.membership) || (event.prev_content && (event.prev_content.membership !== event.content.membership))) {
+                memberChanges = "membership";
+            }
+            else if (event.prev_content && (event.prev_content.displayname !== event.content.displayname)) {
+                memberChanges = "displayname";
+            }
+            // mark the key which changed
+            event.changedKey = memberChanges;
+        }
+        
+        
+        // modify state before adding the message so it points to the right thing.
+        // The events are copied to avoid referencing the same event when adding
+        // the message (circular json structures)
+        if (isStateEvent || isLiveEvent) {
+            var newEvent = angular.copy(event);
+            newEvent.cnt = event.content;
+            room.current_room_state.storeStateEvent(newEvent);
+        }
+        else if (!isLiveEvent) {
+            // mutate the old room state
+            var oldEvent = angular.copy(event);
+            oldEvent.cnt = event.content;
+            if (event.prev_content) {
+                // the m.room.member event we are handling is the NEW event. When
+                // we keep going back in time, we want the PREVIOUS value for displaying
+                // names/etc, hence the clobber here.
+                oldEvent.cnt = event.prev_content;
+            }
+            
+            if (event.changedKey === "membership" && event.content.membership === "join") {
+                // join has a prev_content but it doesn't contain all the info unlike the join, so use that.
+                oldEvent.cnt = event.content;
+            }
+            
+            room.old_room_state.storeStateEvent(oldEvent);
+        }
+        
+        // If there was a change we want to display, dump it in the message
+        // list. This has to be done after room state is updated.
+        if (memberChanges) {
+            room.addMessageEvent(event, !isLiveEvent);
+            
+            if (memberChanges === "membership" && isLiveEvent) {
+                displayNotification(event);
+            }
+        }
+        
+        
+        
+        $rootScope.$broadcast(MEMBER_EVENT, event, isLiveEvent, isStateEvent);
+    };
+    
+    var handlePresence = function(event, isLiveEvent) {
+        // presence is always current, so clobber.
+        modelService.setUser(event);
+        $rootScope.$broadcast(PRESENCE_EVENT, event, isLiveEvent);
+    };
+    
+    var handlePowerLevels = function(event, isLiveEvent) {
+        handleRoomStateEvent(event, isLiveEvent);
+        $rootScope.$broadcast(POWERLEVEL_EVENT, event, isLiveEvent);   
+    };
+
+    var handleRoomName = function(event, isLiveEvent, isStateEvent) {
+        console.log("handleRoomName room_id: " + event.room_id + " - isLiveEvent: " + isLiveEvent + " - name: " + event.content.name);
+        handleRoomStateEvent(event, isLiveEvent, !isStateEvent);
+        $rootScope.$broadcast(NAME_EVENT, event, isLiveEvent);
+    };
+    
+
+    var handleRoomTopic = function(event, isLiveEvent, isStateEvent) {
+        console.log("handleRoomTopic room_id: " + event.room_id + " - isLiveEvent: " + isLiveEvent + " - topic: " + event.content.topic);
+        handleRoomStateEvent(event, isLiveEvent, !isStateEvent);
+        $rootScope.$broadcast(TOPIC_EVENT, event, isLiveEvent);
+    };
+
+    var handleCallEvent = function(event, isLiveEvent) {
+        $rootScope.$broadcast(CALL_EVENT, event, isLiveEvent);
+        if (event.type === 'm.call.invite') {
+            var room = modelService.getRoom(event.room_id);
+            room.addMessageEvent(event, !isLiveEvent);
+        }
+    };
+
+    var handleRedaction = function(event, isLiveEvent) {
+        if (!isLiveEvent) {
+            // we have nothing to remove, so just ignore it.
+            console.log("Received redacted event: "+JSON.stringify(event));
+            return;
+        }
+
+        // we need to remove something possibly: do we know the redacted
+        // event ID?
+        if (eventMap[event.redacts]) {
+            var room = modelService.getRoom(event.room_id);
+            // remove event from list of messages in this room.
+            var eventList = room.events;
+            for (var i=0; i<eventList.length; i++) {
+                if (eventList[i].event_id === event.redacts) {
+                    console.log("Removing event " + event.redacts);
+                    eventList.splice(i, 1);
+                    break;
+                }
+            }
+
+            console.log("Redacted an event.");
+        }
+    }
+
+    return {
+        ROOM_CREATE_EVENT: ROOM_CREATE_EVENT,
+        MSG_EVENT: MSG_EVENT,
+        MEMBER_EVENT: MEMBER_EVENT,
+        PRESENCE_EVENT: PRESENCE_EVENT,
+        POWERLEVEL_EVENT: POWERLEVEL_EVENT,
+        CALL_EVENT: CALL_EVENT,
+        NAME_EVENT: NAME_EVENT,
+        TOPIC_EVENT: TOPIC_EVENT,
+        RESET_EVENT: RESET_EVENT,
+        
+        reset: function() {
+            reset();
+            $rootScope.$broadcast(RESET_EVENT);
+        },
+    
+        handleEvent: function(event, isLiveEvent, isStateEvent) {
+
+            // Avoid duplicated events
+            // Needed for rooms where initialSync has not been done. 
+            // In this case, we do not know where to start pagination. So, it starts from the END
+            // and we can have the same event (ex: joined, invitation) coming from the pagination
+            // AND from the event stream.
+            // FIXME: This workaround should be no more required when /initialSync on a particular room
+            // will be available (as opposite to the global /initialSync done at startup)
+            if (!isStateEvent) {    // Do not consider state events
+                if (event.event_id && eventMap[event.event_id]) {
+                    console.log("discarding duplicate event: " + JSON.stringify(event, undefined, 4));
+                    return;
+                }
+                else {
+                    eventMap[event.event_id] = 1;
+                }
+            }
+
+            if (event.type.indexOf('m.call.') === 0) {
+                handleCallEvent(event, isLiveEvent);
+            }
+            else {            
+                switch(event.type) {
+                    case "m.room.create":
+                        handleRoomCreate(event, isLiveEvent);
+                        break;
+                    case "m.room.aliases":
+                        handleRoomAliases(event, isLiveEvent);
+                        break;
+                    case "m.room.message":
+                        handleMessage(event, isLiveEvent);
+                        break;
+                    case "m.room.member":
+                        handleRoomMember(event, isLiveEvent, isStateEvent);
+                        break;
+                    case "m.presence":
+                        handlePresence(event, isLiveEvent);
+                        break;
+                    case 'm.room.ops_levels':
+                    case 'm.room.send_event_level':
+                    case 'm.room.add_state_level':
+                    case 'm.room.join_rules':
+                    case 'm.room.power_levels':
+                        handlePowerLevels(event, isLiveEvent);
+                        break;
+                    case 'm.room.name':
+                        handleRoomName(event, isLiveEvent, isStateEvent);
+                        break;
+                    case 'm.room.topic':
+                        handleRoomTopic(event, isLiveEvent, isStateEvent);
+                        break;
+                    case 'm.room.redaction':
+                        handleRedaction(event, isLiveEvent);
+                        break;
+                    default:
+                        // if it is a state event, then just add it in so it
+                        // displays on the Room Info screen.
+                        if (typeof(event.state_key) === "string") { // incls. 0-len strings
+                            if (event.room_id) {
+                                handleRoomStateEvent(event, isLiveEvent, false);
+                            }
+                        }
+                        console.log("Unable to handle event type " + event.type);
+                        // console.log(JSON.stringify(event, undefined, 4));
+                        break;
+                }
+            }
+        },
+        
+        // isLiveEvents determines whether notifications should be shown, whether
+        // messages get appended to the start/end of lists, etc.
+        handleEvents: function(events, isLiveEvents, isStateEvents) {
+            for (var i=0; i<events.length; i++) {
+                this.handleEvent(events[i], isLiveEvents, isStateEvents);
+            }
+        },
+
+        // Handle messages from /initialSync or /messages
+        handleRoomMessages: function(room_id, messages, isLiveEvents, dir) {
+            var events = messages.chunk;
+
+            // Handles messages according to their time order
+            if (dir && 'b' === dir) {
+                // paginateBackMessages requests messages to be in reverse chronological order
+                for (var i=0; i<events.length; i++) {
+                    this.handleEvent(events[i], isLiveEvents, isLiveEvents);
+                }
+                
+                // Store how far back we've paginated
+                var room = modelService.getRoom(room_id);
+                room.old_room_state.pagination_token = messages.end;
+
+            }
+            else {
+                // InitialSync returns messages in chronological order, so invert
+                // it to get most recent > oldest
+                for (var i=events.length - 1; i>=0; i--) {
+                    this.handleEvent(events[i], isLiveEvents, isLiveEvents);
+                }
+                // Store where to start pagination
+                var room = modelService.getRoom(room_id);
+                room.old_room_state.pagination_token = messages.start;
+            }
+        },
+
+        handleInitialSyncDone: function(response) {
+            console.log("# handleInitialSyncDone");
+
+            var rooms = response.data.rooms;
+            for (var i = 0; i < rooms.length; ++i) {
+                var room = rooms[i];
+                
+                // FIXME: This is ming: the HS should be sending down the m.room.member
+                // event for the invite in .state but it isn't, so fudge it for now.
+                if (room.inviter && room.membership === "invite") {
+                    var me = matrixService.config().user_id;
+                    var fakeEvent = {
+                        event_id: "__FAKE__" + room.room_id,
+                        user_id: room.inviter,
+                        origin_server_ts: 0,
+                        room_id: room.room_id,
+                        state_key: me,
+                        type: "m.room.member",
+                        content: {
+                            membership: "invite"
+                        }
+                    };
+                    if (!room.state) {
+                        room.state = [];
+                    }
+                    room.state.push(fakeEvent);
+                    console.log("RECV /initialSync invite >> "+room.room_id);
+                }
+                
+                var newRoom = modelService.getRoom(room.room_id);
+                newRoom.current_room_state.storeStateEvents(room.state);
+                newRoom.old_room_state.storeStateEvents(room.state);
+
+                // this should be done AFTER storing state events since these
+                // messages may make the old_room_state diverge.
+                if ("messages" in room) {
+                    this.handleRoomMessages(room.room_id, room.messages, false);
+                    newRoom.current_room_state.pagination_token = room.messages.end;
+                    newRoom.old_room_state.pagination_token = room.messages.start;
+                }
+            }
+            var presence = response.data.presence;
+            this.handleEvents(presence, false);
+
+            initialSyncDeferred.resolve(response);
+        },
+
+        // Returns a promise that resolves when the initialSync request has been processed
+        waitForInitialSyncCompletion: function() {
+            return initialSyncDeferred.promise;
+        },
+
+        resetRoomMessages: function(room_id) {
+            resetRoomMessages(room_id);
+        },
+        
+        eventContainsBingWord: function(event) {
+            return containsBingWord(event);
+        },
+        
+        /**
+         * Return the last message event of a room
+         * @param {String} room_id the room id
+         * @param {Boolean} filterFake true to not take into account fake messages
+         * @returns {undefined | Event} the last message event if available
+         */
+        getLastMessage: function(room_id, filterEcho) {
+            var lastMessage;
+
+            var events = modelService.getRoom(room_id).events;
+            for (var i = events.length - 1; i >= 0; i--) {
+                var message = events[i];
+
+                if (!filterEcho || undefined === message.echo_msg_state) {
+                    lastMessage = message;
+                    break;
+                }
+            }
+
+            return lastMessage;
+        },
+        
+        /**
+         * Compute the room users number, ie the number of members who has joined the room.
+         * @param {String} room_id the room id
+         * @returns {undefined | Number} the room users number if available
+         */
+        getUsersCountInRoom: function(room_id) {
+            var memberCount;
+
+            var room = modelService.getRoom(room_id);
+            memberCount = 0;
+            for (var i in room.current_room_state.members) {
+                if (!room.current_room_state.members.hasOwnProperty(i)) continue;
+
+                var member = room.current_room_state.members[i].event;
+
+                if ("join" === member.content.membership) {
+                    memberCount = memberCount + 1;
+                }
+            }
+
+            return memberCount;
+        },
+        
+        /**
+         * Return the power level of an user in a particular room
+         * @param {String} room_id the room id
+         * @param {String} user_id the user id
+         * @returns {Number} a value between 0 and 10
+         */
+        getUserPowerLevel: function(room_id, user_id) {
+            var powerLevel = 0;
+            var room = modelService.getRoom(room_id).current_room_state;
+            if (room.state("m.room.power_levels")) {
+                if (user_id in room.state("m.room.power_levels").content) {
+                    powerLevel = room.state("m.room.power_levels").content[user_id];
+                }
+                else {
+                    // Use the room default user power
+                    powerLevel = room.state("m.room.power_levels").content["default"];
+                }
+            }
+            return powerLevel;
+        }
+    };
+}]);
diff --git a/webclient/components/matrix/event-stream-service.js b/syweb/webclient/components/matrix/event-stream-service.js
index 05469a3ded..c03f0b953b 100644
--- a/webclient/components/matrix/event-stream-service.js
+++ b/syweb/webclient/components/matrix/event-stream-service.js
@@ -109,25 +109,6 @@ angular.module('eventStreamService', [])
         // without requiring to make an additional request
         matrixService.initialSync(30, false).then(
             function(response) {
-                var rooms = response.data.rooms;
-                for (var i = 0; i < rooms.length; ++i) {
-                    var room = rooms[i];
-                    
-                    eventHandlerService.initRoom(room);
-
-                    if ("messages" in room) {
-                        eventHandlerService.handleRoomMessages(room.room_id, room.messages, false);
-                    }
-                    
-                    if ("state" in room) {
-                        eventHandlerService.handleEvents(room.state, false, true);
-                    }
-                }
-
-                var presence = response.data.presence;
-                eventHandlerService.handleEvents(presence, false);
-
-                // Initial sync is done
                 eventHandlerService.handleInitialSyncDone(response);
 
                 // Start event streaming from that point
diff --git a/webclient/components/matrix/matrix-call.js b/syweb/webclient/components/matrix/matrix-call.js
index 3e8811e5fc..56431817d9 100644
--- a/webclient/components/matrix/matrix-call.js
+++ b/syweb/webclient/components/matrix/matrix-call.js
@@ -35,19 +35,16 @@ var forAllTracksOnStream = function(s, f) {
     forAllAudioTracksOnStream(s, f);
 }
 
-navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
-window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection; // but not mozRTCPeerConnection because its interface is not compatible
-window.RTCSessionDescription = window.RTCSessionDescription || window.webkitRTCSessionDescription || window.mozRTCSessionDescription;
-window.RTCIceCandidate = window.RTCIceCandidate || window.webkitRTCIceCandidate || window.mozRTCIceCandidate;
-
-// Returns true if the browser supports all required features to make WebRTC call
-var isWebRTCSupported = function () {
-    return !!(navigator.getUserMedia || window.RTCPeerConnection || window.RTCSessionDescription || window.RTCIceCandidate);
-};
-
 angular.module('MatrixCall', [])
-.factory('MatrixCall', ['matrixService', 'matrixPhoneService', '$rootScope', '$timeout', function MatrixCallFactory(matrixService, matrixPhoneService, $rootScope, $timeout) {
-    $rootScope.isWebRTCSupported = isWebRTCSupported();
+.factory('MatrixCall', ['matrixService', 'matrixPhoneService', 'modelService', '$rootScope', '$timeout', function MatrixCallFactory(matrixService, matrixPhoneService, modelService, $rootScope, $timeout) {
+    $rootScope.isWebRTCSupported = function () {
+        navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
+        window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection; // but not mozRTCPeerConnection because its interface is not compatible
+        window.RTCSessionDescription = window.RTCSessionDescription || window.webkitRTCSessionDescription || window.mozRTCSessionDescription;
+        window.RTCIceCandidate = window.RTCIceCandidate || window.webkitRTCIceCandidate || window.mozRTCIceCandidate;
+
+        return !!(navigator.getUserMedia || window.RTCPeerConnection || window.RTCSessionDescription || window.RTCIceCandidate);
+    };
 
     var MatrixCall = function(room_id) {
         this.room_id = room_id;
@@ -60,7 +57,7 @@ angular.module('MatrixCall', [])
         this.candidateSendTries = 0;
 
         var self = this;
-        $rootScope.$watch(this.remoteVideoElement, function (oldValue, newValue) {
+        $rootScope.$watch(this.getRemoteVideoElement(), function (oldValue, newValue) {
             self.tryPlayRemoteStream();
         });
 
@@ -85,7 +82,7 @@ angular.module('MatrixCall', [])
         });
     }
 
-    // FIXME: we should prevent any class from being placed or accepted before this has finished
+    // FIXME: we should prevent any calls from being placed or accepted before this has finished
     MatrixCall.getTurnServer();
 
     MatrixCall.CALL_TIMEOUT = 60000;
@@ -95,7 +92,8 @@ angular.module('MatrixCall', [])
         var pc;
         if (window.mozRTCPeerConnection) {
             var iceServers = [];
-            if (MatrixCall.turnServer) {
+            // https://github.com/EricssonResearch/openwebrtc/issues/85
+            if (MatrixCall.turnServer /*&& !this.isOpenWebRTC()*/) {
                 if (MatrixCall.turnServer.uris) {
                     for (var i = 0; i < MatrixCall.turnServer.uris.length; i++) {
                         iceServers.push({
@@ -113,7 +111,8 @@ angular.module('MatrixCall', [])
             pc = new window.mozRTCPeerConnection({"iceServers":iceServers});
         } else {
             var iceServers = [];
-            if (MatrixCall.turnServer) {
+            // https://github.com/EricssonResearch/openwebrtc/issues/85
+            if (MatrixCall.turnServer && !this.isOpenWebRTC()) {
                 if (MatrixCall.turnServer.uris) {
                     iceServers.push({
                         'urls': MatrixCall.turnServer.uris,
@@ -178,7 +177,8 @@ angular.module('MatrixCall', [])
         this.state = 'ringing';
         this.direction = 'inbound';
 
-        if (window.mozRTCPeerConnection) {
+        // This also applied to the Safari OpenWebRTC extension so let's just do this all the time at least for now
+        //if (window.mozRTCPeerConnection) {
             // firefox's RTCPeerConnection doesn't add streams until it starts getting media on them
             // so we need to figure out whether a video channel has been offered by ourselves.
             if (this.msg.offer.sdp.indexOf('m=video') > -1) {
@@ -186,7 +186,7 @@ angular.module('MatrixCall', [])
             } else {
                 this.type = 'voice';
             }
-        }
+        //}
 
         var self = this;
         $timeout(function() {
@@ -213,8 +213,8 @@ angular.module('MatrixCall', [])
 
         var self = this;
 
-        var roomMembers = $rootScope.events.rooms[this.room_id].members;
-        if (roomMembers[matrixService.config().user_id].membership != 'join') {
+        var roomMembers = modelService.getRoom(this.room_id).current_room_state.members;
+        if (roomMembers[matrixService.config().user_id].event.content.membership != 'join') {
             console.log("We need to join the room before we can accept this call");
             matrixService.join(this.room_id).then(function() {
                 self.answer();
@@ -254,8 +254,8 @@ angular.module('MatrixCall', [])
 
         // pausing now keeps the last frame (ish) of the video call in the video element
         // rather than it just turning black straight away
-        if (this.remoteVideoElement) this.remoteVideoElement.pause();
-        if (this.localVideoElement) this.localVideoElement.pause();
+        if (this.getRemoteVideoElement() && this.getRemoteVideoElement().pause) this.getRemoteVideoElement().pause();
+        if (this.getLocalVideoElement() && this.getLocalVideoElement().pause) this.getLocalVideoElement().pause();
 
         this.stopAllMedia();
         if (this.peerConn) this.peerConn.close();
@@ -280,11 +280,18 @@ angular.module('MatrixCall', [])
         }
         if (this.state == 'ended') return;
 
-        if (this.localVideoElement && this.type == 'video') {
+        var videoEl = this.getLocalVideoElement();
+
+        if (videoEl && this.type == 'video') {
             var vidTrack = stream.getVideoTracks()[0];
-            this.localVideoElement.src = URL.createObjectURL(stream);
-            this.localVideoElement.muted = true;
-            this.localVideoElement.play();
+            videoEl.autoplay = true;
+            videoEl.src = URL.createObjectURL(stream);
+            videoEl.muted = true;
+            var self = this;
+            $timeout(function() {
+                var vel = self.getLocalVideoElement();
+                if (vel.play) vel.play();
+            });
         }
 
         this.localAVStream = stream;
@@ -308,11 +315,18 @@ angular.module('MatrixCall', [])
     MatrixCall.prototype.gotUserMediaForAnswer = function(stream) {
         if (this.state == 'ended') return;
 
-        if (this.localVideoElement && this.type == 'video') {
+        var localVidEl = this.getLocalVideoElement();
+
+        if (localVidEl && this.type == 'video') {
+            localVidEl.autoplay = true;
             var vidTrack = stream.getVideoTracks()[0];
-            this.localVideoElement.src = URL.createObjectURL(stream);
-            this.localVideoElement.muted = true;
-            this.localVideoElement.play();
+            localVidEl.src = URL.createObjectURL(stream);
+            localVidEl.muted = true;
+            var self = this;
+            $timeout(function() {
+                var vel = self.getLocalVideoElement();
+                if (vel.play) vel.play();
+            });
         }
 
         this.localAVStream = stream;
@@ -341,11 +355,11 @@ angular.module('MatrixCall', [])
     }
 
     MatrixCall.prototype.gotRemoteIceCandidate = function(cand) {
-        console.log("Got remote ICE "+cand.sdpMid+" candidate: "+cand.candidate);
         if (this.state == 'ended') {
-            console.log("Ignoring remote ICE candidate because call has ended");
+            //console.log("Ignoring remote ICE candidate because call has ended");
             return;
         }
+        console.log("Got remote ICE "+cand.sdpMid+" candidate: "+cand.candidate);
         this.peerConn.addIceCandidate(new RTCIceCandidate(cand), function() {}, function(e) {});
     };
 
@@ -365,41 +379,46 @@ angular.module('MatrixCall', [])
             return;
         }
 
-        this.peerConn.setLocalDescription(description);
-
-        var content = {
-            version: 0,
-            call_id: this.call_id,
-            offer: description,
-            lifetime: MatrixCall.CALL_TIMEOUT
-        };
-        this.sendEventWithRetry('m.call.invite', content);
-
         var self = this;
-        $timeout(function() {
-            if (self.state == 'invite_sent') {
-                self.hangup('invite_timeout');
-            }
-        }, MatrixCall.CALL_TIMEOUT);
+        this.peerConn.setLocalDescription(description, function() {
+            var content = {
+                version: 0,
+                call_id: self.call_id,
+                // OpenWebRTC appears to add extra stuff (like the DTLS fingerprint) to the description
+                // when setting it on the peerconnection. According to the spec it should only add ICE
+                // candidates. Any ICE candidates that have already been generated at this point will
+                // probably be sent both in the offer and separately. Ho hum.
+                offer: self.peerConn.localDescription,
+                lifetime: MatrixCall.CALL_TIMEOUT
+            };
+            self.sendEventWithRetry('m.call.invite', content);
+
+            $timeout(function() {
+                if (self.state == 'invite_sent') {
+                    self.hangup('invite_timeout');
+                }
+            }, MatrixCall.CALL_TIMEOUT);
 
-        $rootScope.$apply(function() {
-            self.state = 'invite_sent';
-        });
+            $rootScope.$apply(function() {
+                self.state = 'invite_sent';
+            });
+        }, function() { console.log("Error setting local description!"); });
     };
 
     MatrixCall.prototype.createdAnswer = function(description) {
         console.log("Created answer: "+description);
-        this.peerConn.setLocalDescription(description);
-        var content = {
-            version: 0,
-            call_id: this.call_id,
-            answer: description
-        };
-        this.sendEventWithRetry('m.call.answer', content);
         var self = this;
-        $rootScope.$apply(function() {
-            self.state = 'connecting';
-        });
+        this.peerConn.setLocalDescription(description, function() {
+            var content = {
+                version: 0,
+                call_id: self.call_id,
+                answer: self.peerConn.localDescription
+            };
+            self.sendEventWithRetry('m.call.answer', content);
+            $rootScope.$apply(function() {
+                self.state = 'connecting';
+            });
+        }, function() { console.log("Error setting local description!"); } );
     };
 
     MatrixCall.prototype.getLocalOfferFailed = function(error) {
@@ -467,10 +486,17 @@ angular.module('MatrixCall', [])
     };
 
     MatrixCall.prototype.tryPlayRemoteStream = function(event) {
-        if (this.remoteVideoElement && this.remoteAVStream) {
-            var player = this.remoteVideoElement;
+        if (this.getRemoteVideoElement() && this.remoteAVStream) {
+            var player = this.getRemoteVideoElement();
+            player.autoplay = true;
             player.src = URL.createObjectURL(this.remoteAVStream);
-            player.play();
+            var self = this;
+            $timeout(function() {
+                var vel = self.getRemoteVideoElement();
+                if (vel.play) vel.play();
+                // OpenWebRTC does not support oniceconnectionstatechange yet
+                if (self.isOpenWebRTC()) self.state = 'connected';
+            });
         }
     };
 
@@ -502,8 +528,8 @@ angular.module('MatrixCall', [])
 
     MatrixCall.prototype.onHangupReceived = function(msg) {
         console.log("Hangup received");
-        if (this.remoteVideoElement) this.remoteVideoElement.pause();
-        if (this.localVideoElement) this.localVideoElement.pause();
+        if (this.getRemoteVideoElement() && this.getRemoteVideoElement().pause) this.getRemoteVideoElement().pause();
+        if (this.getLocalVideoElement() && this.getLocalVideoElement().pause) this.getLocalVideoElement().pause();
         this.state = 'ended';
         this.hangupParty = 'remote';
         this.hangupReason = msg.reason;
@@ -526,8 +552,8 @@ angular.module('MatrixCall', [])
             newCall.gotUserMediaForAnswer(this.localAVStream);
             delete(this.localAVStream);
         }
-        newCall.localVideoElement = this.localVideoElement;
-        newCall.remoteVideoElement = this.remoteVideoElement;
+        newCall.localVideoSelector = this.localVideoSelector;
+        newCall.remoteVideoSelector = this.remoteVideoSelector;
         this.successor = newCall;
         this.hangup(true);
     };
@@ -603,5 +629,31 @@ angular.module('MatrixCall', [])
         }, delayMs);
     };
 
+    MatrixCall.prototype.getLocalVideoElement = function() {
+        if (this.localVideoSelector) {
+            var t = angular.element(this.localVideoSelector);
+            if (t.length) return t[0];
+        }
+        return null;
+    };
+
+    MatrixCall.prototype.getRemoteVideoElement = function() {
+        if (this.remoteVideoSelector) {
+            var t = angular.element(this.remoteVideoSelector);
+            if (t.length) return t[0];
+        }
+        return null;
+    };
+
+    MatrixCall.prototype.isOpenWebRTC = function() {
+        var scripts = angular.element('script');
+        for (var i = 0; i < scripts.length; i++) {
+            if (scripts[i].src.indexOf("owr.js") > -1) {
+                return true;
+            }
+        }
+        return false;
+    };
+
     return MatrixCall;
 }]);
diff --git a/syweb/webclient/components/matrix/matrix-filter.js b/syweb/webclient/components/matrix/matrix-filter.js
new file mode 100644
index 0000000000..cef9235891
--- /dev/null
+++ b/syweb/webclient/components/matrix/matrix-filter.js
@@ -0,0 +1,172 @@
+/*
+ Copyright 2014 OpenMarket Ltd
+ 
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+ 
+ http://www.apache.org/licenses/LICENSE-2.0
+ 
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+'use strict';
+
+angular.module('matrixFilter', [])
+
+// Compute the room name according to information we have
+// TODO: It would be nice if this was stateless and had no dependencies. That would
+//       make the business logic here a lot easier to see.
+.filter('mRoomName', ['$rootScope', 'matrixService', 'modelService', 'mUserDisplayNameFilter',
+function($rootScope, matrixService, modelService, mUserDisplayNameFilter) {
+    return function(room_id) {
+        var roomName;
+
+        // If there is an alias, use it
+        // TODO: only one alias is managed for now
+        var alias = modelService.getRoomIdToAliasMapping(room_id);
+        var room = modelService.getRoom(room_id).current_room_state;
+        
+        var room_name_event = room.state("m.room.name");
+
+        // Determine if it is a public room
+        var isPublicRoom = false;
+        if (room.state("m.room.join_rules") && room.state("m.room.join_rules").content) {
+            isPublicRoom = ("public" === room.state("m.room.join_rules").content.join_rule);
+        }
+        
+        if (room_name_event) {
+            roomName = room_name_event.content.name;
+        }
+        else if (alias) {
+            roomName = alias;
+        }
+        else if (Object.keys(room.members).length > 0 && !isPublicRoom) { // Do not rename public room
+            var user_id = matrixService.config().user_id;
+            
+            // this is a "one to one" room and should have the name of the other user.
+            if (Object.keys(room.members).length === 2) {
+                for (var i in room.members) {
+                    if (!room.members.hasOwnProperty(i)) continue;
+
+                    var member = room.members[i].event;
+                    if (member.state_key !== user_id) {
+                        roomName = mUserDisplayNameFilter(member.state_key, room_id);
+                        if (!roomName) {
+                            roomName = member.state_key;
+                        }
+                        break;
+                    }
+                }
+            }
+            else if (Object.keys(room.members).length === 1) {
+                // this could be just us (self-chat) or could be the other person
+                // in a room if they have invited us to the room. Find out which.
+                var otherUserId = Object.keys(room.members)[0];
+                if (otherUserId === user_id) {
+                    // it's us, we may have been invited to this room or it could
+                    // be a self chat.
+                    if (room.members[otherUserId].event.content.membership === "invite") {
+                        // someone invited us, use the right ID.
+                        roomName = mUserDisplayNameFilter(room.members[otherUserId].event.user_id, room_id);
+                        if (!roomName) {
+                            roomName = room.members[otherUserId].event.user_id;
+                        }
+                    }
+                    else {
+                        roomName = mUserDisplayNameFilter(otherUserId, room_id);
+                        if (!roomName) {
+                            roomName = user_id;
+                        }
+                    }
+                }
+                else { // it isn't us, so use their name if we know it.
+                    roomName = mUserDisplayNameFilter(otherUserId, room_id);
+                    if (!roomName) {
+                        roomName = otherUserId;
+                    }
+                }
+            }
+            else if (Object.keys(room.members).length === 0) {
+                // this shouldn't be possible
+                console.error("0 members in room >> " + room_id);
+            }
+        }
+        
+
+        // Always show the alias in the room displayed name
+        if (roomName && alias && alias !== roomName) {
+            roomName += " (" + alias + ")";
+        }
+
+        if (undefined === roomName) {
+            // By default, use the room ID
+            roomName = room_id;
+        }
+
+        return roomName;
+    };
+}])
+
+// Return the user display name
+.filter('mUserDisplayName', ['modelService', 'matrixService', function(modelService, matrixService) {
+    /**
+     * Return the display name of an user acccording to data already downloaded
+     * @param {String} user_id the id of the user
+     * @param {String} room_id the room id
+     * @param {boolean} wrap whether to insert whitespace into the userid (if displayname not available) to help it wrap
+     * @returns {String} A suitable display name for the user.
+     */
+    return function(user_id, room_id, wrap) {
+        var displayName;
+
+        // Get the user display name from the member list of the room
+        var member = modelService.getMember(room_id, user_id);
+        if (member) {
+            member = member.event;
+        }
+        if (member && member.content.displayname) { // Do not consider null displayname
+            displayName = member.content.displayname;
+
+            // Disambiguate users who have the same displayname in the room
+            if (user_id !== matrixService.config().user_id) {
+                var room = modelService.getRoom(room_id);
+
+                for (var member_id in room.current_room_state.members) {
+                    if (room.current_room_state.members.hasOwnProperty(member_id) && member_id !== user_id) {
+                        var member2 = room.current_room_state.members[member_id].event;
+                        if (member2.content.displayname && member2.content.displayname === displayName) {
+                            displayName = displayName + " (" + user_id + ")";
+                            break;
+                        }
+                    }
+                }
+            }
+        }
+
+        // The user may not have joined the room yet. So try to resolve display name from presence data
+        // Note: This data may not be available
+        if (undefined === displayName) {
+            var usr = modelService.getUser(user_id);
+            if (usr) {
+                displayName = usr.event.content.displayname;
+            }
+        }
+
+        if (undefined === displayName) {
+            // By default, use the user ID
+            if (wrap && user_id.indexOf(':') >= 0) {
+                displayName = user_id.substr(0, user_id.indexOf(':')) + " " + user_id.substr(user_id.indexOf(':'));
+            }
+            else {
+                displayName = user_id;
+            }
+        }
+        
+        return displayName;
+    };
+}]);
diff --git a/webclient/components/matrix/matrix-phone-service.js b/syweb/webclient/components/matrix/matrix-phone-service.js
index 06465ed821..55dbbf522e 100644
--- a/webclient/components/matrix/matrix-phone-service.js
+++ b/syweb/webclient/components/matrix/matrix-phone-service.js
@@ -60,7 +60,7 @@ angular.module('matrixPhoneService', [])
             var MatrixCall = $injector.get('MatrixCall');
             var call = new MatrixCall(event.room_id);
 
-            if (!isWebRTCSupported()) {
+            if (!$rootScope.isWebRTCSupported()) {
                 console.log("Incoming call ID "+msg.call_id+" but this browser doesn't support WebRTC");
                 // don't hang up the call: there could be other clients connected that do support WebRTC and declining the
                 // the call on their behalf would be really annoying.
diff --git a/webclient/components/matrix/matrix-service.js b/syweb/webclient/components/matrix/matrix-service.js
index 1840cf46c0..cfe8691f85 100644
--- a/webclient/components/matrix/matrix-service.js
+++ b/syweb/webclient/components/matrix/matrix-service.js
@@ -23,7 +23,7 @@ This serves to isolate the caller from changes to the underlying url paths, as
 well as attach common params (e.g. access_token) to requests.
 */
 angular.module('matrixService', [])
-.factory('matrixService', ['$http', '$q', '$rootScope', function($http, $q, $rootScope) {
+.factory('matrixService', ['$http', '$q', function($http, $q) {
         
    /* 
     * Permanent storage of user information
@@ -36,13 +36,9 @@ angular.module('matrixService', [])
     */    
     var config;
     
-    var roomIdToAlias = {};
-    var aliasToRoomId = {};
-    
     // Current version of permanent storage
     var configVersion = 0;
     var prefixPath = "/_matrix/client/api/v1";
-    var MAPPING_PREFIX = "alias_for_";
 
     var doRequest = function(method, path, params, data, $httpParams) {
         if (!config) {
@@ -267,7 +263,7 @@ angular.module('matrixService', [])
         
         // get room state for a specific room
         roomState: function(room_id) {
-            var path = "/rooms/" + room_id + "/state";
+            var path = "/rooms/" + encodeURIComponent(room_id) + "/state";
             return doRequest("GET", path);
         },
         
@@ -375,9 +371,11 @@ angular.module('matrixService', [])
         
         
         sendStateEvent: function(room_id, eventType, content, state_key) {
-            var path = "/rooms/$room_id/state/"+eventType;
+            var path = "/rooms/$room_id/state/"+ eventType;
+            // TODO: uncomment this when matrix.org is updated, else all state events 500.
+            // var path = "/rooms/$room_id/state/"+ encodeURIComponent(eventType);
             if (state_key !== undefined) {
-                path += "/" + state_key;
+                path += "/" + encodeURIComponent(state_key);
             }
             room_id = encodeURIComponent(room_id);
             path = path.replace("$room_id", room_id);
@@ -422,7 +420,8 @@ angular.module('matrixService', [])
             var content = {
                  msgtype: "m.image",
                  url: image_url,
-                 body: image_body
+                 info: image_body,
+                 body: "Image"
             };
 
             return this.sendMessage(room_id, msg_id, content);
@@ -440,7 +439,8 @@ angular.module('matrixService', [])
 
         redactEvent: function(room_id, event_id) {
             var path = "/rooms/$room_id/redact/$event_id";
-            path = path.replace("$room_id", room_id);
+            path = path.replace("$room_id", encodeURIComponent(room_id));
+            // TODO: encodeURIComponent when HS updated.
             path = path.replace("$event_id", event_id);
             var content = {};
             return doRequest("POST", path, undefined, content);
@@ -458,7 +458,7 @@ angular.module('matrixService', [])
         
         paginateBackMessages: function(room_id, from_token, limit) {
             var path = "/rooms/$room_id/messages";
-            path = path.replace("$room_id", room_id);
+            path = path.replace("$room_id", encodeURIComponent(room_id));
             var params = {
                 from: from_token,
                 limit: limit,
@@ -506,12 +506,12 @@ angular.module('matrixService', [])
 
         setProfileInfo: function(data, info_segment) {
             var path = "/profile/$user/" + info_segment;
-            path = path.replace("$user", config.user_id);
+            path = path.replace("$user", encodeURIComponent(config.user_id));
             return doRequest("PUT", path, undefined, data);
         },
 
         getProfileInfo: function(userId, info_segment) {
-            var path = "/profile/"+userId
+            var path = "/profile/"+encodeURIComponent(userId);
             if (info_segment) path += '/' + info_segment;
             return doRequest("GET", path);
         },
@@ -630,7 +630,7 @@ angular.module('matrixService', [])
         // Set the logged in user presence state
         setUserPresence: function(presence) {
             var path = "/presence/$user_id/status";
-            path = path.replace("$user_id", config.user_id);
+            path = path.replace("$user_id", encodeURIComponent(config.user_id));
             return doRequest("PUT", path, undefined, {
                 presence: presence
             });
@@ -667,114 +667,30 @@ angular.module('matrixService', [])
             config.version = configVersion;
             localStorage.setItem("config", JSON.stringify(config));
         },
-
-
-        /****** Room aliases management ******/
-
-        /**
-         * Get the room_alias & room_display_name which are computed from data 
-         * already retrieved from the server.
-         * @param {Room object} room one element of the array returned by the response
-         *  of rooms() and publicRooms()
-         * @returns {Object} {room_alias: "...", room_display_name: "..."}
-         */
-        getRoomAliasAndDisplayName: function(room) {
-            var result = {
-                room_alias: undefined,
-                room_display_name: undefined
-            };
-            var alias = this.getRoomIdToAliasMapping(room.room_id);
-            if (alias) {
-                // use the existing alias from storage
-                result.room_alias = alias;
-                result.room_display_name = alias;
-            }
-            // XXX: this only lets us learn aliases from our local HS - we should
-            // make the client stop returning this if we can trust m.room.aliases state events
-            else if (room.aliases && room.aliases[0]) {
-                // save the mapping
-                // TODO: select the smarter alias from the array
-                this.createRoomIdToAliasMapping(room.room_id, room.aliases[0]);
-                result.room_display_name = room.aliases[0];
-                result.room_alias = room.aliases[0];
-            }
-            else if (room.membership === "invite" && "inviter" in room) {
-                result.room_display_name = room.inviter + "'s room";
-            }
-            else {
-                // last resort use the room id
-                result.room_display_name = room.room_id;
-            }
-            return result;
-        },
-        
-        createRoomIdToAliasMapping: function(roomId, alias) {
-            roomIdToAlias[roomId] = alias;
-            aliasToRoomId[alias] = roomId;
-        },
-        
-        getRoomIdToAliasMapping: function(roomId) {
-            var alias = roomIdToAlias[roomId];
-            //console.log("looking for alias for " + roomId + "; found: " + alias);
-            return alias;
-        },
-
-        getAliasToRoomIdMapping: function(alias) {
-            var roomId = aliasToRoomId[alias];
-            //console.log("looking for roomId for " + alias + "; found: " + roomId);
-            return roomId;
-        },
-
-        /****** Power levels management ******/
-
-        /**
-         * Return the power level of an user in a particular room
-         * @param {String} room_id the room id
-         * @param {String} user_id the user id
-         * @returns {Number} a value between 0 and 10
-         */
-        getUserPowerLevel: function(room_id, user_id) {
-            var powerLevel = 0;
-            var room = $rootScope.events.rooms[room_id];
-            if (room && room["m.room.power_levels"]) {
-                if (user_id in room["m.room.power_levels"].content) {
-                    powerLevel = room["m.room.power_levels"].content[user_id];
-                }
-                else {
-                    // Use the room default user power
-                    powerLevel = room["m.room.power_levels"].content["default"];
-                }
-            }
-            return powerLevel;
-        },
             
         /**
          * Change or reset the power level of a user
          * @param {String} room_id the room id
          * @param {String} user_id the user id
-         * @param {Number} powerLevel a value between 0 and 10
+         * @param {Number} powerLevel The desired power level.
          *    If undefined, the user power level will be reset, ie he will use the default room user power level
+         * @param event The existing m.room.power_levels event if one exists.
          * @returns {promise} an $http promise
          */
-        setUserPowerLevel: function(room_id, user_id, powerLevel) {
-            
-            // Hack: currently, there is no home server API so do it by hand by updating
-            // the current m.room.power_levels of the room and send it to the server
-            var room = $rootScope.events.rooms[room_id];
-            if (room && room["m.room.power_levels"]) {
-                var content = angular.copy(room["m.room.power_levels"].content);
-                content[user_id] = powerLevel;
+        setUserPowerLevel: function(room_id, user_id, powerLevel, event) {
+            var content = {};
+            if (event) {
+                // if there is an existing event, copy the content as it contains
+                // the power level values for other members which we do not want
+                // to modify.
+                content = angular.copy(event.content);
+            }
+            content[user_id] = powerLevel;
                 
-                var path = "/rooms/$room_id/state/m.room.power_levels";
-                path = path.replace("$room_id", encodeURIComponent(room_id));
+            var path = "/rooms/$room_id/state/m.room.power_levels";
+            path = path.replace("$room_id", encodeURIComponent(room_id));
                 
-                return doRequest("PUT", path, undefined, content);
-            }
-            
-            // The room does not exist or does not contain power_levels data
-            var deferred = $q.defer();
-            deferred.reject({data:{error: "Invalid room: " + room_id}});
-            return deferred.promise;
+            return doRequest("PUT", path, undefined, content);
         },
 
         getTurnServer: function() {
diff --git a/syweb/webclient/components/matrix/model-service.js b/syweb/webclient/components/matrix/model-service.js
new file mode 100644
index 0000000000..da71dac436
--- /dev/null
+++ b/syweb/webclient/components/matrix/model-service.js
@@ -0,0 +1,213 @@
+/*
+Copyright 2014 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+/*
+This service serves as the entry point for all models in the app. If access to
+underlying data in a room is required, then this service should be used as the
+dependency.
+*/
+// NB: This is more explicit than linking top-level models to $rootScope
+//     in that by adding this service as a dep you are clearly saying "this X
+//     needs access to the underlying data store", rather than polluting the
+//     $rootScope.
+angular.module('modelService', [])
+.factory('modelService', ['matrixService', function(matrixService) {
+
+    // alias / id lookups
+    var roomIdToAlias = {};
+    var aliasToRoomId = {};
+    var setRoomIdToAliasMapping = function(roomId, alias) {
+        roomIdToAlias[roomId] = alias;
+        aliasToRoomId[alias] = roomId;
+    };
+    
+    /***** Room Object *****/
+    var Room = function Room(room_id) {
+        this.room_id = room_id;
+        this.old_room_state = new RoomState();
+        this.current_room_state = new RoomState();
+        this.events = []; // events which can be displayed on the UI. TODO move?
+    };
+    Room.prototype = {
+        addMessageEvents: function addMessageEvents(events, toFront) {
+            for (var i=0; i<events.length; i++) {
+                this.addMessageEvent(events[i], toFront);
+            }
+        },
+        
+        addMessageEvent: function addMessageEvent(event, toFront) {
+            // every message must reference the RoomMember which made it *at
+            // that time* so things like display names display correctly.
+            var stateAtTheTime = toFront ? this.old_room_state : this.current_room_state;
+            event.__room_member = stateAtTheTime.getStateEvent("m.room.member", event.user_id);
+            if (event.type === "m.room.member" && event.content.membership === "invite") {
+                // give information on both the inviter and invitee
+                event.__target_room_member = stateAtTheTime.getStateEvent("m.room.member", event.state_key);
+            }
+            
+            if (toFront) {
+                this.events.unshift(event);
+            }
+            else {
+                this.events.push(event);
+            }
+        },
+        
+        addOrReplaceMessageEvent: function addOrReplaceMessageEvent(event, toFront) {
+            // Start looking from the tail since the first goal of this function 
+            // is to find a message among the latest ones
+            for (var i = this.events.length - 1; i >= 0; i--) {
+                var storedEvent = this.events[i];
+                if (storedEvent.event_id === event.event_id) {
+                    // It's clobbering time!
+                    this.events[i] = event;
+                    return;
+                }
+            }
+            this.addMessageEvent(event, toFront);
+        },
+        
+        leave: function leave() {
+            return matrixService.leave(this.room_id);
+        }
+    };
+    
+    /***** Room State Object *****/
+    var RoomState = function RoomState() {
+        // list of RoomMember
+        this.members = {}; 
+        // state events, the key is a compound of event type + state_key
+        this.state_events = {}; 
+        this.pagination_token = ""; 
+    };
+    RoomState.prototype = {
+        // get a state event for this room from this.state_events. State events
+        // are unique per type+state_key tuple, with a lot of events using 0-len
+        // state keys. To make it not Really Annoying to access, this method is
+        // provided which can just be given the type and it will return the 
+        // 0-len event by default.
+        state: function state(type, state_key) {
+            if (!type) {
+                return undefined; // event type MUST be specified
+            }
+            if (!state_key) {
+                return this.state_events[type]; // treat as 0-len state key
+            }
+            return this.state_events[type + state_key];
+        },
+        
+        storeStateEvent: function storeState(event) {
+            this.state_events[event.type + event.state_key] = event;
+            if (event.type === "m.room.member") {
+                var rm = new RoomMember();
+                rm.event = event;
+                this.members[event.state_key] = rm;
+            }
+            else if (event.type === "m.room.aliases") {
+                setRoomIdToAliasMapping(event.room_id, event.content.aliases[0]);
+            }
+        },
+        
+        storeStateEvents: function storeState(events) {
+            if (!events) {
+                return;
+            }
+            for (var i=0; i<events.length; i++) {
+                this.storeStateEvent(events[i]);
+            }
+        },
+        
+        getStateEvent: function getStateEvent(event_type, state_key) {
+            return this.state_events[event_type + state_key];
+        }
+    };
+    
+    /***** Room Member Object *****/
+    var RoomMember = function RoomMember() {
+        this.event = {}; // the m.room.member event representing the RoomMember.
+        this.user = undefined; // the User
+    };
+    
+    /***** User Object *****/
+    var User = function User() {
+        this.event = {}; // the m.presence event representing the User.
+    };
+    
+    // rooms are stored here when they come in.
+    var rooms = {
+        // roomid: <Room>
+    };
+    
+    var users = {
+        // user_id: <User>
+    };
+    
+    console.log("Models inited.");
+    
+    return {
+    
+        getRoom: function(roomId) {
+            if(!rooms[roomId]) {
+                rooms[roomId] = new Room(roomId);
+            }
+            return rooms[roomId];
+        },
+        
+        getRooms: function() {
+            return rooms;
+        },
+        
+        /**
+         * Get the member object of a room member
+         * @param {String} room_id the room id
+         * @param {String} user_id the id of the user
+         * @returns {undefined | Object} the member object of this user in this room if he is part of the room
+         */
+        getMember: function(room_id, user_id) {
+            var room = this.getRoom(room_id);
+            return room.current_room_state.members[user_id];
+        },
+        
+        createRoomIdToAliasMapping: function(roomId, alias) {
+            setRoomIdToAliasMapping(roomId, alias);
+        },
+        
+        getRoomIdToAliasMapping: function(roomId) {
+            var alias = roomIdToAlias[roomId];
+            //console.log("looking for alias for " + roomId + "; found: " + alias);
+            return alias;
+        },
+
+        getAliasToRoomIdMapping: function(alias) {
+            var roomId = aliasToRoomId[alias];
+            //console.log("looking for roomId for " + alias + "; found: " + roomId);
+            return roomId;
+        },
+        
+        getUser: function(user_id) {
+            return users[user_id];
+        },
+        
+        setUser: function(event) {
+            var usr = new User();
+            usr.event = event;
+            users[event.content.user_id] = usr;
+        }
+    
+    };
+}]);
diff --git a/syweb/webclient/components/matrix/notification-service.js b/syweb/webclient/components/matrix/notification-service.js
new file mode 100644
index 0000000000..9a911413c3
--- /dev/null
+++ b/syweb/webclient/components/matrix/notification-service.js
@@ -0,0 +1,104 @@
+/*
+Copyright 2014 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+/*
+This service manages notifications: enabling, creating and showing them. This
+also contains 'bing word' logic.
+*/
+angular.module('notificationService', [])
+.factory('notificationService', ['$timeout', function($timeout) {
+
+    var getLocalPartFromUserId = function(user_id) {
+        if (!user_id) {
+            return null;
+        }
+        var localpartRegex = /@(.*):\w+/i
+        var results = localpartRegex.exec(user_id);
+        if (results && results.length == 2) {
+            return results[1];
+        }
+        return null;
+    };
+    
+    return {
+    
+        containsBingWord: function(userId, displayName, bingWords, content) {
+            // case-insensitive name check for user_id OR display_name if they exist
+            var userRegex = "";
+            if (userId) {
+                var localpart = getLocalPartFromUserId(userId);
+                if (localpart) {
+                    localpart = localpart.toLocaleLowerCase();
+                    userRegex += "\\b" + localpart + "\\b";
+                }
+            }
+            if (displayName) {
+                displayName = displayName.toLocaleLowerCase();
+                if (userRegex.length > 0) {
+                    userRegex += "|";
+                }
+                userRegex += "\\b" + displayName + "\\b";
+            }
+
+            var regexList = [new RegExp(userRegex, 'i')];
+            
+            // bing word list check
+            if (bingWords && bingWords.length > 0) {
+                for (var i=0; i<bingWords.length; i++) {
+                    var re = RegExp(bingWords[i], 'i');
+                    regexList.push(re);
+                }
+            }
+            return this.hasMatch(regexList, content);
+        },
+    
+        hasMatch: function(regExps, content) {
+            if (!content || $.type(content) != "string") {
+                return false;
+            }
+            
+            if (regExps && regExps.length > 0) {
+                for (var i=0; i<regExps.length; i++) {
+                    if (content.search(regExps[i]) != -1) {
+                        return true;
+                    }
+                }
+            }
+            return false;
+        },
+        
+        showNotification: function(title, body, icon, onclick) {
+            var notification = new window.Notification(
+                title,
+                {
+                    "body": body,
+                    "icon": icon
+                }
+            );
+
+            if (onclick) {
+                notification.onclick = onclick;
+            }
+
+            $timeout(function() {
+                notification.close();
+            }, 5 * 1000);
+        }
+    };
+
+}]);
diff --git a/webclient/components/matrix/presence-service.js b/syweb/webclient/components/matrix/presence-service.js
index b487e3d3bd..b487e3d3bd 100644
--- a/webclient/components/matrix/presence-service.js
+++ b/syweb/webclient/components/matrix/presence-service.js
diff --git a/syweb/webclient/components/matrix/recents-service.js b/syweb/webclient/components/matrix/recents-service.js
new file mode 100644
index 0000000000..3d82b8218b
--- /dev/null
+++ b/syweb/webclient/components/matrix/recents-service.js
@@ -0,0 +1,99 @@
+/*
+Copyright 2014 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+/*
+This service manages shared state between *instances* of recent lists. The
+recents controller will hook into this central service to get things like:
+- which rooms should be highlighted
+- which rooms have been binged
+- which room is currently selected
+- etc.
+This is preferable to polluting the $rootScope with recents specific info, and
+makes the dependency on this shared state *explicit*.
+*/
+angular.module('recentsService', [])
+.factory('recentsService', ['$rootScope', 'eventHandlerService', function($rootScope, eventHandlerService) {
+    // notify listeners when variables in the service are updated. We need to do
+    // this since we do not tie them to any scope.
+    var BROADCAST_SELECTED_ROOM_ID = "recentsService:BROADCAST_SELECTED_ROOM_ID(room_id)";
+    var selectedRoomId = undefined;
+    
+    var BROADCAST_UNREAD_MESSAGES = "recentsService:BROADCAST_UNREAD_MESSAGES(room_id, unreadCount)";
+    var unreadMessages = {
+        // room_id: <number>
+    };
+    
+    var BROADCAST_UNREAD_BING_MESSAGES = "recentsService:BROADCAST_UNREAD_BING_MESSAGES(room_id, event)";
+    var unreadBingMessages = {
+        // room_id: bingEvent
+    };
+    
+    // listen for new unread messages
+    $rootScope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) {
+        if (isLive && event.room_id !== selectedRoomId) {
+            if (eventHandlerService.eventContainsBingWord(event)) {
+                if (!unreadBingMessages[event.room_id]) {
+                    unreadBingMessages[event.room_id] = {};
+                }
+                unreadBingMessages[event.room_id] = event;
+                $rootScope.$broadcast(BROADCAST_UNREAD_BING_MESSAGES, event.room_id, event);
+            }
+        
+            if (!unreadMessages[event.room_id]) {
+                unreadMessages[event.room_id] = 0;
+            }
+            unreadMessages[event.room_id] += 1;
+            $rootScope.$broadcast(BROADCAST_UNREAD_MESSAGES, event.room_id, unreadMessages[event.room_id]);
+        }
+    });
+    
+    return {
+        BROADCAST_SELECTED_ROOM_ID: BROADCAST_SELECTED_ROOM_ID,
+        BROADCAST_UNREAD_MESSAGES: BROADCAST_UNREAD_MESSAGES,
+    
+        getSelectedRoomId: function() {
+            return selectedRoomId;
+        },
+        
+        setSelectedRoomId: function(room_id) {
+            selectedRoomId = room_id;
+            $rootScope.$broadcast(BROADCAST_SELECTED_ROOM_ID, room_id);
+        },
+        
+        getUnreadMessages: function() {
+            return unreadMessages;
+        },
+        
+        getUnreadBingMessages: function() {
+            return unreadBingMessages;
+        },
+        
+        markAsRead: function(room_id) {
+            if (unreadMessages[room_id]) {
+                unreadMessages[room_id] = 0;
+            }
+            if (unreadBingMessages[room_id]) {
+                unreadBingMessages[room_id] = undefined;
+            }
+            $rootScope.$broadcast(BROADCAST_UNREAD_MESSAGES, room_id, 0);
+            $rootScope.$broadcast(BROADCAST_UNREAD_BING_MESSAGES, room_id, undefined);
+        }
+    
+    };
+
+}]);
diff --git a/webclient/components/utilities/utilities-service.js b/syweb/webclient/components/utilities/utilities-service.js
index b417cc5b39..b417cc5b39 100644
--- a/webclient/components/utilities/utilities-service.js
+++ b/syweb/webclient/components/utilities/utilities-service.js
diff --git a/webclient/favicon.ico b/syweb/webclient/favicon.ico
index ba193fabc8..ba193fabc8 100644
--- a/webclient/favicon.ico
+++ b/syweb/webclient/favicon.ico
Binary files differdiff --git a/webclient/home/home-controller.js b/syweb/webclient/home/home-controller.js
index f1295560ef..a9538a0309 100644
--- a/webclient/home/home-controller.js
+++ b/syweb/webclient/home/home-controller.js
@@ -17,8 +17,8 @@ limitations under the License.
 'use strict';
 
 angular.module('HomeController', ['matrixService', 'eventHandlerService', 'RecentsController'])
-.controller('HomeController', ['$scope', '$location', 'matrixService', 'eventHandlerService', 
-                               function($scope, $location, matrixService, eventHandlerService) {
+.controller('HomeController', ['$scope', '$location', 'matrixService', 'eventHandlerService', 'modelService', 'recentsService',
+                               function($scope, $location, matrixService, eventHandlerService, modelService, recentsService) {
 
     $scope.config = matrixService.config();
     $scope.public_rooms = [];
@@ -46,6 +46,8 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
     $scope.newChat = {
         user: ""
     };
+    
+    recentsService.setSelectedRoomId(undefined);
 
     var refresh = function() {
         
@@ -54,11 +56,17 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
                 $scope.public_rooms = response.data.chunk;
                 for (var i = 0; i < $scope.public_rooms.length; i++) {
                     var room = $scope.public_rooms[i];
-
-                    // Add room_alias & room_display_name members
-                    angular.extend(room, matrixService.getRoomAliasAndDisplayName(room));
                     
-                    eventHandlerService.setRoomVisibility(room.room_id, "public");
+                    if (room.aliases && room.aliases.length > 0) {
+                        room.room_display_name = room.aliases[0];
+                        room.room_alias = room.aliases[0];
+                    }
+                    else if (room.name) {
+                        room.room_display_name = room.name;
+                    }
+                    else {
+                        room.room_display_name = room.room_id;
+                    }
                 }
             }
         );
@@ -76,7 +84,7 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
                 // This room has been created. Refresh the rooms list
                 console.log("Created room " + response.data.room_alias + " with id: "+
                 response.data.room_id);
-                matrixService.createRoomIdToAliasMapping(
+                modelService.createRoomIdToAliasMapping(
                     response.data.room_id, response.data.room_alias);
             },
             function(error) {
diff --git a/webclient/home/home.html b/syweb/webclient/home/home.html
index 0af382916e..0af382916e 100644
--- a/webclient/home/home.html
+++ b/syweb/webclient/home/home.html
diff --git a/syweb/webclient/img/attach.png b/syweb/webclient/img/attach.png
new file mode 100644
index 0000000000..d95eabaf00
--- /dev/null
+++ b/syweb/webclient/img/attach.png
Binary files differdiff --git a/webclient/img/close.png b/syweb/webclient/img/close.png
index fbcdb51e6b..fbcdb51e6b 100644
--- a/webclient/img/close.png
+++ b/syweb/webclient/img/close.png
Binary files differdiff --git a/webclient/img/default-profile.png b/syweb/webclient/img/default-profile.png
index 6f81a3c417..6f81a3c417 100644
--- a/webclient/img/default-profile.png
+++ b/syweb/webclient/img/default-profile.png
Binary files differdiff --git a/webclient/img/gradient.png b/syweb/webclient/img/gradient.png
index 8ac9e2193f..8ac9e2193f 100644
--- a/webclient/img/gradient.png
+++ b/syweb/webclient/img/gradient.png
Binary files differdiff --git a/webclient/img/green_phone.png b/syweb/webclient/img/green_phone.png
index 28807c749b..28807c749b 100644
--- a/webclient/img/green_phone.png
+++ b/syweb/webclient/img/green_phone.png
Binary files differdiff --git a/webclient/img/logo-small.png b/syweb/webclient/img/logo-small.png
index 411206dcdc..411206dcdc 100644
--- a/webclient/img/logo-small.png
+++ b/syweb/webclient/img/logo-small.png
Binary files differdiff --git a/webclient/img/logo.png b/syweb/webclient/img/logo.png
index c4b53a8487..c4b53a8487 100644
--- a/webclient/img/logo.png
+++ b/syweb/webclient/img/logo.png
Binary files differdiff --git a/syweb/webclient/img/settings.png b/syweb/webclient/img/settings.png
new file mode 100644
index 0000000000..ac99fe402b
--- /dev/null
+++ b/syweb/webclient/img/settings.png
Binary files differdiff --git a/syweb/webclient/img/video.png b/syweb/webclient/img/video.png
new file mode 100644
index 0000000000..e90afea0c1
--- /dev/null
+++ b/syweb/webclient/img/video.png
Binary files differdiff --git a/syweb/webclient/img/voice.png b/syweb/webclient/img/voice.png
new file mode 100644
index 0000000000..fe464999c0
--- /dev/null
+++ b/syweb/webclient/img/voice.png
Binary files differdiff --git a/webclient/index.html b/syweb/webclient/index.html
index 35c8051298..d9c67333af 100644
--- a/webclient/index.html
+++ b/syweb/webclient/index.html
@@ -13,13 +13,15 @@
    
     <script type='text/javascript' src='js/jquery-1.8.3.min.js'></script>
     <script type="text/javascript" src="https://www.google.com/recaptcha/api/js/recaptcha_ajax.js"></script> 
-    <script src="js/angular.min.js"></script>
+    <script src="js/angular.js"></script>
     <script src="js/angular-route.min.js"></script>
     <script src="js/angular-sanitize.min.js"></script>
-    <script src="js/angular-animate.min.js"></script>
+    <script src="js/jquery.peity.min.js"></script>
+    <script src="js/angular-peity.js"></script>
     <script type='text/javascript' src="js/ui-bootstrap-tpls-0.11.2.js"></script>
     <script type='text/javascript' src='js/ng-infinite-scroll-matrix.js'></script>
     <script type='text/javascript' src='js/autofill-event.js'></script>
+    <script type='text/javascript' src='js/elastic.js'></script>
     <script src="app.js"></script>
     <script src="config.js"></script>
     <script src="app-controller.js"></script>
@@ -40,6 +42,10 @@
     <script src="components/matrix/matrix-phone-service.js"></script>
     <script src="components/matrix/event-stream-service.js"></script>
     <script src="components/matrix/event-handler-service.js"></script>
+    <script src="components/matrix/notification-service.js"></script>
+    <script src="components/matrix/recents-service.js"></script>
+    <script src="components/matrix/commands-service.js"></script>
+    <script src="components/matrix/model-service.js"></script>
     <script src="components/matrix/presence-service.js"></script>
     <script src="components/fileInput/file-input-directive.js"></script>
     <script src="components/fileUpload/file-upload-service.js"></script>
@@ -50,8 +56,8 @@
     <div id="videoBackground" ng-class="videoMode">
         <div id="videoContainer" ng-class="videoMode">
             <div id="videoContainerPadding"></div>
-            <video id="localVideo" ng-class="[videoMode, currentCall.state]" ng-show="currentCall && currentCall.type == 'video' && (currentCall.state == 'connected' || currentCall.state == 'connecting' || currentCall.state == 'invite_sent' || currentCall.state == 'ended')"></video>
-            <video id="remoteVideo" ng-class="[videoMode, currentCall.state]" ng-show="currentCall && currentCall.type == 'video' && (currentCall.state == 'connected' || (currentCall.state == 'ended' && currentCall.didConnect))"></video>
+            <div ng-class="[videoMode, currentCall.state]" ng-show="currentCall && currentCall.type == 'video' && (currentCall.state == 'connected' || currentCall.state == 'connecting' || currentCall.state == 'invite_sent' || currentCall.state == 'ended')"><video id="localVideo"></video></div>
+            <div ng-class="[videoMode, currentCall.state]" ng-show="currentCall && currentCall.type == 'video' && (currentCall.state == 'connected' || (currentCall.state == 'ended' && currentCall.didConnect))"><video id="remoteVideo"></video></div>
         </div>
     </div>
 
@@ -60,8 +66,7 @@
         <div id="headerContent" ng-hide="'/login' == location || '/register' == location">
             <div id="callBar" ng-show="currentCall">
                 <img id="callPeerImage" ng-show="currentCall.userProfile.avatar_url" ngSrc="{{ currentCall.userProfile.avatar_url }}" />
-                <img class="callIcon" src="img/green_phone.png" ng-show="currentCall.state != 'ended'" />
-                <img class="callIcon" id="callEndedIcon" src="img/red_phone.png" ng-show="currentCall.state == 'ended'" />
+                <img class="callIcon" src="img/green_phone.png" ng-show="!!currentCall" ng-class="currentCall.state" />
                 <div id="callPeerNameAndState">
                     <span id="callPeerName">{{ currentCall.userProfile.displayname }}</span>
                     <br />
@@ -82,7 +87,7 @@
                     </span>
                 </div>
                 <span ng-show="currentCall.state == 'ringing'">
-                    <button ng-click="answerCall()" ng-disabled="!isWebRTCSupported" title="{{isWebRTCSupported ? '' : 'Your browser does not support VoIP' }}">Answer {{ currentCall.type }} call</button>
+                    <button ng-click="answerCall()" ng-disabled="!isWebRTCSupported()" title="{{isWebRTCSupported() ? '' : 'Your browser does not support VoIP' }}">Answer {{ currentCall.type }} call</button>
                     <button ng-click="hangupCall()">Reject</button>
                 </span>
                 <button ng-click="hangupCall()" ng-show="currentCall && currentCall.state != 'ringing' && currentCall.state != 'ended' && currentCall.state != 'fledgling'">Hang up</button>
diff --git a/webclient/js/angular-animate.js b/syweb/webclient/js/angular-animate.js
index c15f793c1b..c15f793c1b 100644
--- a/webclient/js/angular-animate.js
+++ b/syweb/webclient/js/angular-animate.js
diff --git a/webclient/js/angular-animate.min.js b/syweb/webclient/js/angular-animate.min.js
index 1ce2a93ac7..1ce2a93ac7 100644
--- a/webclient/js/angular-animate.min.js
+++ b/syweb/webclient/js/angular-animate.min.js
diff --git a/webclient/js/angular-mocks.js b/syweb/webclient/js/angular-mocks.js
index 48c0b5decb..24bbcd4137 100755
--- a/webclient/js/angular-mocks.js
+++ b/syweb/webclient/js/angular-mocks.js
@@ -1,10 +1,3 @@
-/**
- * @license AngularJS v1.2.22
- * (c) 2010-2014 Google, Inc. http://angularjs.org
- * License: MIT
- */
-(function(window, angular, undefined) {
-
 'use strict';
 
 /**
@@ -63,6 +56,8 @@ angular.mock.$Browser = function() {
     return listener;
   };
 
+  self.$$checkUrlChange = angular.noop;
+
   self.cookieHash = {};
   self.lastCookieHash = {};
   self.deferredFns = [];
@@ -125,7 +120,7 @@ angular.mock.$Browser = function() {
     }
   };
 
-  self.$$baseHref = '';
+  self.$$baseHref = '/';
   self.baseHref = function() {
     return this.$$baseHref;
   };
@@ -774,13 +769,22 @@ angular.mock.animate = angular.module('ngAnimateMock', ['ng'])
       };
     });
 
-    $provide.decorator('$animate', function($delegate, $$asyncCallback) {
+    $provide.decorator('$animate', ['$delegate', '$$asyncCallback', '$timeout', '$browser',
+                            function($delegate,   $$asyncCallback,   $timeout,   $browser) {
       var animate = {
         queue : [],
+        cancel : $delegate.cancel,
         enabled : $delegate.enabled,
-        triggerCallbacks : function() {
+        triggerCallbackEvents : function() {
           $$asyncCallback.flush();
         },
+        triggerCallbackPromise : function() {
+          $timeout.flush(0);
+        },
+        triggerCallbacks : function() {
+          this.triggerCallbackEvents();
+          this.triggerCallbackPromise();
+        },
         triggerReflow : function() {
           angular.forEach(reflowQueue, function(fn) {
             fn();
@@ -797,12 +801,12 @@ angular.mock.animate = angular.module('ngAnimateMock', ['ng'])
             element : arguments[0],
             args : arguments
           });
-          $delegate[method].apply($delegate, arguments);
+          return $delegate[method].apply($delegate, arguments);
         };
       });
 
       return animate;
-    });
+    }]);
 
   }]);
 
@@ -888,7 +892,7 @@ angular.mock.dump = function(object) {
  * development please see {@link ngMockE2E.$httpBackend e2e $httpBackend mock}.
  *
  * During unit testing, we want our unit tests to run quickly and have no external dependencies so
- * we don’t want to send [XHR](https://developer.mozilla.org/en/xmlhttprequest) or
+ * we don’t want to send [XHR](https://developer.mozilla.org/en/xmlhttprequest) or
  * [JSONP](http://en.wikipedia.org/wiki/JSONP) requests to a real server. All we really need is
  * to verify whether a certain request has been sent or not, or alternatively just let the
  * application make requests, respond with pre-trained responses and assert that the end result is
@@ -1007,13 +1011,14 @@ angular.mock.dump = function(object) {
   ```js
     // testing controller
     describe('MyController', function() {
-       var $httpBackend, $rootScope, createController;
+       var $httpBackend, $rootScope, createController, authRequestHandler;
 
        beforeEach(inject(function($injector) {
          // Set up the mock http service responses
          $httpBackend = $injector.get('$httpBackend');
          // backend definition common for all tests
-         $httpBackend.when('GET', '/auth.py').respond({userId: 'userX'}, {'A-Token': 'xxx'});
+         authRequestHandler = $httpBackend.when('GET', '/auth.py')
+                                .respond({userId: 'userX'}, {'A-Token': 'xxx'});
 
          // Get hold of a scope (i.e. the root scope)
          $rootScope = $injector.get('$rootScope');
@@ -1039,11 +1044,23 @@ angular.mock.dump = function(object) {
        });
 
 
+       it('should fail authentication', function() {
+
+         // Notice how you can change the response even after it was set
+         authRequestHandler.respond(401, '');
+
+         $httpBackend.expectGET('/auth.py');
+         var controller = createController();
+         $httpBackend.flush();
+         expect($rootScope.status).toBe('Failed...');
+       });
+
+
        it('should send msg to server', function() {
          var controller = createController();
          $httpBackend.flush();
 
-         // now you don’t care about the authentication, but
+         // now you don’t care about the authentication, but
          // the controller will still send the request and
          // $httpBackend will respond without you having to
          // specify the expectation and response for this request
@@ -1186,32 +1203,39 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
    * Creates a new backend definition.
    *
    * @param {string} method HTTP method.
-   * @param {string|RegExp} url HTTP url.
+   * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+   *   and returns true if the url match the current definition.
    * @param {(string|RegExp|function(string))=} data HTTP request body or function that receives
    *   data string and returns true if the data is as expected.
    * @param {(Object|function(Object))=} headers HTTP headers or function that receives http header
    *   object and returns true if the headers match the current definition.
    * @returns {requestHandler} Returns an object with `respond` method that controls how a matched
-   *   request is handled.
+   *   request is handled. You can save this object for later use and invoke `respond` again in
+   *   order to change how a matched request is handled.
    *
-   *  - respond –
+   *  - respond –
    *      `{function([status,] data[, headers, statusText])
    *      | function(function(method, url, data, headers)}`
-   *    – The respond method takes a set of static data to be returned or a function that can
+   *    – The respond method takes a set of static data to be returned or a function that can
    *    return an array containing response status (number), response data (string), response
-   *    headers (Object), and the text for the status (string).
+   *    headers (Object), and the text for the status (string). The respond method returns the
+   *    `requestHandler` object for possible overrides.
    */
   $httpBackend.when = function(method, url, data, headers) {
     var definition = new MockHttpExpectation(method, url, data, headers),
         chain = {
           respond: function(status, data, headers, statusText) {
+            definition.passThrough = undefined;
             definition.response = createResponse(status, data, headers, statusText);
+            return chain;
           }
         };
 
     if ($browser) {
       chain.passThrough = function() {
+        definition.response = undefined;
         definition.passThrough = true;
+        return chain;
       };
     }
 
@@ -1225,10 +1249,12 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
    * @description
    * Creates a new backend definition for GET requests. For more info see `when()`.
    *
-   * @param {string|RegExp} url HTTP url.
+   * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+   *   and returns true if the url match the current definition.
    * @param {(Object|function(Object))=} headers HTTP headers.
    * @returns {requestHandler} Returns an object with `respond` method that control how a matched
-   * request is handled.
+   * request is handled. You can save this object for later use and invoke `respond` again in
+   * order to change how a matched request is handled.
    */
 
   /**
@@ -1237,10 +1263,12 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
    * @description
    * Creates a new backend definition for HEAD requests. For more info see `when()`.
    *
-   * @param {string|RegExp} url HTTP url.
+   * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+   *   and returns true if the url match the current definition.
    * @param {(Object|function(Object))=} headers HTTP headers.
    * @returns {requestHandler} Returns an object with `respond` method that control how a matched
-   * request is handled.
+   * request is handled. You can save this object for later use and invoke `respond` again in
+   * order to change how a matched request is handled.
    */
 
   /**
@@ -1249,10 +1277,12 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
    * @description
    * Creates a new backend definition for DELETE requests. For more info see `when()`.
    *
-   * @param {string|RegExp} url HTTP url.
+   * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+   *   and returns true if the url match the current definition.
    * @param {(Object|function(Object))=} headers HTTP headers.
    * @returns {requestHandler} Returns an object with `respond` method that control how a matched
-   * request is handled.
+   * request is handled. You can save this object for later use and invoke `respond` again in
+   * order to change how a matched request is handled.
    */
 
   /**
@@ -1261,12 +1291,14 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
    * @description
    * Creates a new backend definition for POST requests. For more info see `when()`.
    *
-   * @param {string|RegExp} url HTTP url.
+   * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+   *   and returns true if the url match the current definition.
    * @param {(string|RegExp|function(string))=} data HTTP request body or function that receives
    *   data string and returns true if the data is as expected.
    * @param {(Object|function(Object))=} headers HTTP headers.
    * @returns {requestHandler} Returns an object with `respond` method that control how a matched
-   * request is handled.
+   * request is handled. You can save this object for later use and invoke `respond` again in
+   * order to change how a matched request is handled.
    */
 
   /**
@@ -1275,12 +1307,14 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
    * @description
    * Creates a new backend definition for PUT requests.  For more info see `when()`.
    *
-   * @param {string|RegExp} url HTTP url.
+   * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+   *   and returns true if the url match the current definition.
    * @param {(string|RegExp|function(string))=} data HTTP request body or function that receives
    *   data string and returns true if the data is as expected.
    * @param {(Object|function(Object))=} headers HTTP headers.
    * @returns {requestHandler} Returns an object with `respond` method that control how a matched
-   * request is handled.
+   * request is handled. You can save this object for later use and invoke `respond` again in
+   * order to change how a matched request is handled.
    */
 
   /**
@@ -1289,9 +1323,11 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
    * @description
    * Creates a new backend definition for JSONP requests. For more info see `when()`.
    *
-   * @param {string|RegExp} url HTTP url.
+   * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+   *   and returns true if the url match the current definition.
    * @returns {requestHandler} Returns an object with `respond` method that control how a matched
-   * request is handled.
+   * request is handled. You can save this object for later use and invoke `respond` again in
+   * order to change how a matched request is handled.
    */
   createShortMethods('when');
 
@@ -1303,30 +1339,36 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
    * Creates a new request expectation.
    *
    * @param {string} method HTTP method.
-   * @param {string|RegExp} url HTTP url.
+   * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+   *   and returns true if the url match the current definition.
    * @param {(string|RegExp|function(string)|Object)=} data HTTP request body or function that
    *  receives data string and returns true if the data is as expected, or Object if request body
    *  is in JSON format.
    * @param {(Object|function(Object))=} headers HTTP headers or function that receives http header
    *   object and returns true if the headers match the current expectation.
    * @returns {requestHandler} Returns an object with `respond` method that control how a matched
-   *  request is handled.
+   *  request is handled. You can save this object for later use and invoke `respond` again in
+   *  order to change how a matched request is handled.
    *
-   *  - respond –
+   *  - respond –
    *    `{function([status,] data[, headers, statusText])
    *    | function(function(method, url, data, headers)}`
-   *    – The respond method takes a set of static data to be returned or a function that can
+   *    – The respond method takes a set of static data to be returned or a function that can
    *    return an array containing response status (number), response data (string), response
-   *    headers (Object), and the text for the status (string).
+   *    headers (Object), and the text for the status (string). The respond method returns the
+   *    `requestHandler` object for possible overrides.
    */
   $httpBackend.expect = function(method, url, data, headers) {
-    var expectation = new MockHttpExpectation(method, url, data, headers);
+    var expectation = new MockHttpExpectation(method, url, data, headers),
+        chain = {
+          respond: function (status, data, headers, statusText) {
+            expectation.response = createResponse(status, data, headers, statusText);
+            return chain;
+          }
+        };
+
     expectations.push(expectation);
-    return {
-      respond: function (status, data, headers, statusText) {
-        expectation.response = createResponse(status, data, headers, statusText);
-      }
-    };
+    return chain;
   };
 
 
@@ -1336,10 +1378,12 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
    * @description
    * Creates a new request expectation for GET requests. For more info see `expect()`.
    *
-   * @param {string|RegExp} url HTTP url.
+   * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+   *   and returns true if the url match the current definition.
    * @param {Object=} headers HTTP headers.
    * @returns {requestHandler} Returns an object with `respond` method that control how a matched
-   * request is handled. See #expect for more info.
+   * request is handled. You can save this object for later use and invoke `respond` again in
+   * order to change how a matched request is handled. See #expect for more info.
    */
 
   /**
@@ -1348,10 +1392,12 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
    * @description
    * Creates a new request expectation for HEAD requests. For more info see `expect()`.
    *
-   * @param {string|RegExp} url HTTP url.
+   * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+   *   and returns true if the url match the current definition.
    * @param {Object=} headers HTTP headers.
    * @returns {requestHandler} Returns an object with `respond` method that control how a matched
-   *   request is handled.
+   *   request is handled. You can save this object for later use and invoke `respond` again in
+   *   order to change how a matched request is handled.
    */
 
   /**
@@ -1360,10 +1406,12 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
    * @description
    * Creates a new request expectation for DELETE requests. For more info see `expect()`.
    *
-   * @param {string|RegExp} url HTTP url.
+   * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+   *   and returns true if the url match the current definition.
    * @param {Object=} headers HTTP headers.
    * @returns {requestHandler} Returns an object with `respond` method that control how a matched
-   *   request is handled.
+   *   request is handled. You can save this object for later use and invoke `respond` again in
+   *   order to change how a matched request is handled.
    */
 
   /**
@@ -1372,13 +1420,15 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
    * @description
    * Creates a new request expectation for POST requests. For more info see `expect()`.
    *
-   * @param {string|RegExp} url HTTP url.
+   * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+   *   and returns true if the url match the current definition.
    * @param {(string|RegExp|function(string)|Object)=} data HTTP request body or function that
    *  receives data string and returns true if the data is as expected, or Object if request body
    *  is in JSON format.
    * @param {Object=} headers HTTP headers.
    * @returns {requestHandler} Returns an object with `respond` method that control how a matched
-   *   request is handled.
+   *   request is handled. You can save this object for later use and invoke `respond` again in
+   *   order to change how a matched request is handled.
    */
 
   /**
@@ -1387,13 +1437,15 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
    * @description
    * Creates a new request expectation for PUT requests. For more info see `expect()`.
    *
-   * @param {string|RegExp} url HTTP url.
+   * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+   *   and returns true if the url match the current definition.
    * @param {(string|RegExp|function(string)|Object)=} data HTTP request body or function that
    *  receives data string and returns true if the data is as expected, or Object if request body
    *  is in JSON format.
    * @param {Object=} headers HTTP headers.
    * @returns {requestHandler} Returns an object with `respond` method that control how a matched
-   *   request is handled.
+   *   request is handled. You can save this object for later use and invoke `respond` again in
+   *   order to change how a matched request is handled.
    */
 
   /**
@@ -1402,13 +1454,15 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
    * @description
    * Creates a new request expectation for PATCH requests. For more info see `expect()`.
    *
-   * @param {string|RegExp} url HTTP url.
+   * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+   *   and returns true if the url match the current definition.
    * @param {(string|RegExp|function(string)|Object)=} data HTTP request body or function that
    *  receives data string and returns true if the data is as expected, or Object if request body
    *  is in JSON format.
    * @param {Object=} headers HTTP headers.
    * @returns {requestHandler} Returns an object with `respond` method that control how a matched
-   *   request is handled.
+   *   request is handled. You can save this object for later use and invoke `respond` again in
+   *   order to change how a matched request is handled.
    */
 
   /**
@@ -1417,9 +1471,11 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
    * @description
    * Creates a new request expectation for JSONP requests. For more info see `expect()`.
    *
-   * @param {string|RegExp} url HTTP url.
+   * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+   *   and returns true if the url match the current definition.
    * @returns {requestHandler} Returns an object with `respond` method that control how a matched
-   *   request is handled.
+   *   request is handled. You can save this object for later use and invoke `respond` again in
+   *   order to change how a matched request is handled.
    */
   createShortMethods('expect');
 
@@ -1434,11 +1490,11 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
    *   all pending requests will be flushed. If there are no pending requests when the flush method
    *   is called an exception is thrown (as this typically a sign of programming error).
    */
-  $httpBackend.flush = function(count) {
-    $rootScope.$digest();
+  $httpBackend.flush = function(count, digest) {
+    if (digest !== false) $rootScope.$digest();
     if (!responses.length) throw new Error('No pending request to flush !');
 
-    if (angular.isDefined(count)) {
+    if (angular.isDefined(count) && count !== null) {
       while (count--) {
         if (!responses.length) throw new Error('No more pending request to flush !');
         responses.shift()();
@@ -1448,7 +1504,7 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
         responses.shift()();
       }
     }
-    $httpBackend.verifyNoOutstandingExpectation();
+    $httpBackend.verifyNoOutstandingExpectation(digest);
   };
 
 
@@ -1466,8 +1522,8 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
    *   afterEach($httpBackend.verifyNoOutstandingExpectation);
    * ```
    */
-  $httpBackend.verifyNoOutstandingExpectation = function() {
-    $rootScope.$digest();
+  $httpBackend.verifyNoOutstandingExpectation = function(digest) {
+    if (digest !== false) $rootScope.$digest();
     if (expectations.length) {
       throw new Error('Unsatisfied requests: ' + expectations.join(', '));
     }
@@ -1511,7 +1567,7 @@ function createHttpBackendMock($rootScope, $delegate, $browser) {
 
 
   function createShortMethods(prefix) {
-    angular.forEach(['GET', 'DELETE', 'JSONP'], function(method) {
+    angular.forEach(['GET', 'DELETE', 'JSONP', 'HEAD'], function(method) {
      $httpBackend[prefix + method] = function(url, headers) {
        return $httpBackend[prefix](method, url, undefined, headers);
      };
@@ -1541,6 +1597,7 @@ function MockHttpExpectation(method, url, data, headers) {
   this.matchUrl = function(u) {
     if (!url) return true;
     if (angular.isFunction(url.test)) return url.test(u);
+    if (angular.isFunction(url)) return url(u);
     return url == u;
   };
 
@@ -1627,7 +1684,7 @@ function MockXhr() {
  * that adds a "flush" and "verifyNoPendingTasks" methods.
  */
 
-angular.mock.$TimeoutDecorator = function($delegate, $browser) {
+angular.mock.$TimeoutDecorator = ['$delegate', '$browser', function ($delegate, $browser) {
 
   /**
    * @ngdoc method
@@ -1666,9 +1723,9 @@ angular.mock.$TimeoutDecorator = function($delegate, $browser) {
   }
 
   return $delegate;
-};
+}];
 
-angular.mock.$RAFDecorator = function($delegate) {
+angular.mock.$RAFDecorator = ['$delegate', function($delegate) {
   var queue = [];
   var rafFn = function(fn) {
     var index = queue.length;
@@ -1694,9 +1751,9 @@ angular.mock.$RAFDecorator = function($delegate) {
   };
 
   return rafFn;
-};
+}];
 
-angular.mock.$AsyncCallbackDecorator = function($delegate) {
+angular.mock.$AsyncCallbackDecorator = ['$delegate', function($delegate) {
   var callbacks = [];
   var addFn = function(fn) {
     callbacks.push(fn);
@@ -1708,7 +1765,7 @@ angular.mock.$AsyncCallbackDecorator = function($delegate) {
     callbacks = [];
   };
   return addFn;
-};
+}];
 
 /**
  *
@@ -1822,22 +1879,25 @@ angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) {
  * Creates a new backend definition.
  *
  * @param {string} method HTTP method.
- * @param {string|RegExp} url HTTP url.
+ * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+ *   and returns true if the url match the current definition.
  * @param {(string|RegExp)=} data HTTP request body.
  * @param {(Object|function(Object))=} headers HTTP headers or function that receives http header
  *   object and returns true if the headers match the current definition.
  * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that
- *   control how a matched request is handled.
+ *   control how a matched request is handled. You can save this object for later use and invoke
+ *   `respond` or `passThrough` again in order to change how a matched request is handled.
  *
- *  - respond –
+ *  - respond –
  *    `{function([status,] data[, headers, statusText])
  *    | function(function(method, url, data, headers)}`
- *    – The respond method takes a set of static data to be returned or a function that can return
+ *    – The respond method takes a set of static data to be returned or a function that can return
  *    an array containing response status (number), response data (string), response headers
  *    (Object), and the text for the status (string).
- *  - passThrough – `{function()}` – Any request matching a backend definition with
+ *  - passThrough – `{function()}` – Any request matching a backend definition with
  *    `passThrough` handler will be passed through to the real backend (an XHR request will be made
  *    to the server.)
+ *  - Both methods return the `requestHandler` object for possible overrides.
  */
 
 /**
@@ -1847,10 +1907,12 @@ angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) {
  * @description
  * Creates a new backend definition for GET requests. For more info see `when()`.
  *
- * @param {string|RegExp} url HTTP url.
+ * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+ *   and returns true if the url match the current definition.
  * @param {(Object|function(Object))=} headers HTTP headers.
  * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that
- *   control how a matched request is handled.
+ *   control how a matched request is handled. You can save this object for later use and invoke
+ *   `respond` or `passThrough` again in order to change how a matched request is handled.
  */
 
 /**
@@ -1860,10 +1922,12 @@ angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) {
  * @description
  * Creates a new backend definition for HEAD requests. For more info see `when()`.
  *
- * @param {string|RegExp} url HTTP url.
+ * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+ *   and returns true if the url match the current definition.
  * @param {(Object|function(Object))=} headers HTTP headers.
  * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that
- *   control how a matched request is handled.
+ *   control how a matched request is handled. You can save this object for later use and invoke
+ *   `respond` or `passThrough` again in order to change how a matched request is handled.
  */
 
 /**
@@ -1873,10 +1937,12 @@ angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) {
  * @description
  * Creates a new backend definition for DELETE requests. For more info see `when()`.
  *
- * @param {string|RegExp} url HTTP url.
+ * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+ *   and returns true if the url match the current definition.
  * @param {(Object|function(Object))=} headers HTTP headers.
  * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that
- *   control how a matched request is handled.
+ *   control how a matched request is handled. You can save this object for later use and invoke
+ *   `respond` or `passThrough` again in order to change how a matched request is handled.
  */
 
 /**
@@ -1886,11 +1952,13 @@ angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) {
  * @description
  * Creates a new backend definition for POST requests. For more info see `when()`.
  *
- * @param {string|RegExp} url HTTP url.
+ * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+ *   and returns true if the url match the current definition.
  * @param {(string|RegExp)=} data HTTP request body.
  * @param {(Object|function(Object))=} headers HTTP headers.
  * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that
- *   control how a matched request is handled.
+ *   control how a matched request is handled. You can save this object for later use and invoke
+ *   `respond` or `passThrough` again in order to change how a matched request is handled.
  */
 
 /**
@@ -1900,11 +1968,13 @@ angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) {
  * @description
  * Creates a new backend definition for PUT requests.  For more info see `when()`.
  *
- * @param {string|RegExp} url HTTP url.
+ * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+ *   and returns true if the url match the current definition.
  * @param {(string|RegExp)=} data HTTP request body.
  * @param {(Object|function(Object))=} headers HTTP headers.
  * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that
- *   control how a matched request is handled.
+ *   control how a matched request is handled. You can save this object for later use and invoke
+ *   `respond` or `passThrough` again in order to change how a matched request is handled.
  */
 
 /**
@@ -1914,11 +1984,13 @@ angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) {
  * @description
  * Creates a new backend definition for PATCH requests.  For more info see `when()`.
  *
- * @param {string|RegExp} url HTTP url.
+ * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+ *   and returns true if the url match the current definition.
  * @param {(string|RegExp)=} data HTTP request body.
  * @param {(Object|function(Object))=} headers HTTP headers.
  * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that
- *   control how a matched request is handled.
+ *   control how a matched request is handled. You can save this object for later use and invoke
+ *   `respond` or `passThrough` again in order to change how a matched request is handled.
  */
 
 /**
@@ -1928,30 +2000,17 @@ angular.module('ngMockE2E', ['ng']).config(['$provide', function($provide) {
  * @description
  * Creates a new backend definition for JSONP requests. For more info see `when()`.
  *
- * @param {string|RegExp} url HTTP url.
+ * @param {string|RegExp|function(string)} url HTTP url or function that receives the url
+ *   and returns true if the url match the current definition.
  * @returns {requestHandler} Returns an object with `respond` and `passThrough` methods that
- *   control how a matched request is handled.
+ *   control how a matched request is handled. You can save this object for later use and invoke
+ *   `respond` or `passThrough` again in order to change how a matched request is handled.
  */
 angular.mock.e2e = {};
 angular.mock.e2e.$httpBackendDecorator =
   ['$rootScope', '$delegate', '$browser', createHttpBackendMock];
 
 
-angular.mock.clearDataCache = function() {
-  var key,
-      cache = angular.element.cache;
-
-  for(key in cache) {
-    if (Object.prototype.hasOwnProperty.call(cache,key)) {
-      var handle = cache[key].handle;
-
-      handle && angular.element(handle.elem).off();
-      delete cache[key];
-    }
-  }
-};
-
-
 if(window.jasmine || window.mocha) {
 
   var currentSpec = null,
@@ -1982,8 +2041,6 @@ if(window.jasmine || window.mocha) {
       injector.get('$browser').pollFns.length = 0;
     }
 
-    angular.mock.clearDataCache();
-
     // clean up jquery's fragment cache
     angular.forEach(angular.element.fragments, function(val, key) {
       delete angular.element.fragments[key];
@@ -2003,6 +2060,7 @@ if(window.jasmine || window.mocha) {
    * @description
    *
    * *NOTE*: This function is also published on window for easy access.<br>
+   * *NOTE*: This function is declared ONLY WHEN running tests with jasmine or mocha
    *
    * This function registers a module configuration code. It collects the configuration information
    * which will be used when the injector is created by {@link angular.mock.inject inject}.
@@ -2045,6 +2103,7 @@ if(window.jasmine || window.mocha) {
    * @description
    *
    * *NOTE*: This function is also published on window for easy access.<br>
+   * *NOTE*: This function is declared ONLY WHEN running tests with jasmine or mocha
    *
    * The inject function wraps a function into an injectable function. The inject() creates new
    * instance of {@link auto.$injector $injector} per test, which is then used for
@@ -2144,14 +2203,28 @@ if(window.jasmine || window.mocha) {
     /////////////////////
     function workFn() {
       var modules = currentSpec.$modules || [];
-
+      var strictDi = !!currentSpec.$injectorStrict;
       modules.unshift('ngMock');
       modules.unshift('ng');
       var injector = currentSpec.$injector;
       if (!injector) {
-        injector = currentSpec.$injector = angular.injector(modules);
+        if (strictDi) {
+          // If strictDi is enabled, annotate the providerInjector blocks
+          angular.forEach(modules, function(moduleFn) {
+            if (typeof moduleFn === "function") {
+              angular.injector.$$annotate(moduleFn);
+            }
+          });
+        }
+        injector = currentSpec.$injector = angular.injector(modules, strictDi);
+        currentSpec.$injectorStrict = strictDi;
       }
       for(var i = 0, ii = blockFns.length; i < ii; i++) {
+        if (currentSpec.$injectorStrict) {
+          // If the injector is strict / strictDi, and the spec wants to inject using automatic
+          // annotation, then annotate the function here.
+          injector.annotate(blockFns[i]);
+        }
         try {
           /* jshint -W040 *//* Jasmine explicitly provides a `this` object when calling functions */
           injector.invoke(blockFns[i] || angular.noop, this);
@@ -2167,7 +2240,20 @@ if(window.jasmine || window.mocha) {
       }
     }
   };
-}
 
 
-})(window, window.angular);
\ No newline at end of file
+  angular.mock.inject.strictDi = function(value) {
+    value = arguments.length ? !!value : true;
+    return isSpecRunning() ? workFn() : workFn;
+
+    function workFn() {
+      if (value !== currentSpec.$injectorStrict) {
+        if (currentSpec.$injector) {
+          throw new Error('Injector already created, can not modify strict annotations');
+        } else {
+          currentSpec.$injectorStrict = value;
+        }
+      }
+    }
+  };
+}
diff --git a/syweb/webclient/js/angular-peity.js b/syweb/webclient/js/angular-peity.js
new file mode 100644
index 0000000000..2acb647d91
--- /dev/null
+++ b/syweb/webclient/js/angular-peity.js
@@ -0,0 +1,69 @@
+var angularPeity = angular.module( 'angular-peity', [] );
+
+$.fn.peity.defaults.pie = {
+  fill: ["#ff0000", "#aaaaaa"],
+  radius: 4,
+}
+
+var buildChartDirective = function ( chartType ) {
+	return {
+		restrict: 'E',
+		scope: {
+			data: "=",
+			options: "="
+		},
+		link: function ( scope, element, attrs ) {
+
+            var options = {};
+            if ( scope.options ) {
+				options = scope.options;
+            }
+            
+            // N.B. live-binding to data by Matthew
+            scope.$watch('data', function () {
+    			var span = document.createElement( 'span' );
+    			span.textContent = scope.data.join();
+
+                if ( !attrs.class ) {
+                    span.className = "";
+                } else {
+                    span.className = attrs.class;
+                }
+
+                if (element[0].nodeType === 8) {
+                    element.replaceWith( span );
+                }
+                else if (element[0].firstChild) {
+                    element.empty();
+                    element[0].appendChild( span );
+                }
+                else {
+                    element[0].appendChild( span );
+                }
+
+                jQuery( span ).peity( chartType, options );
+            });
+		}
+	};
+};
+
+
+angularPeity.directive( 'pieChart', function () {
+
+	return buildChartDirective( "pie" );
+
+} );
+
+
+angularPeity.directive( 'barChart', function () {
+
+	return buildChartDirective( "bar" );
+
+} );
+
+
+angularPeity.directive( 'lineChart', function () {
+
+	return buildChartDirective( "line" );
+
+} );
diff --git a/webclient/js/angular-route.js b/syweb/webclient/js/angular-route.js
index 305d92e855..305d92e855 100644
--- a/webclient/js/angular-route.js
+++ b/syweb/webclient/js/angular-route.js
diff --git a/webclient/js/angular-route.min.js b/syweb/webclient/js/angular-route.min.js
index 03da279ec3..03da279ec3 100644
--- a/webclient/js/angular-route.min.js
+++ b/syweb/webclient/js/angular-route.min.js
diff --git a/webclient/js/angular-sanitize.js b/syweb/webclient/js/angular-sanitize.js
index ec46895f68..ec46895f68 100644
--- a/webclient/js/angular-sanitize.js
+++ b/syweb/webclient/js/angular-sanitize.js
diff --git a/webclient/js/angular-sanitize.min.js b/syweb/webclient/js/angular-sanitize.min.js
index ce99bba18e..ce99bba18e 100644
--- a/webclient/js/angular-sanitize.min.js
+++ b/syweb/webclient/js/angular-sanitize.min.js
diff --git a/webclient/js/angular.js b/syweb/webclient/js/angular.js
index bdc97abb02..bdc97abb02 100644
--- a/webclient/js/angular.js
+++ b/syweb/webclient/js/angular.js
diff --git a/webclient/js/angular.min.js b/syweb/webclient/js/angular.min.js
index 5475589e2f..5475589e2f 100644
--- a/webclient/js/angular.min.js
+++ b/syweb/webclient/js/angular.min.js
diff --git a/webclient/js/autofill-event.js b/syweb/webclient/js/autofill-event.js
index 006f83e1be..006f83e1be 100755
--- a/webclient/js/autofill-event.js
+++ b/syweb/webclient/js/autofill-event.js
diff --git a/syweb/webclient/js/elastic.js b/syweb/webclient/js/elastic.js
new file mode 100644
index 0000000000..d585d81109
--- /dev/null
+++ b/syweb/webclient/js/elastic.js
@@ -0,0 +1,216 @@
+/*
+ * angular-elastic v2.4.0
+ * (c) 2014 Monospaced http://monospaced.com
+ * License: MIT
+ */
+
+angular.module('monospaced.elastic', [])
+
+  .constant('msdElasticConfig', {
+    append: ''
+  })
+
+  .directive('msdElastic', [
+    '$timeout', '$window', 'msdElasticConfig',
+    function($timeout, $window, config) {
+      'use strict';
+
+      return {
+        require: 'ngModel',
+        restrict: 'A, C',
+        link: function(scope, element, attrs, ngModel) {
+
+          // cache a reference to the DOM element
+          var ta = element[0],
+              $ta = element;
+
+          // ensure the element is a textarea, and browser is capable
+          if (ta.nodeName !== 'TEXTAREA' || !$window.getComputedStyle) {
+            return;
+          }
+
+          // set these properties before measuring dimensions
+          $ta.css({
+            'overflow': 'hidden',
+            'overflow-y': 'hidden',
+            'word-wrap': 'break-word'
+          });
+
+          // force text reflow
+          var text = ta.value;
+          ta.value = '';
+          ta.value = text;
+
+          var append = attrs.msdElastic ? attrs.msdElastic.replace(/\\n/g, '\n') : config.append,
+              $win = angular.element($window),
+              mirrorInitStyle = 'position: absolute; top: -999px; right: auto; bottom: auto;' +
+                                'left: 0; overflow: hidden; -webkit-box-sizing: content-box;' +
+                                '-moz-box-sizing: content-box; box-sizing: content-box;' +
+                                'min-height: 0 !important; height: 0 !important; padding: 0;' +
+                                'word-wrap: break-word; border: 0;',
+              $mirror = angular.element('<textarea tabindex="-1" ' +
+                                        'style="' + mirrorInitStyle + '"/>').data('elastic', true),
+              mirror = $mirror[0],
+              taStyle = getComputedStyle(ta),
+              resize = taStyle.getPropertyValue('resize'),
+              borderBox = taStyle.getPropertyValue('box-sizing') === 'border-box' ||
+                          taStyle.getPropertyValue('-moz-box-sizing') === 'border-box' ||
+                          taStyle.getPropertyValue('-webkit-box-sizing') === 'border-box',
+              boxOuter = !borderBox ? {width: 0, height: 0} : {
+                            width:  parseInt(taStyle.getPropertyValue('border-right-width'), 10) +
+                                    parseInt(taStyle.getPropertyValue('padding-right'), 10) +
+                                    parseInt(taStyle.getPropertyValue('padding-left'), 10) +
+                                    parseInt(taStyle.getPropertyValue('border-left-width'), 10),
+                            height: parseInt(taStyle.getPropertyValue('border-top-width'), 10) +
+                                    parseInt(taStyle.getPropertyValue('padding-top'), 10) +
+                                    parseInt(taStyle.getPropertyValue('padding-bottom'), 10) +
+                                    parseInt(taStyle.getPropertyValue('border-bottom-width'), 10)
+                          },
+              minHeightValue = parseInt(taStyle.getPropertyValue('min-height'), 10),
+              heightValue = parseInt(taStyle.getPropertyValue('height'), 10),
+              minHeight = Math.max(minHeightValue, heightValue) - boxOuter.height,
+              maxHeight = parseInt(taStyle.getPropertyValue('max-height'), 10),
+              mirrored,
+              active,
+              copyStyle = ['font-family',
+                           'font-size',
+                           'font-weight',
+                           'font-style',
+                           'letter-spacing',
+                           'line-height',
+                           'text-transform',
+                           'word-spacing',
+                           'text-indent'];
+
+          // exit if elastic already applied (or is the mirror element)
+          if ($ta.data('elastic')) {
+            return;
+          }
+
+          // Opera returns max-height of -1 if not set
+          maxHeight = maxHeight && maxHeight > 0 ? maxHeight : 9e4;
+
+          // append mirror to the DOM
+          if (mirror.parentNode !== document.body) {
+            angular.element(document.body).append(mirror);
+          }
+
+          // set resize and apply elastic
+          $ta.css({
+            'resize': (resize === 'none' || resize === 'vertical') ? 'none' : 'horizontal'
+          }).data('elastic', true);
+
+          /*
+           * methods
+           */
+
+          function initMirror() {
+            var mirrorStyle = mirrorInitStyle;
+
+            mirrored = ta;
+            // copy the essential styles from the textarea to the mirror
+            taStyle = getComputedStyle(ta);
+            angular.forEach(copyStyle, function(val) {
+              mirrorStyle += val + ':' + taStyle.getPropertyValue(val) + ';';
+            });
+            mirror.setAttribute('style', mirrorStyle);
+          }
+
+          function adjust() {
+            var taHeight,
+                taComputedStyleWidth,
+                mirrorHeight,
+                width,
+                overflow;
+
+            if (mirrored !== ta) {
+              initMirror();
+            }
+
+            // active flag prevents actions in function from calling adjust again
+            if (!active) {
+              active = true;
+
+              mirror.value = ta.value + append; // optional whitespace to improve animation
+              mirror.style.overflowY = ta.style.overflowY;
+
+              taHeight = ta.style.height === '' ? 'auto' : parseInt(ta.style.height, 10);
+
+              taComputedStyleWidth = getComputedStyle(ta).getPropertyValue('width');
+
+              // ensure getComputedStyle has returned a readable 'used value' pixel width
+              if (taComputedStyleWidth.substr(taComputedStyleWidth.length - 2, 2) === 'px') {
+                // update mirror width in case the textarea width has changed
+                width = parseInt(taComputedStyleWidth, 10) - boxOuter.width;
+                mirror.style.width = width + 'px';
+              }
+
+              mirrorHeight = mirror.scrollHeight;
+
+              if (mirrorHeight > maxHeight) {
+                mirrorHeight = maxHeight;
+                overflow = 'scroll';
+              } else if (mirrorHeight < minHeight) {
+                mirrorHeight = minHeight;
+              }
+              mirrorHeight += boxOuter.height;
+
+              ta.style.overflowY = overflow || 'hidden';
+
+              if (taHeight !== mirrorHeight) {
+                ta.style.height = mirrorHeight + 'px';
+                scope.$emit('elastic:resize', $ta);
+              }
+
+              // small delay to prevent an infinite loop
+              $timeout(function() {
+                active = false;
+              }, 1);
+
+            }
+          }
+
+          function forceAdjust() {
+            active = false;
+            adjust();
+          }
+
+          /*
+           * initialise
+           */
+
+          // listen
+          if ('onpropertychange' in ta && 'oninput' in ta) {
+            // IE9
+            ta['oninput'] = ta.onkeyup = adjust;
+          } else {
+            ta['oninput'] = adjust;
+          }
+
+          $win.bind('resize', forceAdjust);
+
+          scope.$watch(function() {
+            return ngModel.$modelValue;
+          }, function(newValue) {
+            forceAdjust();
+          });
+
+          scope.$on('elastic:adjust', function() {
+            initMirror();
+            forceAdjust();
+          });
+
+          $timeout(adjust);
+
+          /*
+           * destroy
+           */
+
+          scope.$on('$destroy', function() {
+            $mirror.remove();
+            $win.unbind('resize', forceAdjust);
+          });
+        }
+      };
+    }
+  ]);
diff --git a/webclient/js/jquery-1.8.3.min.js b/syweb/webclient/js/jquery-1.8.3.min.js
index 3883779527..3883779527 100644
--- a/webclient/js/jquery-1.8.3.min.js
+++ b/syweb/webclient/js/jquery-1.8.3.min.js
diff --git a/syweb/webclient/js/jquery.peity.min.js b/syweb/webclient/js/jquery.peity.min.js
new file mode 100644
index 0000000000..054b83c5d8
--- /dev/null
+++ b/syweb/webclient/js/jquery.peity.min.js
@@ -0,0 +1,13 @@
+// Peity jQuery plugin version 3.0.2
+// (c) 2014 Ben Pickles
+//
+// http://benpickles.github.io/peity
+//
+// Released under MIT license.
+(function(h,w,i,v){var p=function(a,b){var d=w.createElementNS("http://www.w3.org/2000/svg",a);h(d).attr(b);return d},y="createElementNS"in w&&p("svg",{}).createSVGRect,e=h.fn.peity=function(a,b){y&&this.each(function(){var d=h(this),c=d.data("peity");c?(a&&(c.type=a),h.extend(c.opts,b)):(c=new x(d,a,h.extend({},e.defaults[a],b)),d.change(function(){c.draw()}).data("peity",c));c.draw()});return this},x=function(a,b,d){this.$el=a;this.type=b;this.opts=d},r=x.prototype;r.draw=function(){e.graphers[this.type].call(this,
+this.opts)};r.fill=function(){var a=this.opts.fill;return h.isFunction(a)?a:function(b,d){return a[d%a.length]}};r.prepare=function(a,b){this.svg||this.$el.hide().after(this.svg=p("svg",{"class":"peity"}));return h(this.svg).empty().data("peity",this).attr({height:b,width:a})};r.values=function(){return h.map(this.$el.text().split(this.opts.delimiter),function(a){return parseFloat(a)})};e.defaults={};e.graphers={};e.register=function(a,b,d){this.defaults[a]=b;this.graphers[a]=d};e.register("pie",
+{fill:["#ff9900","#fff4dd","#ffc66e"],radius:8},function(a){if(!a.delimiter){var b=this.$el.text().match(/[^0-9\.]/);a.delimiter=b?b[0]:","}b=this.values();if("/"==a.delimiter)var d=b[0],b=[d,i.max(0,b[1]-d)];for(var c=0,d=b.length,n=0;c<d;c++)n+=b[c];for(var c=2*a.radius,f=this.prepare(a.width||c,a.height||c),c=f.width(),f=f.height(),s=c/2,k=f/2,f=i.min(s,k),a=a.innerRadius,e=i.PI,q=this.fill(),g=this.scale=function(a,b){var c=2*a/n*e-e/2;return[b*i.cos(c)+s,b*i.sin(c)+k]},l=0,c=0;c<d;c++){var t=
+b[c],j=t/n;if(0!=j){if(1==j)if(a)var j=s-0.01,o=k-f,m=k-a,j=p("path",{d:["M",s,o,"A",f,f,0,1,1,j,o,"L",j,m,"A",a,a,0,1,0,s,m].join(" ")});else j=p("circle",{cx:s,cy:k,r:f});else o=l+t,m=["M"].concat(g(l,f),"A",f,f,0,0.5<j?1:0,1,g(o,f),"L"),a?m=m.concat(g(o,a),"A",a,a,0,0.5<j?1:0,0,g(l,a)):m.push(s,k),l+=t,j=p("path",{d:m.join(" ")});h(j).attr("fill",q.call(this,t,c,b));this.svg.appendChild(j)}}});e.register("donut",h.extend(!0,{},e.defaults.pie),function(a){a.innerRadius||(a.innerRadius=0.5*a.radius);
+e.graphers.pie.call(this,a)});e.register("line",{delimiter:",",fill:"#c6d9fd",height:16,min:0,stroke:"#4d89f9",strokeWidth:1,width:32},function(a){var b=this.values();1==b.length&&b.push(b[0]);for(var d=i.max.apply(i,a.max==v?b:b.concat(a.max)),c=i.min.apply(i,a.min==v?b:b.concat(a.min)),n=this.prepare(a.width,a.height),f=a.strokeWidth,e=n.width(),k=n.height()-f,h=d-c,d=this.x=function(a){return a*(e/(b.length-1))},n=this.y=function(a){var b=k;h&&(b-=(a-c)/h*k);return b+f/2},q=n(i.max(c,0)),g=[0,
+q],l=0;l<b.length;l++)g.push(d(l),n(b[l]));g.push(e,q);this.svg.appendChild(p("polygon",{fill:a.fill,points:g.join(" ")}));f&&this.svg.appendChild(p("polyline",{fill:"transparent",points:g.slice(2,g.length-2).join(" "),stroke:a.stroke,"stroke-width":f,"stroke-linecap":"square"}))});e.register("bar",{delimiter:",",fill:["#4D89F9"],height:16,min:0,padding:0.1,width:32},function(a){for(var b=this.values(),d=i.max.apply(i,a.max==v?b:b.concat(a.max)),c=i.min.apply(i,a.min==v?b:b.concat(a.min)),e=this.prepare(a.width,
+a.height),f=e.width(),h=e.height(),k=d-c,a=a.padding,e=this.fill(),r=this.x=function(a){return a*f/b.length},q=this.y=function(a){return h-(k?(a-c)/k*h:1)},g=0;g<b.length;g++){var l=r(g+a),t=r(g+1-a)-l,j=b[g],o=q(j),m=o,u;k?0>j?m=q(i.min(d,0)):o=q(i.max(c,0)):u=1;u=o-m;0==u&&(u=1,0<d&&k&&m--);this.svg.appendChild(p("rect",{fill:e.call(this,j,g,b),x:l,y:m,width:t,height:u}))}})})(jQuery,document,Math);
diff --git a/webclient/js/ng-infinite-scroll-matrix.js b/syweb/webclient/js/ng-infinite-scroll-matrix.js
index 045ec8d93e..045ec8d93e 100644
--- a/webclient/js/ng-infinite-scroll-matrix.js
+++ b/syweb/webclient/js/ng-infinite-scroll-matrix.js
diff --git a/webclient/js/ui-bootstrap-tpls-0.11.2.js b/syweb/webclient/js/ui-bootstrap-tpls-0.11.2.js
index 260c2769b8..260c2769b8 100644
--- a/webclient/js/ui-bootstrap-tpls-0.11.2.js
+++ b/syweb/webclient/js/ui-bootstrap-tpls-0.11.2.js
diff --git a/webclient/login/login-controller.js b/syweb/webclient/login/login-controller.js
index 5ef39a7122..5ef39a7122 100644
--- a/webclient/login/login-controller.js
+++ b/syweb/webclient/login/login-controller.js
diff --git a/webclient/login/login.html b/syweb/webclient/login/login.html
index 6b321f8fc5..6b321f8fc5 100644
--- a/webclient/login/login.html
+++ b/syweb/webclient/login/login.html
diff --git a/webclient/login/register-controller.js b/syweb/webclient/login/register-controller.js
index be970ce1c3..b23a72b185 100644
--- a/webclient/login/register-controller.js
+++ b/syweb/webclient/login/register-controller.js
@@ -124,7 +124,7 @@ angular.module('RegisterController', ['matrixService'])
                 $location.url("home");
             },
             function(error) {
-                console.trace("Registration error: "+error);
+                console.error("Registration error: "+JSON.stringify(error));
                 if (useCaptcha) {
                     Recaptcha.reload();
                 }
diff --git a/webclient/login/register.html b/syweb/webclient/login/register.html
index a27f9ad4e8..a27f9ad4e8 100644
--- a/webclient/login/register.html
+++ b/syweb/webclient/login/register.html
diff --git a/webclient/media/busy.mp3 b/syweb/webclient/media/busy.mp3
index fec27ba4c5..fec27ba4c5 100644
--- a/webclient/media/busy.mp3
+++ b/syweb/webclient/media/busy.mp3
Binary files differdiff --git a/webclient/media/busy.ogg b/syweb/webclient/media/busy.ogg
index 5d64a7d0d9..5d64a7d0d9 100644
--- a/webclient/media/busy.ogg
+++ b/syweb/webclient/media/busy.ogg
Binary files differdiff --git a/webclient/media/callend.mp3 b/syweb/webclient/media/callend.mp3
index 50c34e5640..50c34e5640 100644
--- a/webclient/media/callend.mp3
+++ b/syweb/webclient/media/callend.mp3
Binary files differdiff --git a/webclient/media/callend.ogg b/syweb/webclient/media/callend.ogg
index 927ce1f634..927ce1f634 100644
--- a/webclient/media/callend.ogg
+++ b/syweb/webclient/media/callend.ogg
Binary files differdiff --git a/webclient/media/ring.mp3 b/syweb/webclient/media/ring.mp3
index 3c3cdde3f9..3c3cdde3f9 100644
--- a/webclient/media/ring.mp3
+++ b/syweb/webclient/media/ring.mp3
Binary files differdiff --git a/webclient/media/ring.ogg b/syweb/webclient/media/ring.ogg
index de49b8ae6f..de49b8ae6f 100644
--- a/webclient/media/ring.ogg
+++ b/syweb/webclient/media/ring.ogg
Binary files differdiff --git a/webclient/media/ringback.mp3 b/syweb/webclient/media/ringback.mp3
index 6ee34bf395..6ee34bf395 100644
--- a/webclient/media/ringback.mp3
+++ b/syweb/webclient/media/ringback.mp3
Binary files differdiff --git a/webclient/media/ringback.ogg b/syweb/webclient/media/ringback.ogg
index 7dbfdcd017..7dbfdcd017 100644
--- a/webclient/media/ringback.ogg
+++ b/syweb/webclient/media/ringback.ogg
Binary files differdiff --git a/webclient/mobile.css b/syweb/webclient/mobile.css
index 6fa9221ccf..32b01c503d 100644
--- a/webclient/mobile.css
+++ b/syweb/webclient/mobile.css
@@ -1,4 +1,13 @@
 /*** Mobile voodoo ***/
+
+/** iPads **/
+@media all and (max-device-width: 768px) {
+    #roomRecentsTableWrapper {
+        display: none;
+    }
+}
+
+/** iPhones **/
 @media all and (max-device-width: 640px) {
             
     #messageTableWrapper {
@@ -37,11 +46,16 @@
         max-width: 640px ! important;
     }    
     
+    #controls {
+        padding: 0px;
+    }
+    
     #headerUserId,
     #roomHeader img,
     #userIdCell,
     #roomRecentsTableWrapper,
     #usersTableWrapper,
+    #controlButtons,
     .extraControls {
         display: none;
     }
@@ -64,6 +78,10 @@
         padding-top: 10px;
     }
     
+    .roomHeaderInfo {
+        margin-right: 0px;
+    }
+    
     #roomName {
         font-size: 12px ! important;
         margin-top: 0px ! important;
diff --git a/syweb/webclient/recents/recents-controller.js b/syweb/webclient/recents/recents-controller.js
new file mode 100644
index 0000000000..41720d4cb0
--- /dev/null
+++ b/syweb/webclient/recents/recents-controller.js
@@ -0,0 +1,53 @@
+/*
+ Copyright 2014 OpenMarket Ltd
+ 
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+ 
+ http://www.apache.org/licenses/LICENSE-2.0
+ 
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+'use strict';
+
+angular.module('RecentsController', ['matrixService', 'matrixFilter'])
+.controller('RecentsController', ['$rootScope', '$scope', 'eventHandlerService', 'modelService', 'recentsService',
+                               function($rootScope, $scope, eventHandlerService, modelService, recentsService) {
+
+    // Expose the service to the view
+    $scope.eventHandlerService = eventHandlerService;
+    
+    // retrieve all rooms and expose them
+    $scope.rooms = modelService.getRooms();
+    
+    // track the selected room ID: the html will use this
+    $scope.recentsSelectedRoomID = recentsService.getSelectedRoomId();
+    $scope.$on(recentsService.BROADCAST_SELECTED_ROOM_ID, function(ngEvent, room_id) {
+        $scope.recentsSelectedRoomID = room_id;
+    });
+    
+    // track the list of unread messages: the html will use this
+    $scope.unreadMessages = recentsService.getUnreadMessages();
+    $scope.$on(recentsService.BROADCAST_UNREAD_MESSAGES, function(ngEvent, room_id, unreadCount) {
+        $scope.unreadMessages = recentsService.getUnreadMessages();
+    });
+    
+    // track the list of unread BING messages: the html will use this
+    $scope.unreadBings = recentsService.getUnreadBingMessages();
+    $scope.$on(recentsService.BROADCAST_UNREAD_BING_MESSAGES, function(ngEvent, room_id, event) {
+        $scope.unreadBings = recentsService.getUnreadBingMessages();
+    });
+    
+    $scope.selectRoom = function(room) {
+        recentsService.markAsRead(room.room_id);
+        $rootScope.goToPage('room/' + (room.room_alias ? room.room_alias : room.room_id) );
+    };
+
+}]);
+
diff --git a/webclient/recents/recents-filter.js b/syweb/webclient/recents/recents-filter.js
index ef8d9897f7..cfbc6f4bd8 100644
--- a/webclient/recents/recents-filter.js
+++ b/syweb/webclient/recents/recents-filter.js
@@ -17,7 +17,7 @@
 'use strict';
 
 angular.module('RecentsController')
-.filter('orderRecents', ["matrixService", "eventHandlerService", function(matrixService, eventHandlerService) {
+.filter('orderRecents', ["matrixService", "eventHandlerService", "modelService", function(matrixService, eventHandlerService, modelService) {
     return function(rooms) {
         var user_id = matrixService.config().user_id;
 
@@ -25,26 +25,33 @@ angular.module('RecentsController')
         // The key, room_id, is already in value objects
         var filtered = [];
         angular.forEach(rooms, function(room, room_id) {
-            
+            room.recent = {};
+            var meEvent = room.current_room_state.state("m.room.member", user_id);
             // Show the room only if the user has joined it or has been invited
             // (ie, do not show it if he has been banned)
-            var member = eventHandlerService.getMember(room_id, user_id);
-            if (member && ("invite" === member.membership || "join" === member.membership)) {
-            
+            var member = modelService.getMember(room_id, user_id);
+            if (member) {
+                member = member.event;
+            }
+            room.recent.me = member;
+            if (member && ("invite" === member.content.membership || "join" === member.content.membership)) {
+                if ("invite" === member.content.membership) {
+                    room.recent.inviter = member.user_id;
+                }
                 // Count users here
                 // TODO: Compute it directly in eventHandlerService
-                room.numUsersInRoom = eventHandlerService.getUsersCountInRoom(room_id);
+                room.recent.numUsersInRoom = eventHandlerService.getUsersCountInRoom(room_id);
 
                 filtered.push(room);
             }
-            else if ("invite" === room.membership) {
+            else if (meEvent && "invite" === meEvent.content.membership) {
                 // The only information we have about the room is that the user has been invited
                 filtered.push(room);
             }
         });
 
         // And time sort them
-        // The room with the lastest message at first
+        // The room with the latest message at first
         filtered.sort(function (roomA, roomB) {
 
             var lastMsgRoomA = eventHandlerService.getLastMessage(roomA.room_id, true);
diff --git a/webclient/recents/recents.html b/syweb/webclient/recents/recents.html
index a52b215c7e..0b3a77ca11 100644
--- a/webclient/recents/recents.html
+++ b/syweb/webclient/recents/recents.html
@@ -1,16 +1,16 @@
 <div ng-controller="RecentsController">
     <table class="recentsTable">
-        <tbody ng-repeat="(index, room) in events.rooms | orderRecents" 
-               ng-click="goToPage('room/' + (room.room_alias ? room.room_alias : room.room_id) )" 
-               class="recentsRoom" 
-               ng-class="{'recentsRoomSelected': (room.room_id === recentsSelectedRoomID)}">                                           
+        <tbody ng-repeat="(index, room) in rooms | orderRecents" 
+               ng-click="selectRoom(room)" 
+               class="recentsRoom"
+               ng-class="{'recentsRoomSelected': (room.room_id === recentsSelectedRoomID), 'recentsRoomBing': (unreadBings[room.room_id]), 'recentsRoomUnread': (unreadMessages[room.room_id])}">
             <tr>
-                <td ng-class="room['m.room.join_rules'].content.join_rule == 'public' ? 'recentsRoomName recentsPublicRoom' : 'recentsRoomName'">
+                <td ng-class="room.current_room_state.state('m.room.join_rules').content.join_rule == 'public' ? 'recentsRoomName recentsPublicRoom' : 'recentsRoomName'">
                     {{ room.room_id | mRoomName }}
                 </td>
                 <td class="recentsRoomSummaryUsersCount">
-                    <span ng-show="undefined !== room.numUsersInRoom">
-                        {{ room.numUsersInRoom || '1' }} {{ room.numUsersInRoom == 1 ? 'user' : 'users' }}                     
+                    <span ng-show="undefined !== room.recent.numUsersInRoom">
+                        {{ room.recent.numUsersInRoom || '1' }} {{ room.recent.numUsersInRoom == 1 ? 'user' : 'users' }}                     
                     </span>
                 </td>
                 <td class="recentsRoomSummaryTS">
@@ -27,11 +27,11 @@
             <tr>
                 <td colspan="3" class="recentsRoomSummary">
 
-                    <div ng-show="room.membership === 'invite'">
-                        {{ room.inviter | mUserDisplayName: room.room_id }} invited you
+                    <div ng-show="room.recent.me.content.membership === 'invite'">
+                        {{ room.recent.inviter | mUserDisplayName: room.room_id }} invited you
                     </div>
                     
-                    <div ng-hide="room.membership === 'invite'" ng-switch="lastMsg.type">
+                    <div ng-hide="room.recent.me.membership === 'invite'" ng-switch="lastMsg.type">
                         <div ng-switch-when="m.room.member">
                             <span ng-switch="lastMsg.changedKey">
                                 <span ng-switch-when="membership">
diff --git a/webclient/room/room-controller.js b/syweb/webclient/room/room-controller.js
index 841b5cccdd..67372a804f 100644
--- a/webclient/room/room-controller.js
+++ b/syweb/webclient/room/room-controller.js
@@ -14,12 +14,15 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
-.controller('RoomController', ['$modal', '$filter', '$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'mPresence', 'eventHandlerService', 'mFileUpload', 'matrixPhoneService', 'MatrixCall',
-                               function($modal, $filter, $scope, $timeout, $routeParams, $location, $rootScope, matrixService, mPresence, eventHandlerService, mFileUpload, matrixPhoneService, MatrixCall) {
+angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput', 'angular-peity'])
+.controller('RoomController', ['$modal', '$filter', '$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'mPresence', 'eventHandlerService', 'mFileUpload', 'matrixPhoneService', 'MatrixCall', 'modelService', 'recentsService', 'commandsService',
+                               function($modal, $filter, $scope, $timeout, $routeParams, $location, $rootScope, matrixService, mPresence, eventHandlerService, mFileUpload, matrixPhoneService, MatrixCall, modelService, recentsService, commandsService) {
    'use strict';
     var MESSAGES_PER_PAGINATION = 30;
     var THUMBNAIL_SIZE = 320;
+    
+    // .html needs this
+    $scope.containsBingWord = eventHandlerService.eventContainsBingWord;
 
     // Room ids. Computed and resolved in onInit
     $scope.room_id = undefined;
@@ -36,12 +39,8 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         messages_visibility: "hidden", // In order to avoid flickering when scrolling down the message table at the page opening, delay the message table display
     };
     $scope.members = {};
-    $scope.autoCompleting = false;
-    $scope.autoCompleteIndex = 0;    
-    $scope.autoCompleteOriginal = "";
 
     $scope.imageURLToSend = "";
-    $scope.userIDToInvite = "";
     
 
     // vars and functions for updating the name
@@ -54,7 +53,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
                 return;
             };
 
-            var nameEvent = $rootScope.events.rooms[$scope.room_id]['m.room.name'];
+            var nameEvent = $scope.room.current_room_state.state_events['m.room.name'];
             if (nameEvent) {
                 $scope.name.newNameText = nameEvent.content.name;
             }
@@ -95,7 +94,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
                 console.log("Warning: Already editing topic.");
                 return;
             }
-            var topicEvent = $rootScope.events.rooms[$scope.room_id]['m.room.topic'];
+            var topicEvent = $scope.room.current_room_state.state_events['m.room.topic'];
             if (topicEvent) {
                 $scope.topic.newTopicText = topicEvent.content.topic;
             }
@@ -152,7 +151,6 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
 
     $scope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) {
         if (isLive && event.room_id === $scope.room_id) {
-            
             scrollToBottom();
         }
     });
@@ -187,21 +185,6 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
             else {
                 scrollToBottom();
                 updateMemberList(event); 
-
-                // Notify when a user joins
-                if ((document.hidden  || matrixService.presence.unavailable === mPresence.getState())
-                        && event.state_key !== $scope.state.user_id  && "join" === event.membership) {
-                    var notification = new window.Notification(
-                        event.content.displayname +
-                        " (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here
-                    {
-                        "body": event.content.displayname + " joined",
-                        "icon": event.content.avatar_url ? event.content.avatar_url : undefined
-                    });
-                    $timeout(function() {
-                        notification.close();
-                    }, 5 * 1000);
-                }
             }
         }
     });
@@ -240,11 +223,11 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
             $scope.state.paginating = true;
         }
         
-        console.log("paginateBackMessages from " + $rootScope.events.rooms[$scope.room_id].pagination.earliest_token + " for " + numItems);
+        console.log("paginateBackMessages from " + $scope.room.old_room_state.pagination_token + " for " + numItems);
         var originalTopRow = $("#messageTable>tbody>tr:first")[0];
         
         // Paginate events from the point in cache
-        matrixService.paginateBackMessages($scope.room_id, $rootScope.events.rooms[$scope.room_id].pagination.earliest_token, numItems).then(
+        matrixService.paginateBackMessages($scope.room_id, $scope.room.old_room_state.pagination_token, numItems).then(
             function(response) {
 
                 eventHandlerService.handleRoomMessages($scope.room_id, response.data, false, 'b');
@@ -327,8 +310,9 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
             }
             $scope.members[target_user_id] = chunk;   
 
-            if (target_user_id in $rootScope.presence) {
-                updatePresence($rootScope.presence[target_user_id]);
+            var usr = modelService.getUser(target_user_id);
+            if (usr) {
+                updatePresence(usr.event);
             }
         }
         else {
@@ -390,7 +374,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
     var updateUserPowerLevel = function(user_id) {
         var member = $scope.members[user_id];
         if (member) {
-            member.powerLevel = matrixService.getUserPowerLevel($scope.room_id, user_id);
+            member.powerLevel = eventHandlerService.getUserPowerLevel($scope.room_id, user_id);
             
             normaliseMembersPowerLevels();
         }
@@ -431,172 +415,25 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         scrollToBottom(true);
 
         // Store the command in the history
-        history.push(input);
+        $rootScope.$broadcast("commandHistory:BROADCAST_NEW_HISTORY_ITEM(item)",
+                              input);
 
+        var isEmote = input.indexOf("/me ") === 0;
         var promise;
-        var cmd;
-        var args;
+        if (!isEmote) {
+            promise = commandsService.processInput($scope.room_id, input);
+        }
         var echo = false;
         
-        // Check for IRC style commands first
-        // trim any trailing whitespace, as it can confuse the parser for IRC-style commands
-        input = input.replace(/\s+$/, "");
-        
-        if (input[0] === "/" && input[1] !== "/") {
-            var bits = input.match(/^(\S+?)( +(.*))?$/);
-            cmd = bits[1];
-            args = bits[3];
-            
-            console.log("cmd: " + cmd + ", args: " + args);
-            
-            switch (cmd) {
-                case "/me":
-                    promise = matrixService.sendEmoteMessage($scope.room_id, args);
-                    echo = true;
-                    break;
-                    
-                case "/nick":
-                    // Change user display name
-                    if (args) {
-                        promise = matrixService.setDisplayName(args);                     
-                    }
-                    else {
-                        $scope.feedback = "Usage: /nick <display_name>";
-                    }
-                    break;
-
-                case "/join":
-                    // Join a room
-                    if (args) {
-                        var matches = args.match(/^(\S+)$/);
-                        if (matches) {
-                            var room_alias = matches[1];
-                            if (room_alias.indexOf(':') == -1) {
-                                // FIXME: actually track the :domain style name of our homeserver
-                                // with or without port as is appropriate and append it at this point
-                            }
-                            
-                            var room_id = matrixService.getAliasToRoomIdMapping(room_alias);
-                            console.log("joining " + room_alias + " id=" + room_id);
-                            if ($rootScope.events.rooms[room_id]) {
-                                // don't send a join event for a room you're already in.
-                                $location.url("room/" + room_alias);
-                            }
-                            else {
-                                promise = matrixService.joinAlias(room_alias).then(
-                                    function(response) {
-                                        // TODO: factor out the common housekeeping whenever we try to join a room or alias
-                                        matrixService.roomState(response.room_id).then(
-                                            function(response) {
-                                                eventHandlerService.handleEvents(response.data, false, true);
-                                            },
-                                            function(error) {
-                                                $scope.feedback = "Failed to get room state for: " + response.room_id;
-                                            }
-                                        );                                        
-                                        $location.url("room/" + room_alias);
-                                    },
-                                    function(error) {
-                                        $scope.feedback = "Can't join room: " + JSON.stringify(error.data);
-                                    }
-                                );
-                            }
-                        }
-                    }
-                    else {
-                        $scope.feedback = "Usage: /join <room_alias>";
-                    }
-                    break;
-                    
-                case "/kick":
-                    // Kick a user from the room with an optional reason
-                    if (args) {
-                        var matches = args.match(/^(\S+?)( +(.*))?$/);
-                        if (matches) {
-                            promise = matrixService.kick($scope.room_id, matches[1], matches[3]);
-                        }
-                    }
-
-                    if (!promise) {
-                        $scope.feedback = "Usage: /kick <userId> [<reason>]";
-                    }
-                    break;
-
-                case "/ban":
-                    // Ban a user from the room with an optional reason
-                    if (args) {
-                        var matches = args.match(/^(\S+?)( +(.*))?$/);
-                        if (matches) {
-                            promise = matrixService.ban($scope.room_id, matches[1], matches[3]);
-                        }
-                    }
-                    
-                    if (!promise) {
-                        $scope.feedback = "Usage: /ban <userId> [<reason>]";
-                    }
-                    break;
-
-                case "/unban":
-                    // Unban a user from the room
-                    if (args) {
-                        var matches = args.match(/^(\S+)$/);
-                        if (matches) {
-                            // Reset the user membership to "leave" to unban him
-                            promise = matrixService.unban($scope.room_id, matches[1]);
-                        }
-                    }
-                    
-                    if (!promise) {
-                        $scope.feedback = "Usage: /unban <userId>";
-                    }
-                    break;
-                    
-                case "/op":
-                    // Define the power level of a user
-                    if (args) {
-                        var matches = args.match(/^(\S+?)( +(\d+))?$/);
-                        var powerLevel = 50; // default power level for op
-                        if (matches) {
-                            var user_id = matches[1];
-                            if (matches.length === 4 && undefined !== matches[3]) {
-                                powerLevel = parseInt(matches[3]);
-                            }
-                            if (powerLevel !== NaN) {
-                                promise = matrixService.setUserPowerLevel($scope.room_id, user_id, powerLevel);
-                            }
-                        }
-                    }
-                    
-                    if (!promise) {
-                        $scope.feedback = "Usage: /op <userId> [<power level>]";
-                    }
-                    break;
-                    
-                case "/deop":
-                    // Reset the power level of a user
-                    if (args) {
-                        var matches = args.match(/^(\S+)$/);
-                        if (matches) {
-                            promise = matrixService.setUserPowerLevel($scope.room_id, args, undefined);
-                        }
-                    }
-                    
-                    if (!promise) {
-                        $scope.feedback = "Usage: /deop <userId>";
-                    }
-                    break;
-                
-                default:
-                    $scope.feedback = ("Unrecognised IRC-style command: " + cmd);
-                    break;
-            }
-        }
         
-        // By default send this as a message unless it's an IRC-style command
-        if (!promise && !cmd) {
-            // Make the request
-            promise = matrixService.sendTextMessage($scope.room_id, input);
+        if (!promise) { // not a non-echoable command
             echo = true;
+            if (isEmote) {
+                promise = matrixService.sendEmoteMessage($scope.room_id, input.substring(4));
+            }
+            else {
+                promise = matrixService.sendTextMessage($scope.room_id, input);
+            }
         }
         
         if (echo) {
@@ -604,8 +441,8 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
             // To do so, create a minimalist fake text message event and add it to the in-memory list of room messages
             var echoMessage = {
                 content: {
-                    body: (cmd === "/me" ? args : input),
-                    msgtype: (cmd === "/me" ? "m.emote" : "m.text"),
+                    body: (isEmote ? input.substring(4) : input),
+                    msgtype: (isEmote ? "m.emote" : "m.text"),
                 },
                 origin_server_ts: new Date().getTime(), // fake a timestamp
                 room_id: $scope.room_id,
@@ -615,7 +452,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
             };
 
             $('#mainInput').val('');
-            $rootScope.events.rooms[$scope.room_id].messages.push(echoMessage);
+            $scope.room.addMessageEvent(echoMessage);
             scrollToBottom();
         }
 
@@ -638,7 +475,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
                     }         
                 },
                 function(error) {
-                    $scope.feedback = "Request failed: " + error.data.error;
+                    $scope.feedback = error.data.error;
 
                     if (echoMessage) {
                         // Mark the message as unsent for the rest of the page life
@@ -661,7 +498,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         if (room_id_or_alias && '!' === room_id_or_alias[0]) {
             // Yes. We can go on right now
             $scope.room_id = room_id_or_alias;
-            $scope.room_alias = matrixService.getRoomIdToAliasMapping($scope.room_id);
+            $scope.room_alias = modelService.getRoomIdToAliasMapping($scope.room_id);
             onInit2();
         }
         else {
@@ -703,6 +540,9 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
     
     var onInit2 = function() {
         console.log("onInit2");
+        // =============================
+        $scope.room = modelService.getRoom($scope.room_id);
+        // =============================
         
         // Scroll down as soon as possible so that we point to the last message
         // if it already exists in memory
@@ -715,9 +555,9 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
                 var needsToJoin = true;
                 
                 // The room members is available in the data fetched by initialSync
-                if ($rootScope.events.rooms[$scope.room_id]) {
+                if ($scope.room) {
 
-                    var messages = $rootScope.events.rooms[$scope.room_id].messages;
+                    var messages = $scope.room.events;
 
                     if (0 === messages.length
                     || (1 === messages.length && "m.room.member" === messages[0].type && "invite" === messages[0].content.membership && $scope.state.user_id === messages[0].state_key)) {
@@ -729,19 +569,19 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
                         $scope.state.first_pagination = false;
                     }
 
-                    var members = $rootScope.events.rooms[$scope.room_id].members;
+                    var members = $scope.room.current_room_state.members;
 
                     // Update the member list
                     for (var i in members) {
                         if (!members.hasOwnProperty(i)) continue;
 
-                        var member = members[i];
+                        var member = members[i].event;
                         updateMemberList(member);
                     }
 
                     // Check if the user has already join the room
                     if ($scope.state.user_id in members) {
-                        if ("join" === members[$scope.state.user_id].membership) {
+                        if ("join" === members[$scope.state.user_id].event.content.membership) {
                             needsToJoin = false;
                         }
                     }
@@ -785,10 +625,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         console.log("onInit3");
 
         // Make recents highlight the current room
-        $scope.recentsSelectedRoomID = $scope.room_id;
-
-        // Init the history for this room
-        history.init();
+        recentsService.setSelectedRoomId($scope.room_id);
 
         // Get the up-to-date the current member list
         matrixService.getMemberList($scope.room_id).then(
@@ -822,19 +659,6 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
             }
         );
     }; 
-    
-    $scope.inviteUser = function() {
-        
-        matrixService.invite($scope.room_id, $scope.userIDToInvite).then(
-            function() {
-                console.log("Invited.");
-                $scope.feedback = "Invite successfully sent to " + $scope.userIDToInvite;
-                $scope.userIDToInvite = "";
-            },
-            function(reason) {
-                $scope.feedback = "Failure: " + reason.data.error;
-            });
-    };
 
     $scope.leaveRoom = function() {
         
@@ -886,109 +710,51 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         paginate(MESSAGES_PER_PAGINATION);
     };
 
-    $scope.startVoiceCall = function() {
+    $scope.checkWebRTC = function() {
+        if (!$rootScope.isWebRTCSupported()) {
+            alert("Your browser does not support WebRTC");
+            return false;
+        }
+        if ($scope.memberCount() != 2) {
+            alert("WebRTC calls are currently only supported on rooms with two members");
+            return false;
+        }
+        return true;
+    };
+    
+    $scope.startVoiceCall = function() {        
+        if (!$scope.checkWebRTC()) return;
         var call = new MatrixCall($scope.room_id);
         call.onError = $rootScope.onCallError;
         call.onHangup = $rootScope.onCallHangup;
         // remote video element is used for playing audio in voice calls
-        call.remoteVideoElement = angular.element('#remoteVideo')[0];
+        call.remoteVideoSelector = angular.element('#remoteVideo')[0];
         call.placeVoiceCall();
         $rootScope.currentCall = call;
     };
 
     $scope.startVideoCall = function() {
+        if (!$scope.checkWebRTC()) return;
+
         var call = new MatrixCall($scope.room_id);
         call.onError = $rootScope.onCallError;
         call.onHangup = $rootScope.onCallHangup;
-        call.localVideoElement = angular.element('#localVideo')[0];
-        call.remoteVideoElement = angular.element('#remoteVideo')[0];
+        call.localVideoSelector = '#localVideo';
+        call.remoteVideoSelector = '#remoteVideo';
         call.placeVideoCall();
         $rootScope.currentCall = call;
     };
 
-    // Manage history of typed messages
-    // History is saved in sessionStoratge so that it survives when the user
-    // navigates through the rooms and when it refreshes the page
-    var history = {
-        // The list of typed messages. Index 0 is the more recents
-        data: [],
-
-        // The position in the history currently displayed
-        position: -1,
-
-        // The message the user has started to type before going into the history
-        typingMessage: undefined,
-
-        // Init/load data for the current room
-        init: function() {
-            var data = sessionStorage.getItem("history_" + $scope.room_id);
-            if (data) {
-                this.data = JSON.parse(data);
-            }
-        },
-
-        // Store a message in the history
-        push: function(message) {
-            this.data.unshift(message);
-
-            // Update the session storage
-            sessionStorage.setItem("history_" + $scope.room_id, JSON.stringify(this.data));
-
-            // Reset history position
-            this.position = -1;
-            this.typingMessage = undefined;
-        },
-
-        // Move in the history
-        go: function(offset) {
-
-            if (-1 === this.position) {
-                // User starts to go to into the history, save the current line
-                this.typingMessage = $('#mainInput').val();
-            }
-            else {
-                // If the user modified this line in history, keep the change
-                this.data[this.position] = $('#mainInput').val();
-            }
-
-            // Bounds the new position to valid data
-            var newPosition = this.position + offset;
-            newPosition = Math.max(-1, newPosition);
-            newPosition = Math.min(newPosition, this.data.length - 1);
-            this.position = newPosition;
-
-            if (-1 !== this.position) {
-                // Show the message from the history
-                $('#mainInput').val(this.data[this.position]);
-            }
-            else if (undefined !== this.typingMessage) {
-                // Go back to the message the user started to type
-                $('#mainInput').val(this.typingMessage);
-            }
-        }
-    };
-
-    // Make history singleton methods available from HTML
-    $scope.history = {
-        goUp: function($event) {
-            if ($scope.room_id) {
-                history.go(1);
-            }
-            $event.preventDefault();
-        },
-        goDown: function($event) {
-            if ($scope.room_id) {
-                history.go(-1);
-            }
-            $event.preventDefault();
-        }
-    };
-
     $scope.openJson = function(content) {
-        $scope.event_selected = content;
+        $scope.event_selected = angular.copy(content);
+        
+        // FIXME: Pre-calculated event data should be stripped in a nicer way.
+        $scope.event_selected.__room_member = undefined;
+        $scope.event_selected.__target_room_member = undefined;
+        
         // scope this so the template can check power levels and enable/disable
         // buttons
-        $scope.pow = matrixService.getUserPowerLevel;
+        $scope.pow = eventHandlerService.getUserPowerLevel;
 
         var modalInstance = $modal.open({
             templateUrl: 'eventInfoTemplate.html',
@@ -1017,13 +783,70 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         });
     };
 
+    $scope.openRoomInfo = function() {
+        $scope.roomInfo = {};
+        $scope.roomInfo.newEvent = {
+            content: {},
+            type: "",
+            state_key: ""
+        };
+
+        var stateEvents = $scope.room.current_room_state.state_events;
+        // The modal dialog will 2-way bind this field, so we MUST make a deep
+        // copy of the state events else we will be *actually adjusing our view
+        // of the world* when fiddling with the JSON!! Apparently parse/stringify
+        // is faster than jQuery's extend when doing deep copies.
+        $scope.roomInfo.stateEvents = JSON.parse(JSON.stringify(stateEvents));
+        var modalInstance = $modal.open({
+            templateUrl: 'roomInfoTemplate.html',
+            controller: 'RoomInfoController',
+            size: 'lg',
+            scope: $scope
+        });
+    };
+
 }])
 .controller('EventInfoController', function($scope, $modalInstance) {
     console.log("Displaying modal dialog for >>>> " + JSON.stringify($scope.event_selected));
     $scope.redact = function() {
         console.log("User level = "+$scope.pow($scope.room_id, $scope.state.user_id)+
-                    " Redact level = "+$scope.events.rooms[$scope.room_id]["m.room.ops_levels"].content.redact_level);
+                    " Redact level = "+$scope.room.current_room_state.state_events["m.room.ops_levels"].content.redact_level);
         console.log("Redact event >> " + JSON.stringify($scope.event_selected));
         $modalInstance.close("redact");
     };
+    $scope.dismiss = $modalInstance.dismiss;
+})
+.controller('RoomInfoController', function($scope, $modalInstance, $filter, matrixService) {
+    console.log("Displaying room info.");
+    
+    $scope.userIDToInvite = "";
+    
+    $scope.inviteUser = function() {
+        
+        matrixService.invite($scope.room_id, $scope.userIDToInvite).then(
+            function() {
+                console.log("Invited.");
+                $scope.feedback = "Invite successfully sent to " + $scope.userIDToInvite;
+                $scope.userIDToInvite = "";
+            },
+            function(reason) {
+                $scope.feedback = "Failure: " + reason.data.error;
+            });
+    };
+
+    $scope.submit = function(event) {
+        if (event.content) {
+            console.log("submit >>> " + JSON.stringify(event.content));
+            matrixService.sendStateEvent($scope.room_id, event.type, 
+                event.content, event.state_key).then(function(response) {
+                    $modalInstance.dismiss();
+                }, function(err) {
+                    $scope.feedback = err.data.error;
+                }
+            );
+        }
+    };
+
+    $scope.dismiss = $modalInstance.dismiss;
+
 });
diff --git a/webclient/room/room-directive.js b/syweb/webclient/room/room-directive.js
index 05382cfcd3..187032aa88 100644
--- a/webclient/room/room-directive.js
+++ b/syweb/webclient/room/room-directive.js
@@ -144,19 +144,106 @@ angular.module('RoomController')
         });
     };
 }])
+// A directive which stores text sent into it and restores it via up/down arrows
 .directive('commandHistory', [ function() {
-    return function (scope, element, attrs) {
-        element.bind("keydown", function (event) {
-            var keycodePressed = event.which;
-            var UP_ARROW = 38;
-            var DOWN_ARROW = 40;
-            if (keycodePressed === UP_ARROW) {
-                scope.history.goUp(event);
+    var BROADCAST_NEW_HISTORY_ITEM = "commandHistory:BROADCAST_NEW_HISTORY_ITEM(item)";
+
+    // Manage history of typed messages
+    // History is saved in sessionStorage so that it survives when the user
+    // navigates through the rooms and when it refreshes the page
+    var history = {
+        // The list of typed messages. Index 0 is the more recents
+        data: [],
+
+        // The position in the history currently displayed
+        position: -1,
+        
+        element: undefined,
+        roomId: undefined,
+
+        // The message the user has started to type before going into the history
+        typingMessage: undefined,
+
+        // Init/load data for the current room
+        init: function(element, roomId) {
+            this.roomId = roomId;
+            this.element = element;
+            var data = sessionStorage.getItem("history_" + this.roomId);
+            if (data) {
+                this.data = JSON.parse(data);
             }
-            else if (keycodePressed === DOWN_ARROW) {
-                scope.history.goDown(event);
-            } 
-        });
+        },
+
+        // Store a message in the history
+        push: function(message) {
+            this.data.unshift(message);
+
+            // Update the session storage
+            sessionStorage.setItem("history_" + this.roomId, JSON.stringify(this.data));
+
+            // Reset history position
+            this.position = -1;
+            this.typingMessage = undefined;
+        },
+
+        // Move in the history
+        go: function(offset) {
+
+            if (-1 === this.position) {
+                // User starts to go to into the history, save the current line
+                this.typingMessage = this.element.val();
+            }
+            else {
+                // If the user modified this line in history, keep the change
+                this.data[this.position] = this.element.val();
+            }
+
+            // Bounds the new position to valid data
+            var newPosition = this.position + offset;
+            newPosition = Math.max(-1, newPosition);
+            newPosition = Math.min(newPosition, this.data.length - 1);
+            this.position = newPosition;
+
+            if (-1 !== this.position) {
+                // Show the message from the history
+                this.element.val(this.data[this.position]);
+            }
+            else if (undefined !== this.typingMessage) {
+                // Go back to the message the user started to type
+                this.element.val(this.typingMessage);
+            }
+        }
+    };
+
+    return {
+        restrict: "AE",
+        scope: {
+            roomId: "=commandHistory"
+        },
+        link: function (scope, element, attrs) {
+            element.bind("keydown", function (event) {
+                var keycodePressed = event.which;
+                var UP_ARROW = 38;
+                var DOWN_ARROW = 40;
+                if (scope.roomId) {
+                    if (keycodePressed === UP_ARROW) {
+                        history.go(1);
+                        event.preventDefault();
+                    }
+                    else if (keycodePressed === DOWN_ARROW) {
+                        history.go(-1);
+                        event.preventDefault();
+                    }
+                }
+            });
+            
+            scope.$on(BROADCAST_NEW_HISTORY_ITEM, function(ngEvent, item) {
+                history.push(item);
+            });
+            
+            history.init(element, scope.roomId);
+        },
+        
     }
 }])
 
diff --git a/syweb/webclient/room/room.html b/syweb/webclient/room/room.html
new file mode 100644
index 0000000000..17565f879b
--- /dev/null
+++ b/syweb/webclient/room/room.html
@@ -0,0 +1,266 @@
+<div ng-controller="RoomController" data-ng-init="onInit()" class="room" style="height: 100%;">
+
+    <script type="text/ng-template" id="eventInfoTemplate.html">
+        <div class="modal-body">
+            <pre> {{event_selected | json}} </pre>
+        </div>
+        <div class="modal-footer">
+            <button ng-click="redact()" type="button" class="btn btn-danger redact-button" 
+             ng-disabled="!room.current_room_state.state('m.room.ops_levels').content.redact_level || !pow(room_id, state.user_id) || pow(room_id, state.user_id) < room.current_room_state.state('m.room.ops_levels').content.redact_level"
+             title="Delete this event on all home servers. This cannot be undone.">
+                Redact
+            </button>
+
+            <button ng-click="dismiss()" type="button" class="btn">
+                Close
+            </button>
+        </div>
+    </script>
+
+    <script type="text/ng-template" id="roomInfoTemplate.html">
+        <div class="modal-body">
+            <span>
+               Invite a user: 
+                    <input ng-model="userIDToInvite" size="32" type="text" ng-enter="inviteUser()" ng-disabled="state.permission_denied" placeholder="User ID (ex:@user:homeserver)"/>     
+                    <button ng-click="inviteUser()" ng-disabled="state.permission_denied">Invite</button>
+            </span>
+            <br/>
+            <br/>
+            <button ng-click="leaveRoom()" ng-disabled="state.permission_denied">Leave Room</button>
+            </br/>
+            <table class="room-info">
+            <tr ng-repeat="(key, event) in roomInfo.stateEvents" class="room-info-event">
+                <td class="room-info-event-meta" width="30%">
+                    <span class="monospace">{{ event.type }}</span>
+                    <span ng-show="event.state_key" class="monospace"> ({{event.state_key}})</span>
+                    <br/>
+                    {{ (event.origin_server_ts) | date:'MMM d HH:mm' }}
+                    <br/>
+                    Set by: <span class="monospace">{{ event.user_id }}</span>
+                    <br/>
+                    <span ng-show="event.required_power_level >= 0">Required power level: {{event.required_power_level}}<br/></span>
+                    <button ng-click="submit(event)" type="button" class="btn btn-success" ng-disabled="!event.content">
+                        Submit
+                    </button>
+                </td>
+                <td class="room-info-event-content" width="70%">
+                    <textarea class="room-info-textarea-content" msd-elastic ng-model="event.content" asjson></textarea> 
+                </td>
+            </tr>
+            <tr>
+                <td class="room-info-event-meta" width="30%">
+                    <input ng-model="roomInfo.newEvent.type" placeholder="your.event.type" />
+                    <br/>
+                    <button ng-click="submit(roomInfo.newEvent)" type="button" class="btn btn-success" ng-disabled="!roomInfo.newEvent.content || !roomInfo.newEvent.type">
+                        Submit
+                    </button>
+                </td>
+                <td class="room-info-event-content" width="70%">
+                    <textarea class="room-info-textarea-content" msd-elastic ng-model="roomInfo.newEvent.content" asjson></textarea>
+                </td>
+            </tr>
+            </table>
+        </div>
+        <div class="modal-footer">
+            <button ng-click="dismiss()" type="button" class="btn">
+                Close
+            </button>
+        </div>
+    </script>
+
+    <div id="roomHeader">
+        <a href ng-click="goToPage('/')"><img src="img/logo-small.png" width="100" height="43" alt="[matrix]"/></a>
+
+        <div id="controlButtons">
+            <button ng-click="startVoiceCall()" class="controlButton"
+                    style="background: url('img/voice.png')"
+                    ng-show="(currentCall == undefined || currentCall.state == 'ended')"
+                    ng-disabled="state.permission_denied"
+                    >
+            </button>
+            <button ng-click="startVideoCall()" class="controlButton"
+                    style="background: url('img/video.png')"
+                    ng-show="(currentCall == undefined || currentCall.state == 'ended')"
+                    ng-disabled="state.permission_denied"
+                    >
+            </button>
+            <button ng-click="openRoomInfo()" class="controlButton"
+                    style="background: url('img/settings.png')"
+                    >
+            </button>
+        </div>
+
+        <div class="roomHeaderInfo">
+
+            <div class="roomNameSection">
+                <div ng-hide="name.isEditing" ng-dblclick="name.editName()" id="roomName">
+                    {{ room_id | mRoomName }}
+                </div>
+                <form ng-submit="name.updateName()" ng-show="name.isEditing" class="roomNameForm">
+                    <input ng-model="name.newNameText" ng-blur="name.cancelEdit()" class="roomNameInput" placeholder="Room name"/>
+                </form>
+            </div>
+
+            <div class="roomTopicSection">
+                <button ng-hide="room.current_room_state.state_events['m.room.topic'].content.topic || topic.isEditing"
+                    ng-click="topic.editTopic()" class="roomTopicSetNew">
+                    Set Topic
+                </button>
+                <div ng-show="room.current_room_state.state_events['m.room.topic'].content.topic || topic.isEditing">
+                    <div ng-hide="topic.isEditing" ng-dblclick="topic.editTopic()" id="roomTopic"
+                         ng-bind-html="room.current_room_state.state_events['m.room.topic'].content.topic | limitTo: 200 | linky:'_blank'">
+                    </div>
+                    <form ng-submit="topic.updateTopic()" ng-show="topic.isEditing" class="roomTopicForm">
+                        <input ng-model="topic.newTopicText" ng-blur="topic.cancelEdit()" class="roomTopicInput"  placeholder="Topic"/>
+                    </form>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div id="roomPage">
+    <div id="roomWrapper">
+        
+    <div id="roomRecentsTableWrapper">
+        <div ng-include="'recents/recents.html'"></div>
+    </div>
+    
+    <div id="usersTableWrapper" ng-hide="state.permission_denied">
+        <div ng-repeat="member in members | orderMembersList" class="userAvatar">
+            <div class="userAvatarFrame" ng-class="(member.presence === 'online' ? 'online' : (member.presence === 'unavailable' ? 'unavailable' : '')) + ' ' + (member.membership == 'invite' ? 'invited' : '')">
+                <img class="userAvatarImage mouse-pointer" 
+                     ng-click="$parent.goToUserPage(member.id)"
+                     ng-src="{{member.avatar_url || 'img/default-profile.png'}}" 
+                     alt="{{ member.displayname || member.id.substr(0, member.id.indexOf(':')) }}"
+                     title="{{ member.id }} - power: {{ member.powerLevel }}"
+                     width="80" height="80"/>
+                <!-- <div class="userPowerLevel" ng-style="{'width': member.powerLevelNorm +'%'}"></div> -->
+            </div>
+            <div class="userName">
+                <pie-chart ng-show="member.powerLevelNorm" data="[ (member.powerLevelNorm + 0), (100 - member.powerLevelNorm) ]"></pie-chart>
+                {{ member.id | mUserDisplayName:room_id:true }}
+                <span ng-show="member.last_active_ago" style="color: #aaa">({{ member.last_active_ago + (now - member.last_updated) | duration }})</span>
+            </div>
+        </div>
+    </div>
+    
+    <div id="messageTableWrapper" 
+         ng-hide="state.permission_denied" 
+         ng-style="{ 'visibility': state.messages_visibility }"
+         keep-scroll>
+        <table id="messageTable" infinite-scroll="paginateMore()">
+            <tr ng-repeat="msg in room.events"
+                ng-class="(room.events[$index - 1].user_id !== msg.user_id ? 'differentUser' : '') + (msg.user_id === state.user_id ? ' mine' : '')" scroll-item>
+                <td class="leftBlock" ng-mouseover="state.showTs = 1" ng-mouseout="state.showTs = 0">
+                    <div class="timestamp"
+                         ng-style="{ 'opacity': state.showTs ? 1.0 : 0.0 }"
+                         ng-class="msg.echo_msg_state">
+                        {{ (msg.origin_server_ts) | date:'MMM d HH:mm' }}
+                    </div>
+                    <div class="sender" ng-hide="room.events[$index - 1].user_id === msg.user_id || msg.user_id === state.user_id"> {{ msg.__room_member.cnt.displayname || msg.user_id | mUserDisplayName:room_id:true }}</div>
+                </td>
+                <td class="avatar">
+                    <!-- msg.__room_member.avatar_url is just backwards compat, and can be removed in the future. -->
+                    <img class="avatarImage" ng-src="{{ msg.__room_member.cnt.avatar_url || msg.__room_member.avatar_url || 'img/default-profile.png' }}" width="32" height="32" title="{{msg.user_id}}"
+                         ng-hide="room.events[$index - 1].user_id === msg.user_id || msg.user_id === state.user_id"/>
+                </td>
+                <td class="msg" ng-class="(!msg.content.membership && ('m.room.topic' !== msg.type && 'm.room.name' !== msg.type))? (msg.content.msgtype === 'm.emote' ? 'emote text' : 'text') : 'membership text'">
+                    <div class="bubble" ng-dblclick="openJson(msg)">
+                        <span ng-if="'join' === msg.content.membership && msg.changedKey === 'membership'">
+                            {{ msg.content.displayname || members[msg.state_key].displayname || msg.state_key }} joined
+                        </span>
+                        <span ng-if="'leave' === msg.content.membership && msg.changedKey === 'membership'">
+                            <span ng-if="msg.user_id === msg.state_key">
+                                <!-- FIXME: This seems like a synapse bug that the 'leave' content doesn't give the displayname... -->
+                                {{ msg.__room_member.cnt.displayname || members[msg.state_key].displayname || msg.state_key }} left
+                            </span>
+                            <span ng-if="msg.user_id !== msg.state_key && msg.prev_content">
+                                {{ msg.content.displayname || members[msg.user_id].displayname || msg.user_id }}
+                                {{ {"invite": "kicked", "join": "kicked", "ban": "unbanned"}[msg.prev_content.membership] }}
+                                {{ msg.__target_room_member.content.displayname || msg.state_key }}
+                                <span ng-if="'join' === msg.prev_content.membership && msg.content.reason">
+                                    : {{ msg.content.reason }}
+                                </span>
+                            </span>
+                        </span>
+                        <span ng-if="'invite' === msg.content.membership && msg.changedKey === 'membership' || 
+                                     'ban' === msg.content.membership && msg.changedKey === 'membership'">
+                            {{ msg.__room_member.cnt.displayname || msg.user_id }}
+                            {{ {"invite": "invited", "ban": "banned"}[msg.content.membership] }}
+                            {{ msg.__target_room_member.cnt.displayname || msg.state_key }}
+                            <span ng-if="msg.prev_content && 'ban' === msg.prev_content.membership && msg.content.reason">
+                                : {{ msg.content.reason }}
+                            </span>
+                        </span>                        
+                        <span ng-if="msg.changedKey === 'displayname'">
+                            {{ msg.user_id }} changed their display name from {{ msg.prev_content.displayname }} to {{ msg.content.displayname }}
+                        </span>
+                        
+                        <span ng-show='msg.content.msgtype === "m.emote"'
+                              ng-class="msg.echo_msg_state"
+                              ng-bind-html="'* ' + (members[msg.user_id].displayname || msg.user_id) + ' ' + msg.content.body | linky:'_blank'"
+                              />
+                        
+                        <span ng-show='msg.content.msgtype === "m.text"' 
+                              class="message"
+                              ng-class="containsBingWord(msg) && msg.user_id != state.user_id ? msg.echo_msg_state + ' messageBing' : msg.echo_msg_state"
+                              ng-bind-html="(msg.content.msgtype === 'm.text' && msg.type === 'm.room.message' && msg.content.format === 'org.matrix.custom.html') ? 
+                                                                                        (msg.content.formatted_body | unsanitizedLinky) :
+                                             (msg.content.msgtype === 'm.text' && msg.type === 'm.room.message') ? (msg.content.body | linky:'_blank') : '' "/>
+
+                        <span ng-show='msg.type === "m.call.invite" && msg.user_id == state.user_id'>Outgoing Call{{ isWebRTCSupported() ? '' : ' (But your browser does not support VoIP)' }}</span>
+                        <span ng-show='msg.type === "m.call.invite" && msg.user_id != state.user_id'>Incoming Call{{ isWebRTCSupported() ? '' : ' (But your browser does not support VoIP)' }}</span>
+
+                        <div ng-show='msg.content.msgtype === "m.image"'>
+                            <div ng-hide='msg.content.thumbnail_url' ng-style="msg.content.body.h && { 'height' : (msg.content.body.h < 320) ? msg.content.body.h : 320}">
+                                <img class="image" ng-src="{{ msg.content.url }}"/>
+                            </div>
+                            <div ng-show='msg.content.thumbnail_url' ng-style="{ 'height' : msg.content.thumbnail_info.h }">
+                                <img class="image mouse-pointer" ng-src="{{ msg.content.thumbnail_url }}"
+                                     ng-click="$parent.fullScreenImageURL = msg.content.url; $event.stopPropagation();"/>
+                            </div>
+                        </div>
+
+                        <span ng-if="'m.room.topic' === msg.type">
+                            {{ members[msg.user_id].displayname || msg.user_id }} changed the topic to: {{ msg.content.topic }}
+                        </span>
+
+                        <span ng-if="'m.room.name' === msg.type">
+                            {{ members[msg.user_id].displayname || msg.user_id }} changed the room name to: {{ msg.content.name }}
+                        </span>
+
+                    </div>
+                </td>
+                <td class="rightBlock">
+                    <img class="avatarImage" ng-src="{{ members[msg.user_id].avatar_url || 'img/default-profile.png' }}" width="32" height="32"
+                         ng-hide="room.events[$index - 1].user_id === msg.user_id || msg.user_id !== state.user_id"/>
+                </td>
+            </tr>
+        </table>
+    </div>
+        
+    <div ng-show="state.permission_denied">
+        {{ state.permission_denied }}
+    </div>
+    
+    </div>
+    </div>
+
+    <div id="controlPanel">
+        <div id="controls">
+            <button id="attachButton" m-file-input="imageFileToSend" class="extraControls" ng-disabled="state.permission_denied"></button>
+            <textarea id="mainInput" rows="1" ng-enter="send()"
+                      ng-disabled="state.permission_denied"
+                      ng-focus="true" autocomplete="off" tab-complete command-history="room_id"/>
+            {{ feedback }}
+            <div ng-show="state.stream_failure">
+                {{ state.stream_failure.data.error || "Connection failure" }}
+            </div>
+        </div>
+    </div>
+
+    <div id="room-fullscreen-image" ng-show="fullScreenImageURL" ng-click="fullScreenImageURL = undefined;">
+        <img ng-src="{{ fullScreenImageURL }}"/>
+    </div>
+
+ </div>
diff --git a/webclient/settings/settings-controller.js b/syweb/webclient/settings/settings-controller.js
index 9cdace704a..9cdace704a 100644
--- a/webclient/settings/settings-controller.js
+++ b/syweb/webclient/settings/settings-controller.js
diff --git a/webclient/settings/settings.html b/syweb/webclient/settings/settings.html
index 094c846f8b..094c846f8b 100644
--- a/webclient/settings/settings.html
+++ b/syweb/webclient/settings/settings.html
diff --git a/webclient/test/README b/syweb/webclient/test/README
index 1a7bc832c7..e7ed4eaa87 100644
--- a/webclient/test/README
+++ b/syweb/webclient/test/README
@@ -1,13 +1,31 @@
-Requires:
- - nodejs/npm
- - npm install karma
+Testing is done using Karma.
+
+
+UNIT TESTING
+============
+
+Requires the following:
+ - npm/nodejs
+ - phantomjs
+
+Requires the following node packages:
  - npm install jasmine
- - npm install protractor (e2e testing)
+ - npm install karma
+ - npm install karma-jasmine
+ - npm install karma-phantomjs-launcher
+ - npm install karma-junit-reporter
 
-Setting up continuous integration / run the unit tests (make sure you're in
-this directory so it can find the config file):
+Make sure you're in this directory so it can find the config file and run:
   karma start
 
+You should see all the tests pass.
+
+
+E2E TESTING
+===========
+
+npm install protractor
+
 
 Setting up e2e tests (only if you don't have a selenium server to run the tests
 on. If you do, edit the config to point to that url):
diff --git a/webclient/test/e2e/home.spec.js b/syweb/webclient/test/e2e/home.spec.js
index 470237d557..470237d557 100644
--- a/webclient/test/e2e/home.spec.js
+++ b/syweb/webclient/test/e2e/home.spec.js
diff --git a/webclient/test/karma.conf.js b/syweb/webclient/test/karma.conf.js
index 22c4eaaafa..37a9eaf1c1 100644
--- a/webclient/test/karma.conf.js
+++ b/syweb/webclient/test/karma.conf.js
@@ -22,19 +22,27 @@ module.exports = function(config) {
       '../js/angular-route.js',
       '../js/angular-animate.js',
       '../js/angular-sanitize.js',
+      '../js/jquery.peity.min.js',
+      '../js/angular-peity.js',
       '../js/ng-infinite-scroll-matrix.js',
-      '../login/**/*.*',
-      '../room/**/*.*',
-      '../components/**/*.*',
-      '../user/**/*.*',
-      '../home/**/*.*',
-      '../recents/**/*.*',
-      '../settings/**/*.*',
+      '../js/ui-bootstrap*',
+      '../js/elastic.js',  
+      '../login/**/*.js',
+      '../room/**/*.js',
+      '../components/**/*.js',
+      '../user/**/*.js',
+      '../home/**/*.js',
+      '../recents/**/*.js',
+      '../settings/**/*.js',
       '../app.js',
       '../app*',
       './unit/**/*.js'
     ],
 
+    plugins: [
+        'karma-*',
+    ],
+
 
     // list of files to exclude
     exclude: [
@@ -44,14 +52,31 @@ module.exports = function(config) {
     // preprocess matching files before serving them to the browser
     // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
     preprocessors: {
+      '../login/**/*.js': 'coverage', 
+      '../room/**/*.js': 'coverage',
+      '../components/**/*.js': 'coverage',
+      '../user/**/*.js': 'coverage',
+      '../home/**/*.js': 'coverage',
+      '../recents/**/*.js': 'coverage',
+      '../settings/**/*.js': 'coverage',
+      '../app.js': 'coverage'
     },
 
 
     // test results reporter to use
     // possible values: 'dots', 'progress'
     // available reporters: https://npmjs.org/browse/keyword/karma-reporter
-    reporters: ['progress'],
+    reporters: ['progress', 'junit', 'coverage'],
+    junitReporter: {
+        outputFile: 'test-results.xml',
+        suite: ''
+    },
 
+    coverageReporter: {
+        type: 'cobertura',
+        dir: 'coverage/',
+        file: 'coverage.xml'
+    },
 
     // web server port
     port: 9876,
@@ -72,11 +97,11 @@ module.exports = function(config) {
 
     // start these browsers
     // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
-    browsers: ['Chrome'],
+    browsers: ['PhantomJS'],
 
 
     // Continuous Integration mode
     // if true, Karma captures browsers, runs the tests and exits
-    singleRun: false
+    singleRun: true
   });
 };
diff --git a/webclient/test/protractor.conf.js b/syweb/webclient/test/protractor.conf.js
index 76ae7b712b..76ae7b712b 100644
--- a/webclient/test/protractor.conf.js
+++ b/syweb/webclient/test/protractor.conf.js
diff --git a/syweb/webclient/test/unit/commands-service.spec.js b/syweb/webclient/test/unit/commands-service.spec.js
new file mode 100644
index 0000000000..142044f153
--- /dev/null
+++ b/syweb/webclient/test/unit/commands-service.spec.js
@@ -0,0 +1,143 @@
+describe('CommandsService', function() {
+    var scope;
+    var roomId = "!dlwifhweu:localhost";
+    
+    var testPowerLevelsEvent, testMatrixServicePromise;
+    
+    var matrixService = { // these will be spyed on by jasmine, hence stub methods
+        setDisplayName: function(args){},
+        kick: function(args){},
+        ban: function(args){},
+        unban: function(args){},
+        setUserPowerLevel: function(args){}
+    };
+    
+    var modelService = {
+        getRoom: function(roomId) {
+            return {
+                room_id: roomId,
+                current_room_state: {
+                    events: {
+                        "m.room.power_levels": testPowerLevelsEvent
+                    },
+                    state: function(type, key) {
+                        return key ? this.events[type+key] : this.events[type];
+                    }
+                }
+            };
+        }
+    };
+    
+    
+    // helper function for asserting promise outcomes
+    NOTHING = "[Promise]";
+    RESOLVED = "[Resolved promise]";
+    REJECTED = "[Rejected promise]";
+    var expectPromise = function(promise, expects) {
+        var value = NOTHING;
+        promise.then(function(result) {
+            value = RESOLVED;
+        }, function(fail) {
+            value = REJECTED;
+        });
+        scope.$apply();
+        expect(value).toEqual(expects);
+    };
+
+    // setup the service and mocked dependencies
+    beforeEach(function() {
+        
+        // set default mock values
+        testPowerLevelsEvent = {
+            content: {
+                default: 50
+            },
+            user_id: "@foo:bar",
+            room_id: roomId
+        }
+        
+        // mocked dependencies
+        module(function ($provide) {
+          $provide.value('matrixService', matrixService);
+          $provide.value('modelService', modelService);
+        });
+        
+        // tested service
+        module('commandsService');
+    });
+    
+    beforeEach(inject(function($rootScope, $q) {
+        scope = $rootScope;
+        testMatrixServicePromise = $q.defer();
+    }));
+
+    it('should reject a no-arg "/nick".', inject(
+    function(commandsService) {
+        var promise = commandsService.processInput(roomId, "/nick");
+        expectPromise(promise, REJECTED);
+    }));
+    
+    it('should be able to set a /nick with multiple words.', inject(
+    function(commandsService) {
+        spyOn(matrixService, 'setDisplayName').and.returnValue(testMatrixServicePromise);
+        var promise = commandsService.processInput(roomId, "/nick Bob Smith");
+        expect(matrixService.setDisplayName).toHaveBeenCalledWith("Bob Smith");
+        expect(promise).toBe(testMatrixServicePromise);
+    }));
+    
+    it('should be able to /kick a user without a reason.', inject(
+    function(commandsService) {
+        spyOn(matrixService, 'kick').and.returnValue(testMatrixServicePromise);
+        var promise = commandsService.processInput(roomId, "/kick @bob:matrix.org");
+        expect(matrixService.kick).toHaveBeenCalledWith(roomId, "@bob:matrix.org", undefined);
+        expect(promise).toBe(testMatrixServicePromise);
+    }));
+    
+    it('should be able to /kick a user with a reason.', inject(
+    function(commandsService) {
+        spyOn(matrixService, 'kick').and.returnValue(testMatrixServicePromise);
+        var promise = commandsService.processInput(roomId, "/kick @bob:matrix.org he smells");
+        expect(matrixService.kick).toHaveBeenCalledWith(roomId, "@bob:matrix.org", "he smells");
+        expect(promise).toBe(testMatrixServicePromise);
+    }));
+    
+    it('should be able to /ban a user without a reason.', inject(
+    function(commandsService) {
+        spyOn(matrixService, 'ban').and.returnValue(testMatrixServicePromise);
+        var promise = commandsService.processInput(roomId, "/ban @bob:matrix.org");
+        expect(matrixService.ban).toHaveBeenCalledWith(roomId, "@bob:matrix.org", undefined);
+        expect(promise).toBe(testMatrixServicePromise);
+    }));
+    
+    it('should be able to /ban a user with a reason.', inject(
+    function(commandsService) {
+        spyOn(matrixService, 'ban').and.returnValue(testMatrixServicePromise);
+        var promise = commandsService.processInput(roomId, "/ban @bob:matrix.org he smells");
+        expect(matrixService.ban).toHaveBeenCalledWith(roomId, "@bob:matrix.org", "he smells");
+        expect(promise).toBe(testMatrixServicePromise);
+    }));
+    
+    it('should be able to /unban a user.', inject(
+    function(commandsService) {
+        spyOn(matrixService, 'unban').and.returnValue(testMatrixServicePromise);
+        var promise = commandsService.processInput(roomId, "/unban @bob:matrix.org");
+        expect(matrixService.unban).toHaveBeenCalledWith(roomId, "@bob:matrix.org");
+        expect(promise).toBe(testMatrixServicePromise);
+    }));
+    
+    it('should be able to /op a user.', inject(
+    function(commandsService) {
+        spyOn(matrixService, 'setUserPowerLevel').and.returnValue(testMatrixServicePromise);
+        var promise = commandsService.processInput(roomId, "/op @bob:matrix.org 50");
+        expect(matrixService.setUserPowerLevel).toHaveBeenCalledWith(roomId, "@bob:matrix.org", 50, testPowerLevelsEvent);
+        expect(promise).toBe(testMatrixServicePromise);
+    }));
+    
+    it('should be able to /deop a user.', inject(
+    function(commandsService) {
+        spyOn(matrixService, 'setUserPowerLevel').and.returnValue(testMatrixServicePromise);
+        var promise = commandsService.processInput(roomId, "/deop @bob:matrix.org");
+        expect(matrixService.setUserPowerLevel).toHaveBeenCalledWith(roomId, "@bob:matrix.org", undefined, testPowerLevelsEvent);
+        expect(promise).toBe(testMatrixServicePromise);
+    }));
+});
diff --git a/syweb/webclient/test/unit/event-handler-service.spec.js b/syweb/webclient/test/unit/event-handler-service.spec.js
new file mode 100644
index 0000000000..2a4dc3b5a5
--- /dev/null
+++ b/syweb/webclient/test/unit/event-handler-service.spec.js
@@ -0,0 +1,117 @@
+describe('EventHandlerService', function() {
+    var scope;
+    
+    var modelService = {};
+
+    // setup the service and mocked dependencies
+    beforeEach(function() {
+        // dependencies
+        module('matrixService');
+        module('notificationService');
+        module('mPresence');
+        
+        // cleanup mocked methods
+        modelService = {};
+        
+        // mocked dependencies
+        module(function ($provide) {
+          $provide.value('modelService', modelService);
+        });
+        
+        // tested service
+        module('eventHandlerService');
+    });
+    
+    beforeEach(inject(function($rootScope) {
+        scope = $rootScope;
+    }));
+
+    it('should be able to get the number of joined users in a room', inject(
+    function(eventHandlerService) {
+        var roomId = "!foo:matrix.org";
+        // set mocked data
+        modelService.getRoom = function(roomId) {
+            return {
+                room_id: roomId,
+                current_room_state: {
+                    members: {
+                        "@adam:matrix.org": {
+                            event: {
+                                content: { membership: "join" },
+                                user_id: "@adam:matrix.org"
+                            }
+                        },
+                        "@beth:matrix.org": {
+                            event: {
+                                content: { membership: "invite" },
+                                user_id: "@beth:matrix.org"
+                            }
+                        },
+                        "@charlie:matrix.org": {
+                            event: {
+                                content: { membership: "join" },
+                                user_id: "@charlie:matrix.org"
+                            }
+                        },
+                        "@danice:matrix.org": {
+                            event: {
+                                content: { membership: "leave" },
+                                user_id: "@danice:matrix.org"
+                            }
+                        }
+                    }
+                }
+            };
+        }
+        
+        var num = eventHandlerService.getUsersCountInRoom(roomId);
+        expect(num).toEqual(2);
+    }));
+    
+    it('should be able to get a users power level', inject(
+    function(eventHandlerService) {
+        var roomId = "!foo:matrix.org";
+        // set mocked data
+        modelService.getRoom = function(roomId) {
+            return {
+                room_id: roomId,
+                current_room_state: {
+                    members: {
+                        "@adam:matrix.org": {
+                            event: {
+                                content: { membership: "join" },
+                                user_id: "@adam:matrix.org"
+                            }
+                        },
+                        "@beth:matrix.org": {
+                            event: {
+                                content: { membership: "join" },
+                                user_id: "@beth:matrix.org"
+                            }
+                        }
+                    },
+                    s: {
+                        "m.room.power_levels": {
+                            content: {
+                                "@adam:matrix.org": 90,
+                                "default": 50
+                            }
+                        }
+                    },
+                    state: function(type, key) { 
+                        return key ? this.s[type+key] : this.s[type]
+                    }
+                }
+            };
+        };
+        
+        var num = eventHandlerService.getUserPowerLevel(roomId, "@beth:matrix.org");
+        expect(num).toEqual(50);
+        
+        num = eventHandlerService.getUserPowerLevel(roomId, "@adam:matrix.org");
+        expect(num).toEqual(90);
+        
+        num = eventHandlerService.getUserPowerLevel(roomId, "@unknown:matrix.org");
+        expect(num).toEqual(50);
+    }));
+});
diff --git a/syweb/webclient/test/unit/filters.spec.js b/syweb/webclient/test/unit/filters.spec.js
new file mode 100644
index 0000000000..c6253aad96
--- /dev/null
+++ b/syweb/webclient/test/unit/filters.spec.js
@@ -0,0 +1,635 @@
+describe('mRoomName filter', function() {
+    var filter, mRoomName, mUserDisplayName;
+    
+    var roomId = "!weufhewifu:matrix.org";
+    
+    // test state values (f.e. test)
+    var testUserId, testAlias, testDisplayName, testOtherDisplayName, testRoomState;
+    
+    // mocked services which return the test values above.
+    var matrixService = {
+        config: function() {
+            return {
+                user_id: testUserId
+            };
+        }
+    };
+    
+    var modelService = {
+        getRoom: function(room_id) {
+            return {
+                current_room_state: testRoomState
+            };
+        },
+        
+        getRoomIdToAliasMapping: function(room_id) {
+            return testAlias;
+        },
+    };
+    
+    beforeEach(function() {
+        // inject mocked dependencies
+        module(function ($provide) {
+            $provide.value('matrixService', matrixService);
+            $provide.value('modelService', modelService);
+        });
+        
+        module('matrixFilter');
+        
+        // angular resolves dependencies with the same name via a 'last wins'
+        // rule, hence we need to have this mock filter impl AFTER module('matrixFilter')
+        // so it clobbers the actual mUserDisplayName implementation.
+        module(function ($filterProvider) {
+            // provide a fake filter
+            $filterProvider.register('mUserDisplayName', function() {
+                return function(user_id, room_id) {
+                    if (user_id === testUserId) {
+                        return testDisplayName;
+                    }
+                    return testOtherDisplayName;
+                };
+            });
+        });
+    });
+    
+    
+    beforeEach(inject(function($filter) {
+        filter = $filter;
+        mRoomName = filter("mRoomName");
+        
+        // purge the previous test values
+        testUserId = undefined;
+        testAlias = undefined;
+        testDisplayName = undefined;
+        testOtherDisplayName = undefined;
+        
+        // mock up a stub room state
+        testRoomState = {
+            s:{}, // internal; stores the state events
+            state: function(type, key) {
+                // accessor used by filter
+                return key ? this.s[type+key] : this.s[type];
+            },
+            members: {}, // struct used by filter
+            
+            // test helper methods
+            setJoinRule: function(rule) {
+                this.s["m.room.join_rules"] = {
+                    content: {
+                        join_rule: rule
+                    }
+                };
+            },
+            setRoomName: function(name) {
+                this.s["m.room.name"] = {
+                    content: {
+                        name: name
+                    }
+                };
+            },
+            setMember: function(user_id, membership, inviter_user_id) {
+                if (!inviter_user_id) {
+                    inviter_user_id = user_id;
+                }
+                this.s["m.room.member" + user_id] = {
+                    event: {
+                        content: {
+                            membership: membership
+                        },
+                        state_key: user_id,
+                        user_id: inviter_user_id 
+                    }
+                };
+                this.members[user_id] = this.s["m.room.member" + user_id];
+            }
+        };
+    }));
+    
+    /**** ROOM NAME ****/
+    
+    it("should show the room name if one exists for private (invite join_rules) rooms.", function() {
+        var roomName = "The Room Name";
+        testUserId = "@me:matrix.org";
+        testRoomState.setJoinRule("invite");
+        testRoomState.setRoomName(roomName);
+        testRoomState.setMember(testUserId, "join");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(roomName);
+    });
+    
+    it("should show the room name if one exists for public (public join_rules) rooms.", function() {
+        var roomName = "The Room Name";
+        testUserId = "@me:matrix.org";
+        testRoomState.setJoinRule("public");
+        testRoomState.setRoomName(roomName);
+        testRoomState.setMember(testUserId, "join");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(roomName);
+    });
+    
+    /**** ROOM ALIAS ****/
+    
+    it("should show the room alias if one exists for private (invite join_rules) rooms if a room name doesn't exist.", function() {
+        testAlias = "#thealias:matrix.org";
+        testUserId = "@me:matrix.org";
+        testRoomState.setJoinRule("invite");
+        testRoomState.setMember(testUserId, "join");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(testAlias);
+    });
+    
+    it("should show the room alias if one exists for public (public join_rules) rooms if a room name doesn't exist.", function() {
+        testAlias = "#thealias:matrix.org";
+        testUserId = "@me:matrix.org";
+        testRoomState.setJoinRule("public");
+        testRoomState.setMember(testUserId, "join");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(testAlias);
+    });
+    
+    /**** ROOM ID ****/
+    
+    it("should show the room ID for public (public join_rules) rooms if a room name and alias don't exist.", function() {
+        testUserId = "@me:matrix.org";
+        testRoomState.setJoinRule("public");
+        testRoomState.setMember(testUserId, "join");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(roomId);
+    });
+    
+    it("should show the room ID for private (invite join_rules) rooms if a room name and alias don't exist and there are >2 members.", function() {
+        testUserId = "@me:matrix.org";
+        testRoomState.setJoinRule("public");
+        testRoomState.setMember(testUserId, "join");
+        testRoomState.setMember("@alice:matrix.org", "join");
+        testRoomState.setMember("@bob:matrix.org", "join");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(roomId);
+    });
+    
+    /**** SELF-CHAT ****/
+    
+    it("should show your display name for private (invite join_rules) rooms if a room name and alias don't exist and it is a self-chat.", function() {
+        testUserId = "@me:matrix.org";
+        testDisplayName = "Me";
+        testRoomState.setJoinRule("private");
+        testRoomState.setMember(testUserId, "join");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(testDisplayName);
+    });
+    
+    it("should show your user ID for private (invite join_rules) rooms if a room name and alias don't exist and it is a self-chat and they don't have a display name set.", function() {
+        testUserId = "@me:matrix.org";
+        testRoomState.setJoinRule("private");
+        testRoomState.setMember(testUserId, "join");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(testUserId);
+    });
+    
+    /**** ONE-TO-ONE CHAT ****/
+    
+    it("should show the other user's display name for private (invite join_rules) rooms if a room name and alias don't exist and it is a 1:1-chat.", function() {
+        testUserId = "@me:matrix.org";
+        otherUserId = "@alice:matrix.org";
+        testOtherDisplayName = "Alice";
+        testRoomState.setJoinRule("private");
+        testRoomState.setMember(testUserId, "join");
+        testRoomState.setMember("@alice:matrix.org", "join");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(testOtherDisplayName);
+    });
+    
+    it("should show the other user's ID for private (invite join_rules) rooms if a room name and alias don't exist and it is a 1:1-chat and they don't have a display name set.", function() {
+        testUserId = "@me:matrix.org";
+        otherUserId = "@alice:matrix.org";
+        testRoomState.setJoinRule("private");
+        testRoomState.setMember(testUserId, "join");
+        testRoomState.setMember("@alice:matrix.org", "join");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(otherUserId);
+    });
+    
+    /**** INVITED TO ROOM ****/
+    
+    it("should show the other user's display name for private (invite join_rules) rooms if you are invited to it.", function() {
+        testUserId = "@me:matrix.org";
+        testDisplayName = "Me";
+        otherUserId = "@alice:matrix.org";
+        testOtherDisplayName = "Alice";
+        testRoomState.setJoinRule("private");
+        testRoomState.setMember(testUserId, "join");
+        testRoomState.setMember(otherUserId, "join");
+        testRoomState.setMember(testUserId, "invite");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(testOtherDisplayName);
+    });
+    
+    it("should show the other user's ID for private (invite join_rules) rooms if you are invited to it and the inviter doesn't have a display name.", function() {
+        testUserId = "@me:matrix.org";
+        testDisplayName = "Me";
+        otherUserId = "@alice:matrix.org";
+        testRoomState.setJoinRule("private");
+        testRoomState.setMember(testUserId, "join");
+        testRoomState.setMember(otherUserId, "join");
+        testRoomState.setMember(testUserId, "invite");
+        var output = mRoomName(roomId);
+        expect(output).toEqual(otherUserId);
+    });
+});
+
+describe('duration filter', function() {
+    var filter, durationFilter;
+    
+    beforeEach(module('matrixWebClient'));
+    beforeEach(inject(function($filter) {
+        filter = $filter;
+        durationFilter = filter("duration");
+    }));
+    
+    it("should represent 15000 ms as '15s'", function() {
+        var output = durationFilter(15000);
+        expect(output).toEqual("15s");
+    });
+    
+    it("should represent 60000 ms as '1m'", function() {
+        var output = durationFilter(60000);
+        expect(output).toEqual("1m");
+    });
+    
+    it("should represent 65000 ms as '1m'", function() {
+        var output = durationFilter(65000);
+        expect(output).toEqual("1m");
+    });
+    
+    it("should represent 10 ms as '0s'", function() {
+        var output = durationFilter(10);
+        expect(output).toEqual("0s");
+    });
+    
+    it("should represent 4m as '4m'", function() {
+        var output = durationFilter(1000*60*4);
+        expect(output).toEqual("4m");
+    });
+    
+    it("should represent 4m30s as '4m'", function() {
+        var output = durationFilter(1000*60*4 + 1000*30);
+        expect(output).toEqual("4m");
+    });
+    
+    it("should represent 2h as '2h'", function() {
+        var output = durationFilter(1000*60*60*2);
+        expect(output).toEqual("2h");
+    });
+    
+    it("should represent 2h35m as '2h'", function() {
+        var output = durationFilter(1000*60*60*2 + 1000*60*35);
+        expect(output).toEqual("2h");
+    });
+});
+
+describe('orderMembersList filter', function() {
+    var filter, orderMembersList;
+    
+    beforeEach(module('matrixWebClient'));
+    beforeEach(inject(function($filter) {
+        filter = $filter;
+        orderMembersList = filter("orderMembersList");
+    }));
+    
+    it("should sort a single entry", function() {
+        var output = orderMembersList({
+            "@a:example.com": {
+                last_active_ago: 50,
+                last_updated: 1415266943964
+            }
+        });
+        expect(output).toEqual([{
+                id: "@a:example.com",
+                last_active_ago: 50,
+                last_updated: 1415266943964
+        }]);
+    });
+    
+    it("should sort by taking last_active_ago into account", function() {
+        var output = orderMembersList({
+            "@a:example.com": {
+                last_active_ago: 1000,
+                last_updated: 1415266943964
+            },
+            "@b:example.com": {
+                last_active_ago: 50,
+                last_updated: 1415266943964
+            },
+            "@c:example.com": {
+                last_active_ago: 99999,
+                last_updated: 1415266943964
+            }
+        });
+        expect(output).toEqual([
+            {
+                id: "@b:example.com",
+                last_active_ago: 50,
+                last_updated: 1415266943964
+            },
+            {
+                id: "@a:example.com",
+                last_active_ago: 1000,
+                last_updated: 1415266943964
+            },
+            {
+                id: "@c:example.com",
+                last_active_ago: 99999,
+                last_updated: 1415266943964
+            },
+        ]);
+    });
+    
+    it("should sort by taking last_updated into account", function() {
+        var output = orderMembersList({
+            "@a:example.com": {
+                last_active_ago: 1000,
+                last_updated: 1415266943964
+            },
+            "@b:example.com": {
+                last_active_ago: 1000,
+                last_updated: 1415266900000
+            },
+            "@c:example.com": {
+                last_active_ago: 1000,
+                last_updated: 1415266943000
+            }
+        });
+        expect(output).toEqual([
+            {
+                id: "@a:example.com",
+                last_active_ago: 1000,
+                last_updated: 1415266943964
+            },
+            {
+                id: "@c:example.com",
+                last_active_ago: 1000,
+                last_updated: 1415266943000
+            },
+            {
+                id: "@b:example.com",
+                last_active_ago: 1000,
+                last_updated: 1415266900000
+            },
+        ]);
+    });
+    
+    it("should sort by taking last_updated and last_active_ago into account", 
+    function() {
+        var output = orderMembersList({
+            "@a:example.com": {
+                last_active_ago: 1000,
+                last_updated: 1415266943000
+            },
+            "@b:example.com": {
+                last_active_ago: 100000,
+                last_updated: 1415266943900
+            },
+            "@c:example.com": {
+                last_active_ago: 1000,
+                last_updated: 1415266943964
+            }
+        });
+        expect(output).toEqual([
+            {
+                id: "@c:example.com",
+                last_active_ago: 1000,
+                last_updated: 1415266943964
+            },
+            {
+                id: "@a:example.com",
+                last_active_ago: 1000,
+                last_updated: 1415266943000
+            },
+            {
+                id: "@b:example.com",
+                last_active_ago: 100000,
+                last_updated: 1415266943900
+            },
+        ]);
+    });
+    
+    // SYWEB-26 comment
+    it("should sort members who do not have last_active_ago value at the end of the list", 
+    function() {
+        // single undefined entry
+        var output = orderMembersList({
+            "@a:example.com": {
+                last_active_ago: 1000,
+                last_updated: 1415266943964
+            },
+            "@b:example.com": {
+                last_active_ago: 100000,
+                last_updated: 1415266943964
+            },
+            "@c:example.com": {
+                last_active_ago: undefined,
+                last_updated: 1415266943964
+            }
+        });
+        expect(output).toEqual([
+            {
+                id: "@a:example.com",
+                last_active_ago: 1000,
+                last_updated: 1415266943964
+            },
+            {
+                id: "@b:example.com",
+                last_active_ago: 100000,
+                last_updated: 1415266943964
+            },
+            {
+                id: "@c:example.com",
+                last_active_ago: undefined,
+                last_updated: 1415266943964
+            },
+        ]);
+    });
+    
+    it("should sort multiple members who do not have last_active_ago according to presence", 
+    function() {
+        // single undefined entry
+        var output = orderMembersList({
+            "@a:example.com": {
+                last_active_ago: undefined,
+                last_updated: 1415266943964,
+                presence: "unavailable"
+            },
+            "@b:example.com": {
+                last_active_ago: undefined,
+                last_updated: 1415266943964,
+                presence: "online"
+            },
+            "@c:example.com": {
+                last_active_ago: undefined,
+                last_updated: 1415266943964,
+                presence: "offline"
+            }
+        });
+        expect(output).toEqual([
+            {
+                id: "@b:example.com",
+                last_active_ago: undefined,
+                last_updated: 1415266943964,
+                presence: "online"
+            },
+            {
+                id: "@a:example.com",
+                last_active_ago: undefined,
+                last_updated: 1415266943964,
+                presence: "unavailable"
+            },
+            {
+                id: "@c:example.com",
+                last_active_ago: undefined,
+                last_updated: 1415266943964,
+                presence: "offline"
+            },
+        ]);
+    });
+});
+describe('mUserDisplayName filter', function() {
+    var filter, mUserDisplayName;
+    
+    var roomId = "!weufhewifu:matrix.org";
+    
+    // test state values (f.e. test)
+    var testUser_displayname, testUser_user_id;
+    var testSelf_displayname, testSelf_user_id;
+    var testRoomState;
+    
+    // mocked services which return the test values above.
+    var matrixService = {
+        config: function() {
+            return {
+                user_id: testSelf_user_id
+            };
+        }
+    };
+    
+    var modelService = {
+        getRoom: function(room_id) {
+            return {
+                current_room_state: testRoomState
+            };
+        },
+        
+        getUser: function(user_id) {
+            return {
+                event: {
+                    content: {
+                        displayname: testUser_displayname
+                    },
+                    event_id: "wfiuhwf@matrix.org",
+                    user_id: testUser_user_id
+                }
+            };
+        },
+        
+        getMember: function(room_id, user_id) {
+            return testRoomState.members[user_id];
+        }
+    };
+    
+    beforeEach(function() {
+        // inject mocked dependencies
+        module(function ($provide) {
+            $provide.value('matrixService', matrixService);
+            $provide.value('modelService', modelService);
+        });
+        
+        module('matrixFilter');
+    });
+    
+    
+    beforeEach(inject(function($filter) {
+        filter = $filter;
+        mUserDisplayName = filter("mUserDisplayName");
+        
+        // purge the previous test values
+        testSelf_displayname = "Me"; 
+        testSelf_user_id = "@me:matrix.org";
+        testUser_displayname = undefined; 
+        testUser_user_id = undefined;
+        
+        // mock up a stub room state
+        testRoomState = {
+            s:{}, // internal; stores the state events
+            state: function(type, key) {
+                // accessor used by filter
+                return key ? this.s[type+key] : this.s[type];
+            },
+            members: {}, // struct used by filter
+            
+            // test helper methods
+            setMember: function(user_id, displayname, membership, inviter_user_id) {
+                if (!inviter_user_id) {
+                    inviter_user_id = user_id;
+                }
+                if (!membership) {
+                    membership = "join";
+                }
+                this.s["m.room.member" + user_id] = {
+                    event: {
+                        content: {
+                            displayname: displayname,
+                            membership: membership
+                        },
+                        state_key: user_id,
+                        user_id: inviter_user_id 
+                    }
+                };
+                this.members[user_id] = this.s["m.room.member" + user_id];
+            }
+        };
+    }));
+    
+    it("should show the display name of a user in a room if they have set one.", function() {
+        testUser_displayname = "Tom Scott";
+        testUser_user_id = "@tymnhk:matrix.org";
+        testRoomState.setMember(testUser_user_id, testUser_displayname);
+        testRoomState.setMember(testSelf_user_id, testSelf_displayname);
+        var output = mUserDisplayName(testUser_user_id, roomId);
+        expect(output).toEqual(testUser_displayname);
+    });
+    
+    it("should show the user_id of a user in a room if they have no display name.", function() {
+        testUser_user_id = "@mike:matrix.org";
+        testRoomState.setMember(testUser_user_id, testUser_displayname);
+        testRoomState.setMember(testSelf_user_id, testSelf_displayname);
+        var output = mUserDisplayName(testUser_user_id, roomId);
+        expect(output).toEqual(testUser_user_id);
+    });
+    
+    it("should still show the displayname of a user in a room if they are not a member of the room but there exists a User entry for them.", function() {
+        testUser_user_id = "@alice:matrix.org";
+        testUser_displayname = "Alice M";
+        testRoomState.setMember(testSelf_user_id, testSelf_displayname);
+        var output = mUserDisplayName(testUser_user_id, roomId);
+        expect(output).toEqual(testUser_displayname);
+    });
+    
+    it("should disambiguate users with the same displayname with their user id.", function() {
+        testUser_displayname = "Reimu";
+        testSelf_displayname = "Reimu";
+        testUser_user_id = "@reimu:matrix.org";
+        testSelf_user_id = "@xreimux:matrix.org";
+        testRoomState.setMember(testUser_user_id, testUser_displayname);
+        testRoomState.setMember(testSelf_user_id, testSelf_displayname);
+        var output = mUserDisplayName(testUser_user_id, roomId);
+        expect(output).toEqual(testUser_displayname + " (" + testUser_user_id + ")");
+    });
+    
+    it("should wrap user IDs after the : if the wrap flag is set.", function() {
+        testUser_user_id = "@mike:matrix.org";
+        testRoomState.setMember(testUser_user_id, testUser_displayname);
+        testRoomState.setMember(testSelf_user_id, testSelf_displayname);
+        var output = mUserDisplayName(testUser_user_id, roomId, true);
+        expect(output).toEqual("@mike :matrix.org");
+    });
+});
+
diff --git a/syweb/webclient/test/unit/matrix-service.spec.js b/syweb/webclient/test/unit/matrix-service.spec.js
new file mode 100644
index 0000000000..4959f2395d
--- /dev/null
+++ b/syweb/webclient/test/unit/matrix-service.spec.js
@@ -0,0 +1,504 @@
+describe('MatrixService', function() {
+    var scope, httpBackend;
+    var BASE = "http://example.com";
+    var PREFIX = "/_matrix/client/api/v1";
+    var URL = BASE + PREFIX;
+    var roomId = "!wejigf387t34:matrix.org";
+    
+    var CONFIG = {
+        access_token: "foobar",
+        homeserver: BASE
+    };
+    
+    beforeEach(module('matrixService'));
+
+    beforeEach(inject(function($rootScope, $httpBackend) {
+        httpBackend = $httpBackend;
+        scope = $rootScope;
+    }));
+
+    afterEach(function() {
+        httpBackend.verifyNoOutstandingExpectation();
+        httpBackend.verifyNoOutstandingRequest();
+    });
+
+    it('should be able to POST /createRoom with an alias', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var alias = "flibble";
+        matrixService.create(alias).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPOST(URL + "/createRoom?access_token=foobar",
+            {
+                room_alias_name: alias
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+
+    it('should be able to GET /initialSync', inject(function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var limit = 15;
+        matrixService.initialSync(limit).then(function(response) {
+            expect(response.data).toEqual([]);
+        });
+
+        httpBackend.expectGET(
+            URL + "/initialSync?access_token=foobar&limit=15")
+            .respond([]);
+        httpBackend.flush();
+    }));
+    
+    it('should be able to GET /rooms/$roomid/state', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        matrixService.roomState(roomId).then(function(response) {
+            expect(response.data).toEqual([]);
+        });
+
+        httpBackend.expectGET(
+            URL + "/rooms/" + encodeURIComponent(roomId) + 
+            "/state?access_token=foobar")
+            .respond([]);
+        httpBackend.flush();
+    }));
+    
+    it('should be able to POST /join', inject(function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        matrixService.joinAlias(roomId).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPOST(
+            URL + "/join/" + encodeURIComponent(roomId) + 
+            "?access_token=foobar",
+            {})
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to POST /rooms/$roomid/join', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        matrixService.join(roomId).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPOST(
+            URL + "/rooms/" + encodeURIComponent(roomId) + 
+            "/join?access_token=foobar",
+            {})
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to POST /rooms/$roomid/invite', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var inviteUserId = "@user:example.com";
+        matrixService.invite(roomId, inviteUserId).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPOST(
+            URL + "/rooms/" + encodeURIComponent(roomId) + 
+            "/invite?access_token=foobar",
+            {
+                user_id: inviteUserId
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to POST /rooms/$roomid/leave', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        matrixService.leave(roomId).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPOST(
+            URL + "/rooms/" + encodeURIComponent(roomId) + 
+            "/leave?access_token=foobar",
+            {})
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to POST /rooms/$roomid/ban', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var userId = "@example:example.com";
+        var reason = "Because.";
+        matrixService.ban(roomId, userId, reason).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPOST(
+            URL + "/rooms/" + encodeURIComponent(roomId) + 
+            "/ban?access_token=foobar",
+            {
+                user_id: userId,
+                reason: reason
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to GET /directory/room/$alias', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var alias = "#test:example.com";
+        var roomId = "!wefuhewfuiw:example.com";
+        matrixService.resolveRoomAlias(alias).then(function(response) {
+            expect(response.data).toEqual({
+                room_id: roomId
+            });
+        });
+
+        httpBackend.expectGET(
+            URL + "/directory/room/" + encodeURIComponent(alias) +
+                    "?access_token=foobar")
+            .respond({
+                room_id: roomId
+            });
+        httpBackend.flush();
+    }));
+    
+    it('should be able to send m.room.name', inject(function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var roomId = "!fh38hfwfwef:example.com";
+        var name = "Room Name";
+        matrixService.setName(roomId, name).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPUT(
+            URL + "/rooms/" + encodeURIComponent(roomId) + 
+            "/state/m.room.name?access_token=foobar",
+            {
+                name: name
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to send m.room.topic', inject(function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var roomId = "!fh38hfwfwef:example.com";
+        var topic = "A room topic can go here.";
+        matrixService.setTopic(roomId, topic).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPUT(
+            URL + "/rooms/" + encodeURIComponent(roomId) + 
+            "/state/m.room.topic?access_token=foobar",
+            {
+                topic: topic
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to send generic state events without a state key', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var roomId = "!fh38hfwfwef:example.com";
+        var eventType = "com.example.events.test";
+        var content = {
+            testing: "1 2 3"
+        };
+        matrixService.sendStateEvent(roomId, eventType, content).then(
+        function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPUT(
+            URL + "/rooms/" + encodeURIComponent(roomId) + "/state/" + 
+            encodeURIComponent(eventType) + "?access_token=foobar",
+            content)
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    // TODO: Skipped since the webclient is purposefully broken so as not to
+    // 500 matrix.org
+    xit('should be able to send generic state events with a state key', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var roomId = "!fh38hfwfwef:example.com";
+        var eventType = "com.example.events.test:special@characters";
+        var content = {
+            testing: "1 2 3"
+        };
+        var stateKey = "version:1";
+        matrixService.sendStateEvent(roomId, eventType, content, stateKey).then(
+        function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPUT(
+            URL + "/rooms/" + encodeURIComponent(roomId) + "/state/" + 
+            encodeURIComponent(eventType) + "/" + encodeURIComponent(stateKey)+
+            "?access_token=foobar",
+            content)
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to PUT generic events ', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var roomId = "!fh38hfwfwef:example.com";
+        var eventType = "com.example.events.test";
+        var txnId = "42";
+        var content = {
+            testing: "1 2 3"
+        };
+        matrixService.sendEvent(roomId, eventType, txnId, content).then(
+        function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPUT(
+            URL + "/rooms/" + encodeURIComponent(roomId) + "/send/" + 
+            encodeURIComponent(eventType) + "/" + encodeURIComponent(txnId)+
+            "?access_token=foobar",
+            content)
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to PUT text messages ', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var roomId = "!fh38hfwfwef:example.com";
+        var body = "ABC 123";
+        matrixService.sendTextMessage(roomId, body).then(
+        function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPUT(
+            new RegExp(URL + "/rooms/" + encodeURIComponent(roomId) + 
+            "/send/m.room.message/(.*)" +
+            "?access_token=foobar"),
+            {
+                body: body,
+                msgtype: "m.text"
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to PUT emote messages ', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var roomId = "!fh38hfwfwef:example.com";
+        var body = "ABC 123";
+        matrixService.sendEmoteMessage(roomId, body).then(
+        function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPUT(
+            new RegExp(URL + "/rooms/" + encodeURIComponent(roomId) + 
+            "/send/m.room.message/(.*)" +
+            "?access_token=foobar"),
+            {
+                body: body,
+                msgtype: "m.emote"
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to POST redactions', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var roomId = "!fh38hfwfwef:example.com";
+        var eventId = "fwefwexample.com";
+        matrixService.redactEvent(roomId, eventId).then(
+        function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectPOST(URL + "/rooms/" + encodeURIComponent(roomId) + 
+            "/redact/" + encodeURIComponent(eventId) +
+            "?access_token=foobar")
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to GET /directory/room/$alias', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var alias = "#test:example.com";
+        var roomId = "!wefuhewfuiw:example.com";
+        matrixService.resolveRoomAlias(alias).then(function(response) {
+            expect(response.data).toEqual({
+                room_id: roomId
+            });
+        });
+
+        httpBackend.expectGET(
+            URL + "/directory/room/" + encodeURIComponent(alias) +
+                    "?access_token=foobar")
+            .respond({
+                room_id: roomId
+            });
+        httpBackend.flush();
+    }));
+    
+    it('should be able to GET /rooms/$roomid/members', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var roomId = "!wefuhewfuiw:example.com";
+        matrixService.getMemberList(roomId).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectGET(
+            URL + "/rooms/" + encodeURIComponent(roomId) +
+                    "/members?access_token=foobar")
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to paginate a room', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var roomId = "!wefuhewfuiw:example.com";
+        var from = "3t_44e_54z";
+        var limit = 20;
+        matrixService.paginateBackMessages(roomId, from, limit).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectGET(
+            URL + "/rooms/" + encodeURIComponent(roomId) +
+                    "/messages?access_token=foobar&dir=b&from="+
+                    encodeURIComponent(from)+"&limit="+limit)
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to GET /publicRooms', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        matrixService.publicRooms().then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectGET(
+            new RegExp(URL + "/publicRooms(.*)"))
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to GET /profile/$userid/displayname', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var userId = "@foo:example.com";
+        matrixService.getDisplayName(userId).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectGET(URL + "/profile/" + encodeURIComponent(userId) +
+            "/displayname?access_token=foobar")
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to GET /profile/$userid/avatar_url', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var userId = "@foo:example.com";
+        matrixService.getProfilePictureUrl(userId).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+
+        httpBackend.expectGET(URL + "/profile/" + encodeURIComponent(userId) +
+            "/avatar_url?access_token=foobar")
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to PUT /profile/$me/avatar_url', inject(
+    function(matrixService) {
+        var testConfig = angular.copy(CONFIG);
+        testConfig.user_id = "@bob:example.com";
+        matrixService.setConfig(testConfig);
+        var url = "http://example.com/mypic.jpg";
+        matrixService.setProfilePictureUrl(url).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+        httpBackend.expectPUT(URL + "/profile/" + 
+            encodeURIComponent(testConfig.user_id) +
+            "/avatar_url?access_token=foobar",
+            {
+                avatar_url: url
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to PUT /profile/$me/displayname', inject(
+    function(matrixService) {
+        var testConfig = angular.copy(CONFIG);
+        testConfig.user_id = "@bob:example.com";
+        matrixService.setConfig(testConfig);
+        var displayname = "Bob Smith";
+        matrixService.setDisplayName(displayname).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+        httpBackend.expectPUT(URL + "/profile/" + 
+            encodeURIComponent(testConfig.user_id) +
+            "/displayname?access_token=foobar",
+            {
+                displayname: displayname
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to login with password', inject(
+    function(matrixService) {
+        matrixService.setConfig(CONFIG);
+        var userId = "@bob:example.com";
+        var password = "monkey";
+        matrixService.login(userId, password).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+        httpBackend.expectPOST(new RegExp(URL+"/login(.*)"),
+            {
+                user: userId,
+                password: password,
+                type: "m.login.password"
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+    
+    it('should be able to PUT presence status', inject(
+    function(matrixService) {
+        var testConfig = angular.copy(CONFIG);
+        testConfig.user_id = "@bob:example.com";
+        matrixService.setConfig(testConfig);
+        var status = "unavailable";
+        matrixService.setUserPresence(status).then(function(response) {
+            expect(response.data).toEqual({});
+        });
+        httpBackend.expectPUT(URL+"/presence/"+
+            encodeURIComponent(testConfig.user_id)+
+            "/status?access_token=foobar",
+            {
+                presence: status
+            })
+            .respond({});
+        httpBackend.flush();
+    }));
+});
diff --git a/syweb/webclient/test/unit/model-service.spec.js b/syweb/webclient/test/unit/model-service.spec.js
new file mode 100644
index 0000000000..e2fa8ceba3
--- /dev/null
+++ b/syweb/webclient/test/unit/model-service.spec.js
@@ -0,0 +1,30 @@
+describe('ModelService', function() {
+
+    // setup the dependencies
+    beforeEach(function() {
+        // dependencies
+        module('matrixService');
+        
+        // tested service
+        module('modelService');
+    });
+
+    it('should be able to get a member in a room', inject(
+    function(modelService) {
+        var roomId = "!wefiohwefuiow:matrix.org";
+        var userId = "@bob:matrix.org";
+        
+        modelService.getRoom(roomId).current_room_state.storeStateEvent({
+            type: "m.room.member",
+            id: "fwefw:matrix.org",
+            user_id: userId,
+            state_key: userId,
+            content: {
+                membership: "join"
+            }
+        });
+        
+        var user = modelService.getMember(roomId, userId);
+        expect(user.event.state_key).toEqual(userId);
+    }));
+});
diff --git a/syweb/webclient/test/unit/notification-service.spec.js b/syweb/webclient/test/unit/notification-service.spec.js
new file mode 100644
index 0000000000..4205ca0969
--- /dev/null
+++ b/syweb/webclient/test/unit/notification-service.spec.js
@@ -0,0 +1,78 @@
+describe('NotificationService', function() {
+
+    var userId = "@ali:matrix.org";
+    var displayName = "Alice M";
+    var bingWords = ["coffee","foo(.*)bar"]; // literal and wildcard
+
+    beforeEach(function() {
+        module('notificationService');
+    });
+    
+    // User IDs
+    
+    it('should bing on a user ID.', inject(
+    function(notificationService) {
+        expect(notificationService.containsBingWord(userId, displayName, 
+        bingWords, "Hello @ali:matrix.org, how are you?")).toEqual(true);
+    }));
+    
+    it('should bing on a partial user ID.', inject(
+    function(notificationService) {
+        expect(notificationService.containsBingWord(userId, displayName, 
+        bingWords, "Hello @ali, how are you?")).toEqual(true);
+    }));
+    
+    it('should bing on a case-insensitive user ID.', inject(
+    function(notificationService) {
+        expect(notificationService.containsBingWord(userId, displayName, 
+        bingWords, "Hello @AlI:matrix.org, how are you?")).toEqual(true);
+    }));
+    
+    // Display names
+    
+    it('should bing on a display name.', inject(
+    function(notificationService) {
+        expect(notificationService.containsBingWord(userId, displayName, 
+        bingWords, "Hello Alice M, how are you?")).toEqual(true);
+    }));
+    
+    it('should bing on a case-insensitive display name.', inject(
+    function(notificationService) {
+        expect(notificationService.containsBingWord(userId, displayName, 
+        bingWords, "Hello ALICE M, how are you?")).toEqual(true);
+    }));
+    
+    // Bing words
+    
+    it('should bing on a bing word.', inject(
+    function(notificationService) {
+        expect(notificationService.containsBingWord(userId, displayName, 
+        bingWords, "I really like coffee")).toEqual(true);
+    }));
+    
+    it('should bing on case-insensitive bing words.', inject(
+    function(notificationService) {
+        expect(notificationService.containsBingWord(userId, displayName, 
+        bingWords, "Coffee is great")).toEqual(true);
+    }));
+    
+    it('should bing on wildcard (.*) bing words.', inject(
+    function(notificationService) {
+        expect(notificationService.containsBingWord(userId, displayName, 
+        bingWords, "It was foomahbar I think.")).toEqual(true);
+    }));
+    
+    // invalid
+    
+    it('should gracefully handle bad input.', inject(
+    function(notificationService) {
+        expect(notificationService.containsBingWord(userId, displayName, 
+        bingWords, { "foo": "bar" })).toEqual(false);
+    }));
+    
+    it('should gracefully handle just a user ID.', inject(
+    function(notificationService) {
+        expect(notificationService.containsBingWord(userId, undefined, 
+        undefined, "Hello @ali:matrix.org, how are you?")).toEqual(true);
+    }));
+});
diff --git a/syweb/webclient/test/unit/recents-service.spec.js b/syweb/webclient/test/unit/recents-service.spec.js
new file mode 100644
index 0000000000..a2f9ecbaf8
--- /dev/null
+++ b/syweb/webclient/test/unit/recents-service.spec.js
@@ -0,0 +1,153 @@
+describe('RecentsService', function() {
+    var scope;
+    var MSG_EVENT = "__test__";
+    
+    var testEventContainsBingWord, testIsLive, testEvent;
+    
+    var eventHandlerService = {
+        MSG_EVENT: MSG_EVENT,
+        eventContainsBingWord: function(event) {
+            return testEventContainsBingWord;
+        }
+    };
+
+    // setup the service and mocked dependencies
+    beforeEach(function() {
+        
+        // set default mock values
+        testEventContainsBingWord = false;
+        testIsLive = true;
+        testEvent = {
+            content: {
+                body: "Hello world",
+                msgtype: "m.text"
+            },
+            user_id: "@alfred:localhost",
+            room_id: "!fl1bb13:localhost",
+            event_id: "fwuegfw@localhost"
+        }
+        
+        // mocked dependencies
+        module(function ($provide) {
+          $provide.value('eventHandlerService', eventHandlerService);
+        });
+        
+        // tested service
+        module('recentsService');
+    });
+    
+    beforeEach(inject(function($rootScope) {
+        scope = $rootScope;
+    }));
+
+    it('should start with no unread messages.', inject(
+    function(recentsService) {
+        expect(recentsService.getUnreadMessages()).toEqual({});
+        expect(recentsService.getUnreadBingMessages()).toEqual({});
+    }));
+    
+    it('should NOT add an unread message to the room currently selected.', inject(
+    function(recentsService) {
+        recentsService.setSelectedRoomId(testEvent.room_id);
+        scope.$broadcast(MSG_EVENT, testEvent, testIsLive);
+        expect(recentsService.getUnreadMessages()).toEqual({});
+        expect(recentsService.getUnreadBingMessages()).toEqual({});
+    }));
+    
+    it('should add an unread message to the room NOT currently selected.', inject(
+    function(recentsService) {
+        recentsService.setSelectedRoomId("!someotherroomid:localhost");
+        scope.$broadcast(MSG_EVENT, testEvent, testIsLive);
+        
+        var unread = {};
+        unread[testEvent.room_id] = 1;
+        expect(recentsService.getUnreadMessages()).toEqual(unread);
+    }));
+    
+    it('should add an unread message and an unread bing message if a message contains a bing word.', inject(
+    function(recentsService) {
+        recentsService.setSelectedRoomId("!someotherroomid:localhost");
+        testEventContainsBingWord = true;
+        scope.$broadcast(MSG_EVENT, testEvent, testIsLive);
+        
+        var unread = {};
+        unread[testEvent.room_id] = 1;
+        expect(recentsService.getUnreadMessages()).toEqual(unread);
+        
+        var bing = {};
+        bing[testEvent.room_id] = testEvent;
+        expect(recentsService.getUnreadBingMessages()).toEqual(bing);
+    }));
+    
+    it('should clear both unread and unread bing messages when markAsRead is called.', inject(
+    function(recentsService) {
+        recentsService.setSelectedRoomId("!someotherroomid:localhost");
+        testEventContainsBingWord = true;
+        scope.$broadcast(MSG_EVENT, testEvent, testIsLive);
+        
+        var unread = {};
+        unread[testEvent.room_id] = 1;
+        expect(recentsService.getUnreadMessages()).toEqual(unread);
+        
+        var bing = {};
+        bing[testEvent.room_id] = testEvent;
+        expect(recentsService.getUnreadBingMessages()).toEqual(bing);
+        
+        recentsService.markAsRead(testEvent.room_id);
+        
+        unread[testEvent.room_id] = 0;
+        bing[testEvent.room_id] = undefined;
+        expect(recentsService.getUnreadMessages()).toEqual(unread);
+        expect(recentsService.getUnreadBingMessages()).toEqual(bing);
+    }));
+    
+    it('should not add messages as unread if they are not live.', inject(
+    function(recentsService) {
+        testIsLive = false;
+        
+        recentsService.setSelectedRoomId("!someotherroomid:localhost");
+        testEventContainsBingWord = true;
+        scope.$broadcast(MSG_EVENT, testEvent, testIsLive);
+    
+        expect(recentsService.getUnreadMessages()).toEqual({});
+        expect(recentsService.getUnreadBingMessages()).toEqual({});
+    }));
+    
+    it('should increment the unread message count.', inject(
+    function(recentsService) {
+        recentsService.setSelectedRoomId("!someotherroomid:localhost");
+        scope.$broadcast(MSG_EVENT, testEvent, testIsLive);
+    
+        var unread = {};
+        unread[testEvent.room_id] = 1;
+        expect(recentsService.getUnreadMessages()).toEqual(unread);
+        
+        scope.$broadcast(MSG_EVENT, testEvent, testIsLive);
+        
+        unread[testEvent.room_id] = 2;
+        expect(recentsService.getUnreadMessages()).toEqual(unread);
+    }));
+    
+    it('should set the bing event to the latest message to contain a bing word.', inject(
+    function(recentsService) {
+        recentsService.setSelectedRoomId("!someotherroomid:localhost");
+        testEventContainsBingWord = true;
+        scope.$broadcast(MSG_EVENT, testEvent, testIsLive);
+    
+        var nextEvent = angular.copy(testEvent);
+        nextEvent.content.body = "Goodbye cruel world.";
+        nextEvent.event_id = "erfuerhfeaaaa@localhost";
+        scope.$broadcast(MSG_EVENT, nextEvent, testIsLive);
+        
+        var bing = {};
+        bing[testEvent.room_id] = nextEvent;
+        expect(recentsService.getUnreadBingMessages()).toEqual(bing);
+    }));
+    
+    it('should do nothing when marking an unknown room ID as read.', inject(
+    function(recentsService) {
+        recentsService.markAsRead("!someotherroomid:localhost");
+        expect(recentsService.getUnreadMessages()).toEqual({});
+        expect(recentsService.getUnreadBingMessages()).toEqual({});
+    }));
+});
diff --git a/syweb/webclient/test/unit/register-controller.spec.js b/syweb/webclient/test/unit/register-controller.spec.js
new file mode 100644
index 0000000000..b5c7842358
--- /dev/null
+++ b/syweb/webclient/test/unit/register-controller.spec.js
@@ -0,0 +1,84 @@
+describe("RegisterController ", function() {
+    var rootScope, scope, ctrl, $q, $timeout;
+    var userId = "@foo:bar";
+    var displayName = "Foo";
+    var avatarUrl = "avatar.url";
+    
+    window.webClientConfig = {
+        useCapatcha: false
+    };
+    
+    // test vars
+    var testRegisterData, testFailRegisterData;
+    
+    
+    // mock services
+    var matrixService = {
+        config: function() {
+            return {
+                user_id: userId
+            }
+        },
+        setConfig: function(){},
+        register: function(mxid, password, threepidCreds, useCaptcha) {
+            var d = $q.defer();
+            if (testFailRegisterData) {
+                d.reject({
+                    data: testFailRegisterData
+                });
+            }
+            else {
+                d.resolve({
+                    data: testRegisterData
+                });
+            }
+            return d.promise;
+        }
+    };
+    
+    var eventStreamService = {};
+    
+    beforeEach(function() {
+        module('matrixWebClient');
+        
+        // reset test vars
+        testRegisterData = undefined;
+        testFailRegisterData = undefined;
+    });
+
+    beforeEach(inject(function($rootScope, $injector, $location, $controller, _$q_, _$timeout_) {
+            $q = _$q_;
+            $timeout = _$timeout_;
+            scope = $rootScope.$new();
+            rootScope = $rootScope;
+            routeParams = {
+                user_matrix_id: userId
+            };
+            ctrl = $controller('RegisterController', {
+                '$scope': scope,
+                '$rootScope': $rootScope, 
+                '$location': $location,
+                'matrixService': matrixService,
+                'eventStreamService': eventStreamService
+            });
+        })
+    );
+
+    // SYWEB-109
+    it('should display an error if the HS rejects the username on registration', function() {
+        var prevFeedback = angular.copy(scope.feedback);
+    
+        testFailRegisterData = {
+            errcode: "M_UNKNOWN",
+            error: "I am rejecting you."
+        };
+    
+        scope.account.pwd1 = "password";
+        scope.account.pwd2 = "password";
+        scope.account.desired_user_id = "bob";
+        scope.register(); // this depends on the result of a deferred
+        rootScope.$digest(); // which is delivered after the digest
+        
+        expect(scope.feedback).not.toEqual(prevFeedback);
+    });
+});
diff --git a/webclient/test/unit/user-controller.spec.js b/syweb/webclient/test/unit/user-controller.spec.js
index 798cc4de48..798cc4de48 100644
--- a/webclient/test/unit/user-controller.spec.js
+++ b/syweb/webclient/test/unit/user-controller.spec.js
diff --git a/webclient/user/user-controller.js b/syweb/webclient/user/user-controller.js
index 0dbfa325d0..0dbfa325d0 100644
--- a/webclient/user/user-controller.js
+++ b/syweb/webclient/user/user-controller.js
diff --git a/webclient/user/user.html b/syweb/webclient/user/user.html
index 2aa981437b..2aa981437b 100644
--- a/webclient/user/user.html
+++ b/syweb/webclient/user/user.html