summary refs log tree commit diff
path: root/webclient
diff options
context:
space:
mode:
Diffstat (limited to 'webclient')
-rw-r--r--webclient/app.js2
-rw-r--r--webclient/components/matrix/event-handler-service.js40
-rw-r--r--webclient/components/matrix/event-stream-service.js22
-rw-r--r--webclient/components/matrix/matrix-call.js268
-rw-r--r--webclient/components/matrix/matrix-phone-service.js68
-rw-r--r--webclient/components/matrix/matrix-service.js94
-rw-r--r--webclient/home/home-controller.js12
-rw-r--r--webclient/index.html2
-rw-r--r--webclient/recents/recents-controller.js47
-rw-r--r--webclient/recents/recents.html5
-rw-r--r--webclient/room/room-controller.js137
-rw-r--r--webclient/room/room.html16
12 files changed, 598 insertions, 115 deletions
diff --git a/webclient/app.js b/webclient/app.js
index 2d6624ceec..02695c3ae6 100644
--- a/webclient/app.js
+++ b/webclient/app.js
@@ -24,6 +24,8 @@ var matrixWebClient = angular.module('matrixWebClient', [
     'SettingsController',
     'UserController',
     'matrixService',
+    'matrixPhoneService',
+    'MatrixCall',
     'eventStreamService',
     'eventHandlerService',
     'infinite-scroll'
diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js
index 6ea0f58bc5..b6e5c2eaac 100644
--- a/webclient/components/matrix/event-handler-service.js
+++ b/webclient/components/matrix/event-handler-service.js
@@ -27,13 +27,16 @@ 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', function(matrixService, $rootScope) {
+.factory('eventHandlerService', ['matrixService', '$rootScope', '$q', function(matrixService, $rootScope, $q) {
     var MSG_EVENT = "MSG_EVENT";
     var MEMBER_EVENT = "MEMBER_EVENT";
     var PRESENCE_EVENT = "PRESENCE_EVENT";
+    var CALL_EVENT = "CALL_EVENT";
+
+    var InitialSyncDeferred = $q.defer();
     
     $rootScope.events = {
-        rooms: {}, // will contain roomId: { messages:[], members:{userid1: event} }
+        rooms: {} // will contain roomId: { messages:[], members:{userid1: event} }
     };
 
     $rootScope.presence = {};
@@ -47,11 +50,11 @@ angular.module('eventHandlerService', [])
         }
     }
 
-    var reInitRoom = function(room_id) {
-        $rootScope.events.rooms[room_id] = {};
-        $rootScope.events.rooms[room_id].messages = [];
-        $rootScope.events.rooms[room_id].members = {};
-    }
+    var resetRoomMessages = function(room_id) {
+        if ($rootScope.events.rooms[room_id]) {
+            $rootScope.events.rooms[room_id].messages = [];
+        }
+    };
     
     var handleMessage = function(event, isLiveEvent) {
         initRoom(event.room_id);
@@ -92,12 +95,16 @@ angular.module('eventHandlerService', [])
         $rootScope.presence[event.content.user_id] = event;
         $rootScope.$broadcast(PRESENCE_EVENT, event, isLiveEvent);
     };
-    
+
+    var handleCallEvent = function(event, isLiveEvent) {
+        $rootScope.$broadcast(CALL_EVENT, event, isLiveEvent);
+    };
     
     return {
         MSG_EVENT: MSG_EVENT,
         MEMBER_EVENT: MEMBER_EVENT,
         PRESENCE_EVENT: PRESENCE_EVENT,
+        CALL_EVENT: CALL_EVENT,
         
     
         handleEvent: function(event, isLiveEvent) {
@@ -115,6 +122,9 @@ angular.module('eventHandlerService', [])
                     console.log("Unable to handle event type " + event.type);
                     break;
             }
+            if (event.type.indexOf('m.call.') == 0) {
+                handleCallEvent(event, isLiveEvent);
+            }
         },
         
         // isLiveEvents determines whether notifications should be shown, whether
@@ -125,8 +135,18 @@ angular.module('eventHandlerService', [])
             }
         },
 
-        reInitRoom: function(room_id) {
-            reInitRoom(room_id);
+        handleInitialSyncDone: function() {
+            console.log("# handleInitialSyncDone");
+            InitialSyncDeferred.resolve($rootScope.events, $rootScope.presence);
+        },
+
+        // Returns a promise that resolves when the initialSync request has been processed
+        waitForInitialSyncCompletion: function() {
+            return InitialSyncDeferred.promise;
         },
+
+        resetRoomMessages: function(room_id) {
+            resetRoomMessages(room_id);
+        }
     };
 }]);
