summary refs log tree commit diff
path: root/webclient
diff options
context:
space:
mode:
Diffstat (limited to 'webclient')
-rw-r--r--webclient/app-controller.js4
-rwxr-xr-xwebclient/app.css11
-rw-r--r--webclient/components/matrix/event-handler-service.js186
-rw-r--r--webclient/components/matrix/event-stream-service.js6
-rw-r--r--webclient/components/matrix/matrix-call.js42
-rw-r--r--webclient/components/matrix/matrix-phone-service.js64
-rw-r--r--webclient/components/matrix/matrix-service.js163
-rw-r--r--webclient/home/home-controller.js4
-rw-r--r--webclient/index.html3
-rw-r--r--webclient/recents/recents-controller.js130
-rw-r--r--webclient/recents/recents-filter.js33
-rw-r--r--webclient/recents/recents.html68
-rw-r--r--webclient/room/room-controller.js65
-rw-r--r--webclient/room/room.html2
-rw-r--r--webclient/settings/settings-controller.js11
-rw-r--r--webclient/settings/settings.html11
16 files changed, 552 insertions, 251 deletions
diff --git a/webclient/app-controller.js b/webclient/app-controller.js
index 6c3759878b..6338624486 100644
--- a/webclient/app-controller.js
+++ b/webclient/app-controller.js
@@ -130,6 +130,10 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
             angular.element('#ringAudio')[0].pause();
             angular.element('#ringbackAudio')[0].pause();
             angular.element('#busyAudio')[0].play();
+        } else if (newVal == 'ended' && oldVal == 'invite_sent' && $rootScope.currentCall.hangupParty == 'local' && $rootScope.currentCall.hangupReason == 'invite_timeout') {
+            angular.element('#ringAudio')[0].pause();
+            angular.element('#ringbackAudio')[0].pause();
+            angular.element('#busyAudio')[0].play();
         } else if (oldVal == 'invite_sent') {
             angular.element('#ringbackAudio')[0].pause();
         } else if (oldVal == 'ringing') {
diff --git a/webclient/app.css b/webclient/app.css
index 4a4ba7b8f4..704cd83947 100755
--- a/webclient/app.css
+++ b/webclient/app.css
@@ -538,6 +538,10 @@ a:active  { color: #000; }
     color: #F00;
 }
 
+.messageBing {
+    color: #00F;
+}
+
 #room-fullscreen-image {
     position: absolute;
     top: 0px;
@@ -599,7 +603,7 @@ a:active  { color: #000; }
     width: auto;
 }
 
-.recentsRoomSummaryTS {
+.recentsRoomSummaryUsersCount, .recentsRoomSummaryTS {
     color: #888;
     font-size: 12px;
     width: 7em;
@@ -612,6 +616,11 @@ a:active  { color: #000; }
     padding-bottom: 5px;
 }
 
+/* Do not show users count in the recents fragment displayed on the room page */
+#roomPage .recentsRoomSummaryUsersCount {
+    width: 0em;
+}
+
 /*** Recents in the room page ***/
 
 #roomRecentsTableWrapper {
diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js
index 4604ff6192..258de9a31e 100644
--- a/webclient/components/matrix/event-handler-service.js
+++ b/webclient/components/matrix/event-handler-service.js
@@ -27,7 +27,8 @@ Typically, this service will store events or broadcast them to any listeners
 if typically all the $on method would do is update its own $scope.
 */
 angular.module('eventHandlerService', [])
-.factory('eventHandlerService', ['matrixService', '$rootScope', '$q', function(matrixService, $rootScope, $q) {
+.factory('eventHandlerService', ['matrixService', '$rootScope', '$q', '$timeout', 'mPresence', 
+function(matrixService, $rootScope, $q, $timeout, mPresence) {
     var ROOM_CREATE_EVENT = "ROOM_CREATE_EVENT";
     var MSG_EVENT = "MSG_EVENT";
     var MEMBER_EVENT = "MEMBER_EVENT";
@@ -44,6 +45,44 @@ angular.module('eventHandlerService', [])
     var eventMap = {};
 
     $rootScope.presence = {};
+    
+    // TODO: This is attached to the rootScope so .html can just go containsBingWord
+    // for determining classes so it is easy to highlight bing messages. It seems a
+    // bit strange to put the impl in this service though, but I can't think of a better
+    // file to put it in.
+    $rootScope.containsBingWord = function(content) {
+        if (!content || $.type(content) != "string") {
+            return false;
+        }
+        var bingWords = matrixService.config().bingWords;
+        var shouldBing = false;
+        
+        // case-insensitive name check for user_id OR display_name if they exist
+        var myUserId = matrixService.config().user_id;
+        if (myUserId) {
+            myUserId = myUserId.toLocaleLowerCase();
+        }
+        var myDisplayName = matrixService.config().display_name;
+        if (myDisplayName) {
+            myDisplayName = myDisplayName.toLocaleLowerCase();
+        }
+        if ( (myDisplayName && content.toLocaleLowerCase().indexOf(myDisplayName) != -1) ||
+             (myUserId && content.toLocaleLowerCase().indexOf(myUserId) != -1) ) {
+            shouldBing = true;
+        }
+        
+        // bing word list check
+        if (bingWords && !shouldBing) {
+            for (var i=0; i<bingWords.length; i++) {
+                var re = RegExp(bingWords[i]);
+                if (content.search(re) != -1) {
+                    shouldBing = true;
+                    break;
+                }
+            }
+        }
+        return shouldBing;
+    };
 
     var initialSyncDeferred;
 
@@ -63,13 +102,14 @@ angular.module('eventHandlerService', [])
     var initRoom = function(room_id) {
         if (!(room_id in $rootScope.events.rooms)) {
             console.log("Creating new handler entry for " + room_id);
-            $rootScope.events.rooms[room_id] = {};
-            $rootScope.events.rooms[room_id].messages = [];
-            $rootScope.events.rooms[room_id].members = {};
-
-            // Pagination information
-            $rootScope.events.rooms[room_id].pagination = {
-                earliest_token: "END"   // how far back we've paginated
+            $rootScope.events.rooms[room_id] = {
+                room_id: room_id,
+                messages: [],
+                members: {},
+                // Pagination information
+                pagination: {
+                    earliest_token: "END"   // how far back we've paginated
+                }
             };
         }
     };
@@ -136,6 +176,42 @@ angular.module('eventHandlerService', [])
             else {
                 $rootScope.events.rooms[event.room_id].messages.push(event);
             }
+            
+            if (window.Notification && event.user_id != matrixService.config().user_id) {
+                var shouldBing = $rootScope.containsBingWord(event.content.body);
+            
+                // TODO: Binging every message when idle doesn't make much sense. Can we use this more sensibly?
+                // Unfortunately document.hidden = false on ubuntu chrome if chrome is minimised / does not have focus;
+                // true when you swap tabs though. However, for the case where the chat screen is OPEN and there is
+                // another window on top, we want to be notifying for those events. This DOES mean that there will be
+                // notifications when currently viewing the chat screen though, but that is preferable to the alternative imo.
+                var isIdle = (document.hidden || matrixService.presence.unavailable === mPresence.getState());
+                
+                // always bing if there are 0 bing words... apparently.
+                var bingWords = matrixService.config().bingWords;
+                if (bingWords && bingWords.length === 0) {
+                    shouldBing = true;
+                }
+                
+                if (shouldBing) {
+                    console.log("Displaying notification for "+JSON.stringify(event));
+                    var member = $rootScope.events.rooms[event.room_id].members[event.user_id];
+                    var displayname = undefined;
+                    if (member) {
+                        displayname = member.displayname;
+                    }
+                    var notification = new window.Notification(
+                        (displayname || event.user_id) +
+                        " (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here
+                    {
+                        "body": event.content.body,
+                        "icon": member ? member.avatar_url : undefined
+                    });
+                    $timeout(function() {
+                        notification.close();
+                    }, 5 * 1000);
+                }
+            }
         }
         else {
             $rootScope.events.rooms[event.room_id].messages.unshift(event);
@@ -257,7 +333,9 @@ angular.module('eventHandlerService', [])
 
             // FIXME: /initialSync on a particular room is not yet available
             // So initRoom on a new room is not called. Make sure the room data is initialised here
-            initRoom(event.room_id);
+            if (event.room_id) {
+                initRoom(event.room_id);
+            }
 
             // Avoid duplicated events
             // Needed for rooms where initialSync has not been done. 
@@ -326,13 +404,29 @@ angular.module('eventHandlerService', [])
         },
 
         // Handle messages from /initialSync or /messages
-        handleRoomMessages: function(room_id, messages, isLiveEvents) {
+        handleRoomMessages: function(room_id, messages, isLiveEvents, dir) {
             initRoom(room_id);
-            this.handleEvents(messages.chunk, isLiveEvents);
 
-            // Store how far back we've paginated
-            // This assumes the paginations requests are contiguous and in reverse chronological order
-            $rootScope.events.rooms[room_id].pagination.earliest_token = messages.end;
+            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
+                $rootScope.events.rooms[room_id].pagination.earliest_token = messages.end;
+            }
+            else {
+                // InitialSync returns messages in chronological order
+                for (var i=events.length - 1; i>=0; i--) {
+                    this.handleEvent(events[i], isLiveEvents, isLiveEvents);
+                }
+                // Store where to start pagination
+                $rootScope.events.rooms[room_id].pagination.earliest_token = messages.start;
+            }
         },
 
         handleInitialSyncDone: function(initialSyncData) {
@@ -347,6 +441,70 @@ angular.module('eventHandlerService', [])
 
         resetRoomMessages: function(room_id) {
             resetRoomMessages(room_id);
+        },
+        
+        /**
+         * 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 room = $rootScope.events.rooms[room_id];
+            if (room) {
+                for (var i = room.messages.length - 1; i >= 0; i--) {
+                    var message = room.messages[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 = $rootScope.events.rooms[room_id];
+            if (room) {
+                memberCount = 0;
+
+                for (var i in room.members) {
+                    var member = room.members[i];
+
+                    if ("join" === member.membership) {
+                        memberCount = memberCount + 1;
+                    }
+                }
+            }
+
+            return memberCount;
+        },
+        
+        /**
+         * 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 member;
+            
+            var room = $rootScope.events.rooms[room_id];
+            if (room) {
+                member = room.members[user_id];
+            }
+            return member;
         }
     };
 }]);
diff --git a/webclient/components/matrix/event-stream-service.js b/webclient/components/matrix/event-stream-service.js
index 03b805213d..6f92332246 100644
--- a/webclient/components/matrix/event-stream-service.js
+++ b/webclient/components/matrix/event-stream-service.js
@@ -104,8 +104,10 @@ angular.module('eventStreamService', [])
         settings.isActive = true;
         var deferred = $q.defer();
 
-        // Initial sync: get all information and the last message of all rooms of the user
-        matrixService.initialSync(1, false).then(
+        // Initial sync: get all information and the last 30 messages of all rooms of the user
+        // 30 messages should be enough to display a full page of messages in a room
+        // 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) {
diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js
index fd21198d24..bf1e61ad7e 100644
--- a/webclient/components/matrix/matrix-call.js
+++ b/webclient/components/matrix/matrix-call.js
@@ -53,6 +53,8 @@ angular.module('MatrixCall', [])
         this.candidateSendTries = 0;
     }
 
+    MatrixCall.CALL_TIMEOUT = 60000;
+
     MatrixCall.prototype.createPeerConnection = function() {
         var stunServer = 'stun:stun.l.google.com:19302';
         var pc;
@@ -78,12 +80,30 @@ angular.module('MatrixCall', [])
         this.config = config;
     };
 
-    MatrixCall.prototype.initWithInvite = function(msg) {
-        this.msg = msg;
+    MatrixCall.prototype.initWithInvite = function(event) {
+        this.msg = event.content;
         this.peerConn = this.createPeerConnection();
         this.peerConn.setRemoteDescription(new RTCSessionDescription(this.msg.offer), this.onSetRemoteDescriptionSuccess, this.onSetRemoteDescriptionError);
         this.state = 'ringing';
         this.direction = 'inbound';
+        var self = this;
+        $timeout(function() {
+            if (self.state == 'ringing') {
+                self.state = 'ended';
+                self.hangupParty = 'remote'; // effectively
+                self.stopAllMedia();
+                if (self.peerConn.signalingState != 'closed') self.peerConn.close();
+                if (self.onHangup) self.onHangup(self);
+            }
+        }, this.msg.lifetime - event.age);
+    };
+
+    // perverse as it may seem, sometimes we want to instantiate a call with a hangup message
+    // (because when getting the state of the room on load, events come in reverse order and
+    // we want to remember that a call has been hung up)
+    MatrixCall.prototype.initWithHangup = function(event) {
+        this.msg = event.content;
+        this.state = 'ended';
     };
 
     MatrixCall.prototype.answer = function() {
@@ -188,14 +208,12 @@ angular.module('MatrixCall', [])
             console.log("Ignoring remote ICE candidate because call has ended");
             return;
         }
-        var candidateObject = new RTCIceCandidate({
-            sdpMLineIndex: cand.label,
-            candidate: cand.candidate
-        });
-        this.peerConn.addIceCandidate(candidateObject, function() {}, function(e) {});
+        this.peerConn.addIceCandidate(new RTCIceCandidate(cand), function() {}, function(e) {});
     };
 
     MatrixCall.prototype.receivedAnswer = function(msg) {
+        if (this.state == 'ended') return;
+
         this.peerConn.setRemoteDescription(new RTCSessionDescription(msg.answer), this.onSetRemoteDescriptionSuccess, this.onSetRemoteDescriptionError);
         this.state = 'connecting';
     };
@@ -213,11 +231,19 @@ angular.module('MatrixCall', [])
         var content = {
             version: 0,
             call_id: this.call_id,
-            offer: description
+            offer: description,
+            lifetime: MatrixCall.CALL_TIMEOUT
         };
         this.sendEventWithRetry('m.call.invite', content);
 
         var self = this;
+        $timeout(function() {
+            if (self.state == 'invite_sent') {
+                self.hangupReason = 'invite_timeout';
+                self.hangup();
+            }
+        }, MatrixCall.CALL_TIMEOUT);
+
         $rootScope.$apply(function() {
             self.state = 'invite_sent';
         });
diff --git a/webclient/components/matrix/matrix-phone-service.js b/webclient/components/matrix/matrix-phone-service.js
index 2d0732a8da..d05eecf72a 100644
--- a/webclient/components/matrix/matrix-phone-service.js
+++ b/webclient/components/matrix/matrix-phone-service.js
@@ -24,22 +24,52 @@ angular.module('matrixPhoneService', [])
     matrixPhoneService.INCOMING_CALL_EVENT = "INCOMING_CALL_EVENT";
     matrixPhoneService.REPLACED_CALL_EVENT = "REPLACED_CALL_EVENT";
     matrixPhoneService.allCalls = {};
+    // a place to save candidates that come in for calls we haven't got invites for yet (when paginating backwards)
+    matrixPhoneService.candidatesByCall = {};
 
     matrixPhoneService.callPlaced = function(call) {
         matrixPhoneService.allCalls[call.call_id] = call;
     };
 
     $rootScope.$on(eventHandlerService.CALL_EVENT, function(ngEvent, event, isLive) {
-        if (!isLive) return; // until matrix supports expiring messages
         if (event.user_id == matrixService.config().user_id) return;
+
         var msg = event.content;
+
         if (event.type == 'm.call.invite') {
+            if (event.age == undefined || msg.lifetime == undefined) {
+                // if the event doesn't have either an age (the HS is too old) or a lifetime
+                // (the sending client was too old when it sent it) then fall back to old behaviour
+                if (!isLive) return; // until matrix supports expiring messages
+            }
+
+            if (event.age > msg.lifetime) {
+                console.log("Ignoring expired call event of type "+event.type);
+                return;
+            }
+
+            var call = undefined;
+            if (!isLive) {
+                // if this event wasn't live then this call may already be over
+                call = matrixPhoneService.allCalls[msg.call_id];
+                if (call && call.state == 'ended') {
+                    return;
+                }
+            }
+
             var MatrixCall = $injector.get('MatrixCall');
             var call = new MatrixCall(event.room_id);
             call.call_id = msg.call_id;
-            call.initWithInvite(msg);
+            call.initWithInvite(event);
             matrixPhoneService.allCalls[call.call_id] = call;
 
+            // if we stashed candidate events for that call ID, play them back now
+            if (!isLive && matrixPhoneService.candidatesByCall[call.call_id] != undefined) {
+                for (var i = 0; i < matrixPhoneService.candidatesByCall[call.call_id].length; ++i) {
+                    call.gotRemoteIceCandidate(matrixPhoneService.candidatesByCall[call.call_id][i]);
+                }
+            }
+
             // Were we trying to call that user (room)?
             var existingCall;
             var callIds = Object.keys(matrixPhoneService.allCalls);
@@ -79,21 +109,35 @@ angular.module('matrixPhoneService', [])
             call.receivedAnswer(msg);
         } else if (event.type == 'm.call.candidates') {
             var call = matrixPhoneService.allCalls[msg.call_id];
-            if (!call) {
+            if (!call && isLive) {
                 console.log("Got candidates for unknown call ID "+msg.call_id);
                 return;
-            }
-            for (var i = 0; i < msg.candidates.length; ++i) {
-                call.gotRemoteIceCandidate(msg.candidates[i]);
+            } else if (!call) {
+                if (matrixPhoneService.candidatesByCall[msg.call_id] == undefined) {
+                    matrixPhoneService.candidatesByCall[msg.call_id] = [];
+                }
+                matrixPhoneService.candidatesByCall[msg.call_id] = matrixPhoneService.candidatesByCall[msg.call_id].concat(msg.candidates);
+            } else {
+                for (var i = 0; i < msg.candidates.length; ++i) {
+                    call.gotRemoteIceCandidate(msg.candidates[i]);
+                }
             }
         } else if (event.type == 'm.call.hangup') {
             var call = matrixPhoneService.allCalls[msg.call_id];
-            if (!call) {
+            if (!call && isLive) {
                 console.log("Got hangup for unknown call ID "+msg.call_id);
-                return;
+            } else if (!call) {
+                // if not live, store the fact that the call has ended because we're probably getting events backwards so
+                // the hangup will come before the invite
+                var MatrixCall = $injector.get('MatrixCall');
+                var call = new MatrixCall(event.room_id);
+                call.call_id = msg.call_id;
+                call.initWithHangup(event);
+                matrixPhoneService.allCalls[msg.call_id] = call;
+            } else {
+                call.onHangupReceived();
+                delete(matrixPhoneService.allCalls[msg.call_id]);
             }
-            call.onHangupReceived();
-            delete(matrixPhoneService.allCalls[msg.call_id]);
         }
     });
     
diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js
index 68ef16800b..069e02e939 100644
--- a/webclient/components/matrix/matrix-service.js
+++ b/webclient/components/matrix/matrix-service.js
@@ -81,38 +81,155 @@ angular.module('matrixService', [])
 
         return $http(request);
     };
+    
+    var doRegisterLogin = function(path, loginType, sessionId, userName, password, threepidCreds) {
+        var data = {};
+        if (loginType === "m.login.recaptcha") {
+            var challengeToken = Recaptcha.get_challenge();
+            var captchaEntry = Recaptcha.get_response();
+            data = {
+                type: "m.login.recaptcha",
+                challenge: challengeToken,
+                response: captchaEntry
+            };
+        }
+        else if (loginType === "m.login.email.identity") {
+            data = {
+                threepidCreds: threepidCreds
+            };
+        }
+        else if (loginType === "m.login.password") {
+            data = {
+                user: userName,
+                password: password
+            };
+        }
+        
+        if (sessionId) {
+            data.session = sessionId;
+        }
+        data.type = loginType;
+        console.log("doRegisterLogin >>> " + loginType);
+        return doRequest("POST", path, undefined, data);
+    };
 
     return {
         /****** Home server API ******/
         prefix: prefixPath,
 
         // Register an user
-        register: function(user_name, password, threepidCreds, useCaptcha) {         
-            // The REST path spec
+        register: function(user_name, password, threepidCreds, useCaptcha) {
+            // registration is composed of multiple requests, to check you can
+            // register, then to actually register. This deferred will fire when
+            // all the requests are done, along with the final response.
+            var deferred = $q.defer();
             var path = "/register";
             
-            var data = {
-                 user_id: user_name,
-                 password: password,
-                 threepidCreds: threepidCreds
-            };
+            // check we can actually register with this HS.
+            doRequest("GET", path, undefined, undefined).then(
+                function(response) {
+                    console.log("/register [1] : "+JSON.stringify(response));
+                    var flows = response.data.flows;
+                    var knownTypes = [
+                        "m.login.password",
+                        "m.login.recaptcha",
+                        "m.login.email.identity"
+                    ];
+                    // if they entered 3pid creds, we want to use a flow which uses it.
+                    var useThreePidFlow = threepidCreds != undefined;
+                    var flowIndex = 0;
+                    var firstRegType = undefined;
+                    
+                    for (var i=0; i<flows.length; i++) {
+                        var isThreePidFlow = false;
+                        if (flows[i].stages) {
+                            for (var j=0; j<flows[i].stages.length; j++) {
+                                var regType = flows[i].stages[j];
+                                if (knownTypes.indexOf(regType) === -1) {
+                                    deferred.reject("Unknown type: "+regType);
+                                    return;
+                                }
+                                if (regType == "m.login.email.identity") {
+                                    isThreePidFlow = true;
+                                }
+                                if (!useCaptcha && regType == "m.login.recaptcha") {
+                                    console.error("Web client setup to not use captcha, but HS demands a captcha.");
+                                    deferred.reject({
+                                        data: {
+                                            errcode: "M_CAPTCHA_NEEDED",
+                                            error: "Home server requires a captcha."
+                                        }
+                                    });
+                                    return;
+                                }
+                            }
+                        }
+                        
+                        if ( (isThreePidFlow && useThreePidFlow) || (!isThreePidFlow && !useThreePidFlow) ) {
+                            flowIndex = i;
+                        }
+                        
+                        if (knownTypes.indexOf(flows[i].type) == -1) {
+                            deferred.reject("Unknown type: "+flows[i].type);
+                            return;
+                        }
+                    }
+                    
+                    // looks like we can register fine, go ahead and do it.
+                    console.log("Using flow " + JSON.stringify(flows[flowIndex]));
+                    firstRegType = flows[flowIndex].type;
+                    var sessionId = undefined;
+                    
+                    // generic response processor so it can loop as many times as required
+                    var loginResponseFunc = function(response) {
+                        if (response.data.session) {
+                            sessionId = response.data.session;
+                        }
+                        console.log("login response: " + JSON.stringify(response.data));
+                        if (response.data.access_token) {
+                            deferred.resolve(response);
+                        }
+                        else if (response.data.next) {
+                            var nextType = response.data.next;
+                            if (response.data.next instanceof Array) {
+                                for (var i=0; i<response.data.next.length; i++) {
+                                    if (useThreePidFlow && response.data.next[i] == "m.login.email.identity") {
+                                        nextType = response.data.next[i];
+                                        break;
+                                    }
+                                    else if (!useThreePidFlow && response.data.next[i] != "m.login.email.identity") {
+                                        nextType = response.data.next[i];
+                                        break;
+                                    }
+                                }
+                            }
+                            return doRegisterLogin(path, nextType, sessionId, user_name, password, threepidCreds).then(
+                                loginResponseFunc,
+                                function(err) {
+                                    deferred.reject(err);
+                                }
+                            );
+                        }
+                        else {
+                            deferred.reject("Unknown continuation: "+JSON.stringify(response));
+                        }
+                    };
+                    
+                    // set the ball rolling
+                    doRegisterLogin(path, firstRegType, undefined, user_name, password, threepidCreds).then(
+                        loginResponseFunc,
+                        function(err) {
+                            deferred.reject(err);
+                        }
+                    );
+                    
+                },
+                function(err) {
+                    deferred.reject(err);
+                }
+            );
             
-            if (useCaptcha) {
-                // Not all home servers will require captcha on signup, but if this flag is checked,
-                // send captcha information.
-                // TODO: Might be nice to make this a bit more flexible..
-                var challengeToken = Recaptcha.get_challenge();
-                var captchaEntry = Recaptcha.get_response();
-                var captchaType = "m.login.recaptcha";
-                
-                data.captcha = {
-                    type: captchaType,
-                    challenge: challengeToken,
-                    response: captchaEntry
-                };
-            }   
-
-            return doRequest("POST", path, undefined, data);
+            return deferred.promise;
         },
 
         // Create a room
diff --git a/webclient/home/home-controller.js b/webclient/home/home-controller.js
index c0c4ea11aa..8ba817ca68 100644
--- a/webclient/home/home-controller.js
+++ b/webclient/home/home-controller.js
@@ -117,6 +117,10 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
         matrixService.getDisplayName($scope.config.user_id).then(
             function(response) {
                 $scope.profile.displayName = response.data.displayname;
+                var config = matrixService.config();
+                config.display_name = response.data.displayname;
+                matrixService.setConfig(config);
+                matrixService.saveConfig();
             },
             function(error) {
                 $scope.feedback = "Can't load display name";
diff --git a/webclient/index.html b/webclient/index.html
index 9eea08215c..7e4dcb8345 100644
--- a/webclient/index.html
+++ b/webclient/index.html
@@ -62,7 +62,8 @@
                         <span ng-show="currentCall.state == 'connecting'">Call Connecting...</span>
                         <span ng-show="currentCall.state == 'connected'">Call Connected</span>
                         <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'remote'">Call Rejected</span>
-                        <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local'">Call Canceled</span>
+                        <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local' && currentCall.hangupReason == undefined">Call Canceled</span>
+                        <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local' && currentCall.hangupReason == 'invite_timeout'">User Not Responding</span>
                         <span ng-show="currentCall.state == 'ended' && currentCall.didConnect && currentCall.direction == 'outbound'">Call Ended</span>
                         <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'inbound'">Call Canceled</span>
                         <span ng-show="currentCall.state == 'ended' && currentCall.didConnect && currentCall.direction == 'inbound'">Call Ended</span>
diff --git a/webclient/recents/recents-controller.js b/webclient/recents/recents-controller.js
index a0db0538f3..ee8a41c366 100644
--- a/webclient/recents/recents-controller.js
+++ b/webclient/recents/recents-controller.js
@@ -16,134 +16,16 @@
 
 'use strict';
 
-angular.module('RecentsController', ['matrixService', 'matrixFilter', 'eventHandlerService'])
-.controller('RecentsController', ['$rootScope', '$scope', 'matrixService', 'eventHandlerService', 
-                               function($rootScope, $scope, matrixService, eventHandlerService) {
-                                   
-    // FIXME: Angularjs reloads the controller (and resets its $scope) each time
-    // the page URL changes, use $rootScope to avoid to have to reload data
-    $rootScope.rooms;
+angular.module('RecentsController', ['matrixService', 'matrixFilter'])
+.controller('RecentsController', ['$rootScope', '$scope', 'eventHandlerService', 
+                               function($rootScope, $scope, eventHandlerService) {
+
+    // Expose the service to the view
+    $scope.eventHandlerService = eventHandlerService;
 
     // $rootScope of the parent where the recents component is included can override this value
     // in order to highlight a specific room in the list
     $rootScope.recentsSelectedRoomID;
-    
-    var listenToEventStream = function() {
-        // Refresh the list on matrix invitation and message event
-        $rootScope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) {
-            if (isLive) {
-                if (!$rootScope.rooms[event.room_id]) {
-                    // The user has joined a new room, which we do not have data yet. The reason is that
-                    // the room has appeared in the scope of the user rooms after the global initialSync
-                    // FIXME: an initialSync on this specific room should be done
-                    $rootScope.rooms[event.room_id] = {
-                        room_id:event.room_id
-                    };
-                }
-                else if (event.state_key === matrixService.config().user_id && "invite" !== event.membership && "join" !== event.membership) {
-                    // The user has been kicked or banned from the room, remove this room from the recents
-                    delete $rootScope.rooms[event.room_id];
-                }
-                
-                if ($rootScope.rooms[event.room_id]) {
-                    $rootScope.rooms[event.room_id].lastMsg = event;
-                }
-                
-                // Update room users count
-                $rootScope.rooms[event.room_id].numUsersInRoom = getUsersCountInRoom(event.room_id);
-            }
-        });
-        $rootScope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) {
-            if (isLive) {
-                $rootScope.rooms[event.room_id].lastMsg = event;              
-            }
-        });
-        $rootScope.$on(eventHandlerService.CALL_EVENT, function(ngEvent, event, isLive) {
-            if (isLive) {
-                $rootScope.rooms[event.room_id].lastMsg = event;
-            }
-        });
-        $rootScope.$on(eventHandlerService.ROOM_CREATE_EVENT, function(ngEvent, event, isLive) {
-            if (isLive) {
-                $rootScope.rooms[event.room_id] = event;
-            }
-        });
-        $rootScope.$on(eventHandlerService.NAME_EVENT, function(ngEvent, event, isLive) {
-            if (isLive) {
-                $rootScope.rooms[event.room_id].lastMsg = event;
-            }
-        });
-        $rootScope.$on(eventHandlerService.TOPIC_EVENT, function(ngEvent, event, isLive) {
-            if (isLive) {
-                $rootScope.rooms[event.room_id].lastMsg = event;
-            }
-        });
-    };
-    
-    /**
-     * 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
-     */
-    var getUsersCountInRoom = function(room_id) {
-        var memberCount;
-        
-        var room = $rootScope.events.rooms[room_id];
-        if (room) {
-            memberCount = 0;
-            
-            for (var i in room.members) {
-                var member = room.members[i];
-                
-                if ("join" === member.membership) {
-                    memberCount = memberCount + 1;
-                }
-            }
-        }
-        
-        return memberCount;
-    };
-
-    $scope.onInit = function() {
-        // Init recents list only once
-        if ($rootScope.rooms) {
-            return;
-        }
-        
-        $rootScope.rooms = {};
-        
-        // Use initialSync data to init the recents list
-        eventHandlerService.waitForInitialSyncCompletion().then(
-            function(initialSyncData) {
-            
-                var rooms = initialSyncData.data.rooms;
-                for (var i=0; i<rooms.length; i++) {
-                    var room = rooms[i];
-                    
-                    // Add room_alias & room_display_name members
-                    $rootScope.rooms[room.room_id] = angular.extend(room, matrixService.getRoomAliasAndDisplayName(room));
-
-                    // Create a shortcut for the last message of this room
-                    if (room.messages && room.messages.chunk && room.messages.chunk[0]) {
-                        $rootScope.rooms[room.room_id].lastMsg = room.messages.chunk[0];
-                    }
-                    
-                    $rootScope.rooms[room.room_id].numUsersInRoom = getUsersCountInRoom(room.room_id);
-                }
-
-                // From now, update recents from the stream
-                listenToEventStream();
-            },
-            function(error) {
-                $rootScope.feedback = "Failure: " + error.data;
-            }
-        );
-    };
-
-    // Clean data when user logs out
-    $scope.$on(eventHandlerService.RESET_EVENT, function() {
 
-        delete $rootScope.rooms;
-    });
 }]);
 
diff --git a/webclient/recents/recents-filter.js b/webclient/recents/recents-filter.js
index d80de6fbeb..2fd4dbe98b 100644
--- a/webclient/recents/recents-filter.js
+++ b/webclient/recents/recents-filter.js
@@ -17,31 +17,48 @@
 'use strict';
 
 angular.module('RecentsController')
-.filter('orderRecents', function() {
+.filter('orderRecents', ["matrixService", "eventHandlerService", function(matrixService, eventHandlerService) {
     return function(rooms) {
 
+        var user_id = matrixService.config().user_id;
+
         // Transform the dict into an array
         // The key, room_id, is already in value objects
         var filtered = [];
-        angular.forEach(rooms, function(value, key) {
-            filtered.push( value );
+        angular.forEach(rooms, function(room, room_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)) {
+            
+                // Count users here
+                // TODO: Compute it directly in eventHandlerService
+                room.numUsersInRoom = eventHandlerService.getUsersCountInRoom(room_id);
+
+                filtered.push(room);
+            }
         });
 
         // And time sort them
         // The room with the lastest message at first
-        filtered.sort(function (a, b) {
+        filtered.sort(function (roomA, roomB) {
+
+            var lastMsgRoomA = eventHandlerService.getLastMessage(roomA.room_id, true);
+            var lastMsgRoomB = eventHandlerService.getLastMessage(roomB.room_id, true);
+
             // Invite message does not have a body message nor ts
             // Puth them at the top of the list
-            if (undefined === a.lastMsg) {
+            if (undefined === lastMsgRoomA) {
                 return -1;
             }
-            else if (undefined === b.lastMsg) {
+            else if (undefined === lastMsgRoomB) {
                 return 1;
             }
             else {
-                return b.lastMsg.ts - a.lastMsg.ts;
+                return lastMsgRoomB.ts - lastMsgRoomA.ts;
             }
         });
         return filtered;
     };
-});
\ No newline at end of file
+}]);
\ No newline at end of file
diff --git a/webclient/recents/recents.html b/webclient/recents/recents.html
index ca7636e36a..e783d3a6b4 100644
--- a/webclient/recents/recents.html
+++ b/webclient/recents/recents.html
@@ -1,20 +1,24 @@
-<div ng-controller="RecentsController" data-ng-init="onInit()">
+<div ng-controller="RecentsController">
     <table class="recentsTable">
-        <tbody ng-repeat="(rm_id, room) in rooms | orderRecents" 
+        <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)}">
+               ng-class="{'recentsRoomSelected': (room.room_id === recentsSelectedRoomID)}">                                           
             <tr>
                 <td class="recentsRoomName">
                     {{ room.room_id | mRoomName }}
                 </td>
-                <td class="recentsRoomSummaryTS">
+                <td class="recentsRoomSummaryUsersCount">
                     <span ng-show="undefined !== room.numUsersInRoom">
                         {{ room.numUsersInRoom || '1' }} {{ room.numUsersInRoom == 1 ? 'user' : 'users' }}                     
                     </span>
                 </td>
                 <td class="recentsRoomSummaryTS">
-                    {{ (room.lastMsg.ts) | date:'MMM d HH:mm' }}
+                    <!-- Use a temp var as alias to the last room message.
+                         Declaring it in this way ensures the data-binding -->
+                    {{ lastMsg = eventHandlerService.getLastMessage(room.room_id, true);"" }}
+
+                    {{ (lastMsg.ts) | date:'MMM d HH:mm' }}
                 </td>
             </tr>
 
@@ -25,67 +29,67 @@
                         {{ room.inviter | mUserDisplayName: room.room_id }} invited you
                     </div>
                     
-                    <div ng-hide="room.membership === 'invite'" ng-switch="room.lastMsg.type">
+                    <div ng-hide="room.membership === 'invite'" ng-switch="lastMsg.type">
                         <div ng-switch-when="m.room.member">
-                            <span ng-if="'join' === room.lastMsg.content.membership">
-                                {{ room.lastMsg.state_key | mUserDisplayName: room.room_id}} joined
+                            <span ng-if="'join' === lastMsg.content.membership">
+                                {{ lastMsg.state_key | mUserDisplayName: room.room_id}} joined
                             </span>
-                            <span ng-if="'leave' === room.lastMsg.content.membership">
-                                <span ng-if="room.lastMsg.user_id === room.lastMsg.state_key">
-                                    {{room.lastMsg.state_key | mUserDisplayName: room.room_id }} left
+                            <span ng-if="'leave' === lastMsg.content.membership">
+                                <span ng-if="lastMsg.user_id === lastMsg.state_key">
+                                    {{lastMsg.state_key | mUserDisplayName: room.room_id }} left
                                 </span>
-                                <span ng-if="room.lastMsg.user_id !== room.lastMsg.state_key">
-                                    {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }}
-                                    {{ {"join": "kicked", "ban": "unbanned"}[room.lastMsg.content.prev] }}
-                                    {{ room.lastMsg.state_key | mUserDisplayName: room.room_id }}
+                                <span ng-if="lastMsg.user_id !== lastMsg.state_key">
+                                    {{ lastMsg.user_id | mUserDisplayName: room.room_id }}
+                                    {{ {"join": "kicked", "ban": "unbanned"}[lastMsg.content.prev] }}
+                                    {{ lastMsg.state_key | mUserDisplayName: room.room_id }}
                                 </span>
-                                <span ng-if="'join' === room.lastMsg.content.prev && room.lastMsg.content.reason">
-                                    : {{ room.lastMsg.content.reason }}
+                                <span ng-if="'join' === lastMsg.content.prev && lastMsg.content.reason">
+                                    : {{ lastMsg.content.reason }}
                                 </span>
                             </span>
-                            <span ng-if="'invite' === room.lastMsg.content.membership || 'ban' === room.lastMsg.content.membership">
-                                {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }}
-                                {{ {"invite": "invited", "ban": "banned"}[room.lastMsg.content.membership] }}
-                                {{ room.lastMsg.state_key | mUserDisplayName: room.room_id }}
-                                <span ng-if="'ban' === room.lastMsg.content.prev && room.lastMsg.content.reason">
-                                    : {{ room.lastMsg.content.reason }}
+                            <span ng-if="'invite' === lastMsg.content.membership || 'ban' === lastMsg.content.membership">
+                                {{ lastMsg.user_id | mUserDisplayName: room.room_id }}
+                                {{ {"invite": "invited", "ban": "banned"}[lastMsg.content.membership] }}
+                                {{ lastMsg.state_key | mUserDisplayName: room.room_id }}
+                                <span ng-if="'ban' === lastMsg.content.prev && lastMsg.content.reason">
+                                    : {{ lastMsg.content.reason }}
                                 </span>
                             </span>
                         </div>
 
                         <div ng-switch-when="m.room.message">
-                            <div ng-switch="room.lastMsg.content.msgtype">
+                            <div ng-switch="lastMsg.content.msgtype">
                                 <div ng-switch-when="m.text">
-                                    {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} :
-                                    <span ng-bind-html="(room.lastMsg.content.body) | linky:'_blank'">
+                                    {{ lastMsg.user_id | mUserDisplayName: room.room_id }} :
+                                    <span ng-bind-html="(lastMsg.content.body) | linky:'_blank'">
                                     </span>
                                 </div>
 
                                 <div ng-switch-when="m.image">
-                                    {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} sent an image
+                                    {{ lastMsg.user_id | mUserDisplayName: room.room_id }} sent an image
                                 </div>
 
                                 <div ng-switch-when="m.emote">
-                                    <span ng-bind-html="'* ' + (room.lastMsg.user_id | mUserDisplayName: room.room_id) + ' ' + room.lastMsg.content.body | linky:'_blank'">
+                                    <span ng-bind-html="'* ' + (lastMsg.user_id | mUserDisplayName: room.room_id) + ' ' + lastMsg.content.body | linky:'_blank'">
                                     </span>
                                 </div>
 
                                 <div ng-switch-default>
-                                    {{ room.lastMsg.content }}
+                                    {{ lastMsg.content }}
                                 </div>
                             </div>
                         </div>
 
                         <div ng-switch-when="m.room.topic">
-                            {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} changed the topic to: {{ room.lastMsg.content.topic }}
+                            {{ lastMsg.user_id | mUserDisplayName: room.room_id }} changed the topic to: {{ lastMsg.content.topic }}
                         </div>
 
                         <div ng-switch-when="m.room.name">
-                            {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} changed the room name to: {{ room.lastMsg.content.name }}
+                            {{ lastMsg.user_id | mUserDisplayName: room.room_id }} changed the room name to: {{ lastMsg.content.name }}
                         </div>
 
                         <div ng-switch-default>
-                            <div ng-if="room.lastMsg.type.indexOf('m.call.') === 0">
+                            <div ng-if="lastMsg.type.indexOf('m.call.') === 0">
                                 Call
                             </div>
                         </div>
diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js
index 50d902ae47..6e1d83a23d 100644
--- a/webclient/room/room-controller.js
+++ b/webclient/room/room-controller.js
@@ -15,8 +15,8 @@ limitations under the License.
 */
 
 angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
-.controller('RoomController', ['$filter', '$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'eventHandlerService', 'mFileUpload', 'mPresence', 'matrixPhoneService', 'MatrixCall',
-                               function($filter, $scope, $timeout, $routeParams, $location, $rootScope, matrixService, eventHandlerService, mFileUpload, mPresence, matrixPhoneService, MatrixCall) {
+.controller('RoomController', ['$filter', '$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'eventHandlerService', 'mFileUpload', 'matrixPhoneService', 'MatrixCall',
+                               function($filter, $scope, $timeout, $routeParams, $location, $rootScope, matrixService, eventHandlerService, mFileUpload, matrixPhoneService, MatrixCall) {
    'use strict';
     var MESSAGES_PER_PAGINATION = 30;
     var THUMBNAIL_SIZE = 320;
@@ -139,27 +139,11 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         if (isLive && event.room_id === $scope.room_id) {
             
             scrollToBottom();
-
-            if (window.Notification) {
-                // Show notification when the window is hidden, or the user is idle
-                if (document.hidden || matrixService.presence.unavailable === mPresence.getState()) {
-                    var notification = new window.Notification(
-                        ($scope.members[event.user_id].displayname || event.user_id) +
-                        " (" + ($scope.room_alias || $scope.room_id) + ")", // FIXME: don't leak room_ids here
-                    {
-                        "body": event.content.body,
-                        "icon": $scope.members[event.user_id].avatar_url
-                    });
-                    $timeout(function() {
-                        notification.close();
-                    }, 5 * 1000);
-                }
-            }
         }
     });
     
     $scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) {
-        if (isLive) {
+        if (isLive && event.room_id === $scope.room_id) {
             if ($scope.state.waiting_for_joined_event) {
                 // The user has successfully joined the room, we can getting data for this room
                 $scope.state.waiting_for_joined_event = false;
@@ -177,19 +161,33 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
                 else {
                     user = event.user_id;
                 }
-
                  
                 if ("ban" === event.membership) {
                     $scope.state.permission_denied = "You have been banned by " + user;
                 }
                 else {
                     $scope.state.permission_denied = "You have been kicked by " + user;
-                }
-                
+                }  
             }
             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) {
+                    debugger;
+                    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);
+                }
             }
         }
     });
@@ -235,7 +233,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         matrixService.paginateBackMessages($scope.room_id, $rootScope.events.rooms[$scope.room_id].pagination.earliest_token, numItems).then(
             function(response) {
 
-                eventHandlerService.handleRoomMessages($scope.room_id, response.data, false);
+                eventHandlerService.handleRoomMessages($scope.room_id, response.data, false, 'b');
                 if (response.data.chunk.length < MESSAGES_PER_PAGINATION) {
                     // no more messages to paginate. this currently never gets turned true again, as we never
                     // expire paginated contents in the current implementation.
@@ -676,6 +674,10 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
     var onInit2 = function() {
         console.log("onInit2");
         
+        // Scroll down as soon as possible so that we point to the last message
+        // if it already exists in memory
+        scrollToBottom(true);
+
         // Make sure the initialSync has been before going further
         eventHandlerService.waitForInitialSyncCompletion().then(
             function() {
@@ -684,6 +686,10 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
                 
                 // The room members is available in the data fetched by initialSync
                 if ($rootScope.events.rooms[$scope.room_id]) {
+
+                    // There is no need to do a 1st pagination (initialSync provided enough to fill a page)
+                    $scope.state.first_pagination = false;
+
                     var members = $rootScope.events.rooms[$scope.room_id].members;
 
                     // Update the member list
@@ -743,9 +749,18 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
                 // Arm list timing update timer
                 updateMemberListPresenceAge();
 
-                // Start pagination
+                // Allow pagination
                 $scope.state.can_paginate = true;
-                paginate(MESSAGES_PER_PAGINATION);
+
+                // Do a first pagination only if it is required
+                // FIXME: Should be no more require when initialSync/{room_id} will be available
+                if ($scope.state.first_pagination) {
+                    paginate(MESSAGES_PER_PAGINATION);
+                }
+                else {
+                    // There are already messages, go to the last message
+                    scrollToBottom(true);
+                }
             },
             function(error) {
                 $scope.feedback = "Failed get member list: " + error.data.error;
diff --git a/webclient/room/room.html b/webclient/room/room.html
index 9d617eadd8..886c2afe64 100644
--- a/webclient/room/room.html
+++ b/webclient/room/room.html
@@ -105,7 +105,7 @@
                         
                         <span ng-show='msg.content.msgtype === "m.text"' 
                               class="message"
-                              ng-class="msg.echo_msg_state"
+                              ng-class="containsBingWord(msg.content.body) && msg.user_id != state.user_id ? msg.echo_msg_state + ' messageBing' : msg.echo_msg_state"
                               ng-bind-html="((msg.content.msgtype === 'm.text') ? msg.content.body : '') | linky:'_blank'"/>
 
                         <span ng-show='msg.type === "m.call.invite" && msg.user_id == state.user_id'>Outgoing Call</span>
diff --git a/webclient/settings/settings-controller.js b/webclient/settings/settings-controller.js
index 8c877a24e9..9cdace704a 100644
--- a/webclient/settings/settings-controller.js
+++ b/webclient/settings/settings-controller.js
@@ -194,7 +194,16 @@ angular.module('SettingsController', ['matrixService', 'mFileUpload', 'mFileInpu
     
     /*** Desktop notifications section ***/
     $scope.settings = {
-        notifications: undefined
+        notifications: undefined,
+        bingWords: matrixService.config().bingWords
+    };
+    
+    $scope.saveBingWords = function() {
+        console.log("Saving words: "+JSON.stringify($scope.settings.bingWords));
+        var config = matrixService.config();
+        config.bingWords = $scope.settings.bingWords;
+        matrixService.setConfig(config);
+        matrixService.saveConfig();
     };
 
     // If the browser supports it, check the desktop notification state
diff --git a/webclient/settings/settings.html b/webclient/settings/settings.html
index c358a6e9d8..0a5a3db51f 100644
--- a/webclient/settings/settings.html
+++ b/webclient/settings/settings.html
@@ -51,7 +51,16 @@
         <h3>Desktop notifications</h3>
         <div class="section" ng-switch="settings.notifications">
             <div ng-switch-when="granted">
-                Notifications are enabled.
+                Notifications are enabled. You will be alerted when a message contains your user ID or display name.
+                <div class="section">
+                    <h4>Additional words to alert on:</h4>
+                    <p>Leave blank to alert on all messages.</p>
+                    <input size=40 name="bingWords" ng-model="settings.bingWords" ng-list placeholder="Enter words separated with , (supports regex)"
+                    ng-blur="saveBingWords()"/>
+                    <ul>
+                        <li ng-repeat="word in settings.bingWords">{{word}}</li>
+                    </ul>
+                </div>
             </div>
             <div ng-switch-when="denied">
                 You have denied permission for notifications.<br/>