diff --git a/webclient/components/matrix/event-stream-service.js b/webclient/components/matrix/event-stream-service.js
index a1a98b2a36..441148670e 100644
--- a/webclient/components/matrix/event-stream-service.js
+++ b/webclient/components/matrix/event-stream-service.js
@@ -25,7 +25,8 @@ the eventHandlerService.
 angular.module('eventStreamService', [])
 .factory('eventStreamService', ['$q', '$timeout', 'matrixService', 'eventHandlerService', function($q, $timeout, matrixService, eventHandlerService) {
     var END = "END";
-    var TIMEOUT_MS = 30000;
+    var SERVER_TIMEOUT_MS = 30000;
+    var CLIENT_TIMEOUT_MS = 40000;
     var ERR_TIMEOUT_MS = 5000;
     
     var settings = {
@@ -55,7 +56,7 @@ angular.module('eventStreamService', [])
         deferred = deferred || $q.defer();
 
         // run the stream from the latest token
-        matrixService.getEventStream(settings.from, TIMEOUT_MS).then(
+        matrixService.getEventStream(settings.from, SERVER_TIMEOUT_MS, CLIENT_TIMEOUT_MS).then(
             function(response) {
                 if (!settings.isActive) {
                     console.log("[EventStream] Got response but now inactive. Dropping data.");
@@ -80,7 +81,7 @@ angular.module('eventStreamService', [])
                 }
             },
             function(error) {
-                if (error.status == 403) {
+                if (error.status === 403) {
                     settings.shouldPoll = false;
                 }
                 
@@ -96,7 +97,7 @@ angular.module('eventStreamService', [])
         );
 
         return deferred.promise;
-    }    
+    }; 
 
     var startEventStream = function() {
         settings.shouldPoll = true;
@@ -110,18 +111,17 @@ angular.module('eventStreamService', [])
                 for (var i = 0; i < rooms.length; ++i) {
                     var room = rooms[i];
                     if ("state" in room) {
-                        for (var j = 0; j < room.state.length; ++j) {
-                            eventHandlerService.handleEvents(room.state[j], false);
-                        }
+                        eventHandlerService.handleEvents(room.state, false);
                     }
                 }
 
                 var presence = response.data.presence;
-                for (var i = 0; i < presence.length; ++i) {
-                    eventHandlerService.handleEvent(presence[i], false);
-                }
+                eventHandlerService.handleEvents(presence, false);
+
+                // Initial sync is done
+                eventHandlerService.handleInitialSyncDone();
 
-                settings.from = response.data.end
+                settings.from = response.data.end;
                 doEventStream(deferred);        
             },
             function(error) {
diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js
new file mode 100644
index 0000000000..45d00ee792
--- /dev/null
+++ b/webclient/components/matrix/matrix-call.js
@@ -0,0 +1,268 @@
+/*
+Copyright 2014 matrix.org
+
+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';
+
+var forAllVideoTracksOnStream = function(s, f) {
+    var tracks = s.getVideoTracks();
+    for (var i = 0; i < tracks.length; i++) {
+        f(tracks[i]);
+    }
+}
+
+var forAllAudioTracksOnStream = function(s, f) {
+    var tracks = s.getAudioTracks();
+    for (var i = 0; i < tracks.length; i++) {
+        f(tracks[i]);
+    }
+}
+
+var forAllTracksOnStream = function(s, f) {
+    forAllVideoTracksOnStream(s, f);
+    forAllAudioTracksOnStream(s, f);
+}
+
+angular.module('MatrixCall', [])
+.factory('MatrixCall', ['matrixService', 'matrixPhoneService', function MatrixCallFactory(matrixService, matrixPhoneService) {
+    var MatrixCall = function(room_id) {
+        this.room_id = room_id;
+        this.call_id = "c" + new Date().getTime();
+        this.state = 'fledgling';
+    }
+
+    navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
+
+    window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection;
+
+    MatrixCall.prototype.placeCall = function() {
+        self = this;
+        matrixPhoneService.callPlaced(this);
+        navigator.getUserMedia({audio: true, video: false}, function(s) { self.gotUserMediaForInvite(s); }, function(e) { self.getUserMediaFailed(e); });
+        self.state = 'wait_local_media';
+    };
+
+    MatrixCall.prototype.initWithInvite = function(msg) {
+        this.msg = msg;
+        this.peerConn = new window.RTCPeerConnection({"iceServers":[{"urls":"stun:stun.l.google.com:19302"}]})
+        self= this;
+        this.peerConn.oniceconnectionstatechange = function() { self.onIceConnectionStateChanged(); };
+        this.peerConn.onicecandidate = function(c) { self.gotLocalIceCandidate(c); };
+        this.peerConn.onsignalingstatechange = function() { self.onSignallingStateChanged(); };
+        this.peerConn.onaddstream = function(s) { self.onAddStream(s); };
+        this.peerConn.setRemoteDescription(new RTCSessionDescription(this.msg.offer), self.onSetRemoteDescriptionSuccess, self.onSetRemoteDescriptionError);
+        this.state = 'ringing';
+    };
+
+    MatrixCall.prototype.answer = function() {
+        console.trace("Answering call "+this.call_id);
+        self = this;
+        navigator.getUserMedia({audio: true, video: false}, function(s) { self.gotUserMediaForAnswer(s); }, function(e) { self.getUserMediaFailed(e); });
+        this.state = 'wait_local_media';
+    };
+
+    MatrixCall.prototype.hangup = function() {
+        console.trace("Ending call "+this.call_id);
+
+        if (this.localAVStream) {
+            forAllTracksOnStream(this.localAVStream, function(t) {
+                t.stop();
+            });
+        }
+        if (this.remoteAVStream) {
+            forAllTracksOnStream(this.remoteAVStream, function(t) {
+                t.stop();
+            });
+        }
+
+        var content = {
+            version: 0,
+            call_id: this.call_id,
+        };
+        matrixService.sendEvent(this.room_id, 'm.call.hangup', undefined, content).then(this.messageSent, this.messageSendFailed);
+        this.state = 'ended';
+    };
+
+    MatrixCall.prototype.gotUserMediaForInvite = function(stream) {
+        this.localAVStream = stream;
+        var audioTracks = stream.getAudioTracks();
+        for (var i = 0; i < audioTracks.length; i++) {
+            audioTracks[i].enabled = true;
+        }
+        this.peerConn = new window.RTCPeerConnection({"iceServers":[{"urls":"stun:stun.l.google.com:19302"}]})
+        self = this;
+        this.peerConn.oniceconnectionstatechange = function() { self.onIceConnectionStateChanged(); };
+        this.peerConn.onsignalingstatechange = function() { self.onSignallingStateChanged(); };
+        this.peerConn.onicecandidate = function(c) { self.gotLocalIceCandidate(c); };
+        this.peerConn.onaddstream = function(s) { self.onAddStream(s); };
+        this.peerConn.addStream(stream);
+        this.peerConn.createOffer(function(d) {
+            self.gotLocalOffer(d);
+        }, function(e) {
+            self.getLocalOfferFailed(e);
+        });
+        this.state = 'create_offer';
+    };
+
+    MatrixCall.prototype.gotUserMediaForAnswer = function(stream) {
+        this.localAVStream = stream;
+        var audioTracks = stream.getAudioTracks();
+        for (var i = 0; i < audioTracks.length; i++) {
+            audioTracks[i].enabled = true;
+        }
+        this.peerConn.addStream(stream);
+        self = this;
+        var constraints = {
+            'mandatory': {
+                'OfferToReceiveAudio': true,
+                'OfferToReceiveVideo': false
+            },
+        };
+        this.peerConn.createAnswer(function(d) { self.createdAnswer(d); }, function(e) {}, constraints);
+        this.state = 'create_answer';
+    };
+
+    MatrixCall.prototype.gotLocalIceCandidate = function(event) {
+        console.trace(event);
+        if (event.candidate) {
+            var content = {
+                version: 0,
+                call_id: this.call_id,
+                candidate: event.candidate
+            };
+            matrixService.sendEvent(this.room_id, 'm.call.candidate', undefined, content).then(this.messageSent, this.messageSendFailed);
+        }
+    }
+
+    MatrixCall.prototype.gotRemoteIceCandidate = function(cand) {
+        console.trace("Got ICE candidate from remote: "+cand);
+        var candidateObject = new RTCIceCandidate({
+            sdpMLineIndex: cand.label,
+            candidate: cand.candidate
+        });
+        this.peerConn.addIceCandidate(candidateObject, function() {}, function(e) {});
+    };
+
+    MatrixCall.prototype.receivedAnswer = function(msg) {
+        this.peerConn.setRemoteDescription(new RTCSessionDescription(msg.answer), self.onSetRemoteDescriptionSuccess, self.onSetRemoteDescriptionError);
+        this.state = 'connecting';
+    };
+
+    MatrixCall.prototype.gotLocalOffer = function(description) {
+        console.trace("Created offer: "+description);
+        this.peerConn.setLocalDescription(description);
+
+        var content = {
+            version: 0,
+            call_id: this.call_id,
+            offer: description
+        };
+        matrixService.sendEvent(this.room_id, 'm.call.invite', undefined, content).then(this.messageSent, this.messageSendFailed);
+        this.state = 'invite_sent';
+    };
+
+    MatrixCall.prototype.createdAnswer = function(description) {
+        console.trace("Created answer: "+description);
+        this.peerConn.setLocalDescription(description);
+        var content = {
+            version: 0,
+            call_id: this.call_id,
+            answer: description
+        };
+        matrixService.sendEvent(this.room_id, 'm.call.answer', undefined, content).then(this.messageSent, this.messageSendFailed);
+        this.state = 'connecting';
+    };
+
+    MatrixCall.prototype.messageSent = function() {
+    };
+    
+    MatrixCall.prototype.messageSendFailed = function(error) {
+    };
+
+    MatrixCall.prototype.getLocalOfferFailed = function(error) {
+        this.onError("Failed to start audio for call!");
+    };
+
+    MatrixCall.prototype.getUserMediaFailed = function() {
+        this.onError("Couldn't start capturing audio! Is your microphone set up?");
+    };
+
+    MatrixCall.prototype.onIceConnectionStateChanged = function() {
+        console.trace("Ice connection state changed to: "+this.peerConn.iceConnectionState);
+        // ideally we'd consider the call to be connected when we get media but chrome doesn't implement nay of the 'onstarted' events yet
+        if (this.peerConn.iceConnectionState == 'completed' || this.peerConn.iceConnectionState == 'connected') {
+            this.state = 'connected';
+        }
+    };
+
+    MatrixCall.prototype.onSignallingStateChanged = function() {
+        console.trace("Signalling state changed to: "+this.peerConn.signalingState);
+    };
+
+    MatrixCall.prototype.onSetRemoteDescriptionSuccess = function() {
+        console.trace("Set remote description");
+    };
+    
+    MatrixCall.prototype.onSetRemoteDescriptionError = function(e) {
+        console.trace("Failed to set remote description"+e);
+    };
+
+    MatrixCall.prototype.onAddStream = function(event) {
+        console.trace("Stream added"+event);
+
+        var s = event.stream;
+
+        this.remoteAVStream = s;
+
+        var self = this;
+        forAllTracksOnStream(s, function(t) {
+            // not currently implemented in chrome
+            t.onstarted = self.onRemoteStreamTrackStarted;
+        });
+
+        // not currently implemented in chrome
+        event.stream.onstarted = this.onRemoteStreamStarted;
+        var player = new Audio();
+        player.src = URL.createObjectURL(s);
+        player.play();
+    };
+
+    MatrixCall.prototype.onRemoteStreamStarted = function(event) {
+        this.state = 'connected';
+    };
+
+    MatrixCall.prototype.onRemoteStreamTrackStarted = function(event) {
+        this.state = 'connected';
+    };
+
+    MatrixCall.prototype.onHangupReceived = function() {
+        this.state = 'ended';
+
+        if (this.localAVStream) {
+            forAllTracksOnStream(this.localAVStream, function(t) {
+                t.stop();
+            });
+        }
+        if (this.remoteAVStream) {
+            forAllTracksOnStream(this.remoteAVStream, function(t) {
+                t.stop();
+            });
+        }
+
+        this.onHangup();
+    };
+
+    return MatrixCall;
+}]);
diff --git a/webclient/components/matrix/matrix-phone-service.js b/webclient/components/matrix/matrix-phone-service.js
new file mode 100644
index 0000000000..d9e2e8baa3
--- /dev/null
+++ b/webclient/components/matrix/matrix-phone-service.js
@@ -0,0 +1,68 @@
+/*
+Copyright 2014 matrix.org
+
+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('matrixPhoneService', [])
+.factory('matrixPhoneService', ['$rootScope', '$injector', 'matrixService', 'eventHandlerService', function MatrixPhoneService($rootScope, $injector, matrixService, eventHandlerService) {
+    var matrixPhoneService = function() {
+    };
+
+    matrixPhoneService.INCOMING_CALL_EVENT = "INCOMING_CALL_EVENT";
+    matrixPhoneService.allCalls = {};
+
+    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') {
+            var MatrixCall = $injector.get('MatrixCall');
+            var call = new MatrixCall(event.room_id);
+            call.call_id = msg.call_id;
+            call.initWithInvite(msg);
+            matrixPhoneService.allCalls[call.call_id] = call;
+            $rootScope.$broadcast(matrixPhoneService.INCOMING_CALL_EVENT, call);
+        } else if (event.type == 'm.call.answer') {
+            var call = matrixPhoneService.allCalls[msg.call_id];
+            if (!call) {
+                console.trace("Got answer for unknown call ID "+msg.call_id);
+                return;
+            }
+            call.receivedAnswer(msg);
+        } else if (event.type == 'm.call.candidate') {
+            var call = matrixPhoneService.allCalls[msg.call_id];
+            if (!call) {
+                console.trace("Got candidate for unknown call ID "+msg.call_id);
+                return;
+            }
+            call.gotRemoteIceCandidate(msg.candidate);
+        } else if (event.type == 'm.call.hangup') {
+            var call = matrixPhoneService.allCalls[msg.call_id];
+            if (!call) {
+                console.trace("Got hangup for unknown call ID "+msg.call_id);
+                return;
+            }
+            call.onHangupReceived();
+            matrixPhoneService.allCalls[msg.call_id] = undefined;
+        }
+    });
+    
+    return matrixPhoneService;
+}]);
diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js
index 2feddac5d8..8543491dca 100644
--- a/webclient/components/matrix/matrix-service.js
+++ b/webclient/components/matrix/matrix-service.js
@@ -41,7 +41,7 @@ angular.module('matrixService', [])
     var prefixPath = "/matrix/client/api/v1";
     var MAPPING_PREFIX = "alias_for_";
 
-    var doRequest = function(method, path, params, data) {
+    var doRequest = function(method, path, params, data, $httpParams) {
         if (!config) {
             console.warn("No config exists. Cannot perform request to "+path);
             return;
@@ -58,7 +58,7 @@ angular.module('matrixService', [])
             path = prefixPath + path;
         }
         
-        return doBaseRequest(config.homeserver, method, path, params, data, undefined);
+        return doBaseRequest(config.homeserver, method, path, params, data, undefined, $httpParams);
     };
 
     var doBaseRequest = function(baseUrl, method, path, params, data, headers, $httpParams) {
@@ -172,9 +172,9 @@ angular.module('matrixService', [])
             return doRequest("GET", path, undefined, {});
         },
 
-        sendMessage: function(room_id, txn_id, content) {
+        sendEvent: function(room_id, eventType, txn_id, content) {
             // The REST path spec
-            var path = "/rooms/$room_id/send/m.room.message/$txn_id";
+            var path = "/rooms/$room_id/send/"+eventType+"/$txn_id";
 
             if (!txn_id) {
                 txn_id = "m" + new Date().getTime();
@@ -190,6 +190,10 @@ angular.module('matrixService', [])
             return doRequest("PUT", path, undefined, content);
         },
 
+        sendMessage: function(room_id, txn_id, content) {
+            return this.sendEvent(room_id, 'm.room.message', txn_id, content);
+        },
+
         // Send a text message
         sendTextMessage: function(room_id, body, msg_id) {
             var content = {
@@ -343,15 +347,31 @@ angular.module('matrixService', [])
 
             return doBaseRequest(config.homeserver, "POST", path, params, file, headers, $httpParams);
         },
-        
-        // start listening on /events
-        getEventStream: function(from, timeout) {
+
+        /**
+         * Start listening on /events
+         * @param {String} from the token from which to listen events to
+         * @param {Integer} serverTimeout the time in ms the server will hold open the connection
+         * @param {Integer} clientTimeout the timeout in ms used at the client HTTP request level
+         * @returns a promise
+         */
+        getEventStream: function(from, serverTimeout, clientTimeout) {
             var path = "/events";
             var params = {
                 from: from,
-                timeout: timeout
+                timeout: serverTimeout
             };
-            return doRequest("GET", path, params);
+
+            var $httpParams;
+            if (clientTimeout) {
+                // If the Internet connection is lost, this timeout is used to be able to
+                // cancel the current request and notify the client so that it can retry with a new request.
+                $httpParams = {
+                    timeout: clientTimeout
+                };
+            }
+
+            return doRequest("GET", path, params, undefined, $httpParams);
         },
 
         // Indicates if user authentications details are stored in cache
@@ -420,34 +440,38 @@ angular.module('matrixService', [])
         /****** Room aliases management ******/
 
         /**
-         * Enhance data returned by rooms() and publicRooms() by adding room_alias
-         *  & room_display_name which are computed from data already retrieved from the server.
-         * @param {Array} data the response of rooms() and publicRooms()
-         * @returns {Array} the same array with enriched objects
+         * 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: "..."}
          */
-        assignRoomAliases: function(data) {
-            for (var i=0; i<data.length; i++) {
-                var alias = this.getRoomIdToAliasMapping(data[i].room_id);
-                if (alias) {
-                    // use the existing alias from storage
-                    data[i].room_alias = alias;
-                    data[i].room_display_name = alias;
-                }
-                else if (data[i].aliases && data[i].aliases[0]) {
-                    // save the mapping
-                    // TODO: select the smarter alias from the array
-                    this.createRoomIdToAliasMapping(data[i].room_id, data[i].aliases[0]);
-                    data[i].room_display_name = data[i].aliases[0];
-                }
-                else if (data[i].membership == "invite" && "inviter" in data[i]) {
-                    data[i].room_display_name = data[i].inviter + "'s room"
-                }
-                else {
-                    // last resort use the room id
-                    data[i].room_display_name = data[i].room_id;
-                }
+        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;
+            }
+            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];
+            }
+            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 data;
+            return result;
         },
         
         createRoomIdToAliasMapping: function(roomId, alias) {
diff --git a/webclient/home/home-controller.js b/webclient/home/home-controller.js
index 62f6ef2d95..547a5c5603 100644
--- a/webclient/home/home-controller.js
+++ b/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', 'eventStreamService', 
-                               function($scope, $location, matrixService, eventHandlerService, eventStreamService) {
+.controller('HomeController', ['$scope', '$location', 'matrixService', 
+                               function($scope, $location, matrixService) {
 
     $scope.config = matrixService.config();
     $scope.public_rooms = [];
@@ -42,7 +42,13 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
         
         matrixService.publicRooms().then(
             function(response) {
-                $scope.public_rooms = matrixService.assignRoomAliases(response.data.chunk);
+                $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));
+                }
             }
         );
     };
diff --git a/webclient/index.html b/webclient/index.html
index 16f0e8ac5f..5faf165626 100644
--- a/webclient/index.html
+++ b/webclient/index.html
@@ -26,6 +26,8 @@
     <script src="settings/settings-controller.js"></script>
     <script src="user/user-controller.js"></script>
     <script src="components/matrix/matrix-service.js"></script>
+    <script src="components/matrix/matrix-call.js"></script>
+    <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/presence-service.js"></script>
diff --git a/webclient/recents/recents-controller.js b/webclient/recents/recents-controller.js
index bf6a1b8874..d33d41a922 100644
--- a/webclient/recents/recents-controller.js
+++ b/webclient/recents/recents-controller.js
@@ -17,21 +17,34 @@
 'use strict';
 
 angular.module('RecentsController', ['matrixService', 'eventHandlerService'])
-.controller('RecentsController', ['$scope', 'matrixService', 'eventHandlerService', 'eventStreamService', 
-                               function($scope,  matrixService, eventHandlerService, eventStreamService) {
+.controller('RecentsController', ['$scope', 'matrixService', 'eventHandlerService', 
+                               function($scope,  matrixService, eventHandlerService) {
     $scope.rooms = {};
 
     // $scope of the parent where the recents component is included can override this value
     // in order to highlight a specific room in the list
     $scope.recentsSelectedRoomID;
 
-    // Refresh the list on matrix invitation and message event
-    $scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) {
-        refresh();
-    });
-    $scope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) {
-        refresh();
-    });
+    var listenToEventStream = function() {
+        // Refresh the list on matrix invitation and message event
+        $scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) {
+            var config = matrixService.config();
+            if (isLive && event.state_key === config.user_id && event.content.membership === "invite") {
+                console.log("Invited to room " + event.room_id);
+                // FIXME push membership to top level key to match /im/sync
+                event.membership = event.content.membership;
+                // FIXME bodge a nicer name than the room ID for this invite.
+                event.room_display_name = event.user_id + "'s room";
+                $scope.rooms[event.room_id] = event;
+            }
+        });
+        $scope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) {
+            if (isLive) {
+                $scope.rooms[event.room_id].lastMsg = event;              
+            }
+        });
+    };
+
     
     var refresh = function() {
         // List all rooms joined or been invited to
@@ -42,13 +55,16 @@ angular.module('RecentsController', ['matrixService', 'eventHandlerService'])
                 // Reset data
                 $scope.rooms = {};
 
-                var data = matrixService.assignRoomAliases(response.data.rooms);
-                for (var i=0; i<data.length; i++) {
-                    $scope.rooms[data[i].room_id] = data[i];
+                var rooms = response.data.rooms;
+                for (var i=0; i<rooms.length; i++) {
+                    var room = rooms[i];
+                    
+                    // Add room_alias & room_display_name members
+                    $scope.rooms[room.room_id] = angular.extend(room, matrixService.getRoomAliasAndDisplayName(room));
 
                     // Create a shortcut for the last message of this room
-                    if (data[i].messages && data[i].messages.chunk && data[i].messages.chunk[0]) {
-                        $scope.rooms[data[i].room_id].lastMsg = data[i].messages.chunk[0];
+                    if (room.messages && room.messages.chunk && room.messages.chunk[0]) {
+                        $scope.rooms[room.room_id].lastMsg = room.messages.chunk[0];
                     }
                 }
 
@@ -56,6 +72,9 @@ angular.module('RecentsController', ['matrixService', 'eventHandlerService'])
                 for (var i = 0; i < presence.length; ++i) {
                     eventHandlerService.handleEvent(presence[i], false);
                 }
+
+                // From now, update recents from the stream
+                listenToEventStream();
             },
             function(error) {
                 $scope.feedback = "Failure: " + error.data;
diff --git a/webclient/recents/recents.html b/webclient/recents/recents.html
index 6fda6c5c6b..3f025a98d8 100644
--- a/webclient/recents/recents.html
+++ b/webclient/recents/recents.html
@@ -39,6 +39,11 @@
                                     {{ room.lastMsg.user_id }} sent an image
                                 </div>
 
+                                <div ng-switch-when="m.emote">
+                                    <span ng-bind-html="'* ' + (room.lastMsg.user_id) + ' ' + room.lastMsg.content.body | linky:'_blank'">
+                                    </span>
+                                </div>
+
                                 <div ng-switch-default>
                                     {{ room.lastMsg.content }}
                                 </div>
diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js
index e775d88570..09dac85d26 100644
--- a/webclient/room/room-controller.js
+++ b/webclient/room/room-controller.js
@@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-angular.module('RoomController', ['ngSanitize', 'mFileInput', 'mUtilities'])
-.controller('RoomController', ['$scope', '$http', '$timeout', '$routeParams', '$location', 'matrixService', 'eventStreamService', 'eventHandlerService', 'mFileUpload', 'mUtilities', '$rootScope',
-                               function($scope, $http, $timeout, $routeParams, $location, matrixService, eventStreamService, eventHandlerService, mFileUpload, mUtilities, $rootScope) {
+angular.module('RoomController', ['ngSanitize', 'mFileInput'])
+.controller('RoomController', ['$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'eventHandlerService', 'mFileUpload', 'mPresence', 'matrixPhoneService', 'MatrixCall',
+                               function($scope, $timeout, $routeParams, $location, $rootScope, matrixService, eventHandlerService, mFileUpload, mPresence, matrixPhoneService, MatrixCall) {
    'use strict';
     var MESSAGES_PER_PAGINATION = 30;
     var THUMBNAIL_SIZE = 320;
@@ -51,21 +51,20 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput', 'mUtilities'])
             objDiv.scrollTop = objDiv.scrollHeight;
         }, 0);
     };
-    
+
     $scope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) {
         if (isLive && event.room_id === $scope.room_id) {
             scrollToBottom();
             
             if (window.Notification) {
-                // FIXME: we should also notify based on a timer or other heuristics
-                // rather than the window being minimised
-                if (document.hidden) {
+                // Show notification when the user is idle
+                if (matrixService.presence.offline === 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,
+                        "icon": $scope.members[event.user_id].avatar_url
                     });
                     $timeout(function() {
                         notification.close();
@@ -82,6 +81,17 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput', 'mUtilities'])
     $scope.$on(eventHandlerService.PRESENCE_EVENT, function(ngEvent, event, isLive) {
         updatePresence(event);
     });
+
+    $rootScope.$on(matrixPhoneService.INCOMING_CALL_EVENT, function(ngEvent, call) {
+        console.trace("incoming call");
+        call.onError = $scope.onCallError;
+        call.onHangup = $scope.onCallHangup;
+        $scope.currentCall = call;
+    });
+
+    $scope.memberCount = function() {
+        return Object.keys($scope.members).length;
+    };
     
     $scope.paginateMore = function() {
         if ($scope.state.can_paginate) {
@@ -89,6 +99,15 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput', 'mUtilities'])
             paginate(MESSAGES_PER_PAGINATION);
         }
     };
+
+    $scope.answerCall = function() {
+        $scope.currentCall.answer();
+    };
+
+    $scope.hangupCall = function() {
+        $scope.currentCall.hangup();
+        $scope.currentCall = undefined;
+    };
         
     var paginate = function(numItems) {
         // console.log("paginate " + numItems);
@@ -214,7 +233,7 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput', 'mUtilities'])
             var member = $scope.members[target_user_id];
             member.content.membership = chunk.content.membership;
         }
-    }
+    };
 
     var updatePresence = function(chunk) {
         if (!(chunk.content.user_id in $scope.members)) {
@@ -241,10 +260,10 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput', 'mUtilities'])
         if ("avatar_url" in chunk.content) {
             member.avatar_url = chunk.content.avatar_url;
         }
-    }
+    };
 
     $scope.send = function() {
-        if ($scope.textInput == "") {
+        if ($scope.textInput === "") {
             return;
         }
 
@@ -253,7 +272,7 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput', 'mUtilities'])
         // Send the text message
         var promise;
         // FIXME: handle other commands too
-        if ($scope.textInput.indexOf("/me") == 0) {
+        if ($scope.textInput.indexOf("/me") === 0) {
             promise = matrixService.sendEmoteMessage($scope.room_id, $scope.textInput.substr(4));
         }
         else {
@@ -282,7 +301,7 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput', 'mUtilities'])
         }
 
         if (room_id_or_alias && '!' === room_id_or_alias[0]) {
-            // Yes. We can start right now
+            // Yes. We can go on right now
             $scope.room_id = room_id_or_alias;
             $scope.room_alias = matrixService.getRoomIdToAliasMapping($scope.room_id);
             onInit2();
@@ -313,7 +332,7 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput', 'mUtilities'])
                 $scope.room_id = response.data.room_id;
                 console.log("   -> Room ID: " + $scope.room_id);
 
-                // Now, we can start
+                // Now, we can go on
                 onInit2();
             },
             function () {
@@ -323,36 +342,61 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput', 'mUtilities'])
             });
         }
     };
-
+    
     var onInit2 = function() {
-        eventHandlerService.reInitRoom($scope.room_id); 
-
-        // Make recents highlight the current room
-        $scope.recentsSelectedRoomID = $scope.room_id;
-
-        // Join the room
-        matrixService.join($scope.room_id).then(
+        console.log("onInit2");
+        
+        // Make sure the initialSync has been before going further
+        eventHandlerService.waitForInitialSyncCompletion().then(
             function() {
-                console.log("Joined room "+$scope.room_id);
+                var needsToJoin = true;
+                
+                // The room members is available in the data fetched by initialSync
+                if ($rootScope.events.rooms[$scope.room_id]) {
+                    var members = $rootScope.events.rooms[$scope.room_id].members;
+
+                    // Update the member list
+                    for (var i in members) {
+                        var member = members[i];
+                        updateMemberList(member);
+                    }
 
-                // Get the current member list
-                matrixService.getMemberList($scope.room_id).then(
-                    function(response) {
-                        for (var i = 0; i < response.data.chunk.length; i++) {
-                            var chunk = response.data.chunk[i];
-                            updateMemberList(chunk);
+                    // Check if the user has already join the room
+                    if ($scope.state.user_id in members) {
+                        if ("join" === members[$scope.state.user_id].membership) {
+                            needsToJoin = false;
                         }
-                    },
-                    function(error) {
-                        $scope.feedback = "Failed get member list: " + error.data.error;
                     }
-                );
+                }
                 
-                paginate(MESSAGES_PER_PAGINATION);
-            },
-            function(reason) {
-                $scope.feedback = "Can't join room: " + reason;
-            });
+                // Do we to join the room before starting?
+                if (needsToJoin) {
+                    matrixService.join($scope.room_id).then(
+                        function() {
+                            console.log("Joined room "+$scope.room_id);
+                            onInit3();
+                        },
+                        function(reason) {
+                            $scope.feedback = "Can't join room: " + reason;
+                        });
+                }
+                else {
+                    onInit3();
+                }
+            }
+        );
+    };
+
+    var onInit3 = function() {
+        console.log("onInit3");
+        
+        // TODO: We should be able to keep them
+        eventHandlerService.resetRoomMessages($scope.room_id); 
+
+        // Make recents highlight the current room
+        $scope.recentsSelectedRoomID = $scope.room_id;
+        
+        paginate(MESSAGES_PER_PAGINATION);
     }; 
     
     $scope.inviteUser = function(user_id) {
@@ -429,4 +473,21 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput', 'mUtilities'])
     $scope.loadMoreHistory = function() {
         paginate(MESSAGES_PER_PAGINATION);
     };
+
+    $scope.startVoiceCall = function() {
+        var call = new MatrixCall($scope.room_id);
+        call.onError = $scope.onCallError;
+        call.onHangup = $scope.onCallHangup;
+        call.placeCall();
+        $scope.currentCall = call;
+    }
+
+    $scope.onCallError = function(errStr) {
+        $scope.feedback = errStr;
+    }
+
+    $scope.onCallHangup = function() {
+        $scope.feedback = "Call ended";
+        $scope.currentCall = undefined;
+    }
 }]);
diff --git a/webclient/room/room.html b/webclient/room/room.html
index 236ca0a89b..a3514c3a91 100644
--- a/webclient/room/room.html
+++ b/webclient/room/room.html
@@ -45,13 +45,13 @@
                 </td>
                 <td ng-class="!msg.content.membership ? (msg.content.msgtype === 'm.emote' ? 'emote text' : 'text') : 'membership text'">
                     <div class="bubble">
-                        <span ng-hide='msg.type !== "m.room.member"'>
+                        <span ng-show='msg.type === "m.room.member"'>
                             {{ members[msg.user_id].displayname || msg.user_id }}
                             {{ {"join": "joined", "leave": "left", "invite": "invited"}[msg.content.membership] }}
                             {{ msg.content.membership === "invite" ? (msg.state_key || '') : '' }}
                         </span>
-                        <span ng-hide='msg.content.msgtype !== "m.emote"' ng-bind-html="'* ' + (members[msg.user_id].displayname || msg.user_id) + ' ' + msg.content.body | linky:'_blank'"/>
-                        <span ng-hide='msg.content.msgtype !== "m.text"' ng-bind-html="((msg.content.msgtype === 'm.text') ? msg.content.body : '') | linky:'_blank'"/>
+                        <span ng-show='msg.content.msgtype === "m.emote"' ng-bind-html="'* ' + (members[msg.user_id].displayname || msg.user_id) + ' ' + msg.content.body | linky:'_blank'"/>
+                        <span ng-show='msg.content.msgtype === "m.text"' ng-bind-html="((msg.content.msgtype === 'm.text') ? msg.content.body : '') | linky:'_blank'"/>
                         <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 }}"/>
@@ -98,10 +98,18 @@
                         <button ng-click="inviteUser(userIDToInvite)">Invite</button>
                 </span>
                 <button ng-click="leaveRoom()">Leave</button>
+                <button ng-click="startVoiceCall()" ng-show="currentCall == undefined && memberCount() == 2">Voice Call</button>
+                <div ng-show="currentCall.state == 'ringing'">
+                Incoming call from {{ currentCall.user_id }}
+                <button ng-click="answerCall()">Answer</button>
+                <button ng-click="hangupCall()">Reject</button>
+                </div>
+                <button ng-click="hangupCall()" ng-show="currentCall && currentCall.state != 'ringing'">Hang up</button>
+                <span style="display: none; ">{{ currentCall.state }}</span>
             </div>
         
             {{ feedback }}
-            <div ng-hide="!state.stream_failure">
+            <div ng-show="state.stream_failure">
                 {{ state.stream_failure.data.error || "Connection failure" }}
             </div>
         </div>