summary refs log tree commit diff
path: root/webclient/components
diff options
context:
space:
mode:
Diffstat (limited to 'webclient/components')
-rw-r--r--webclient/components/matrix/event-handler-service.js199
-rw-r--r--webclient/components/matrix/event-stream-service.js15
-rw-r--r--webclient/components/matrix/matrix-call.js226
-rw-r--r--webclient/components/matrix/matrix-filter.js6
-rw-r--r--webclient/components/matrix/matrix-phone-service.js41
-rw-r--r--webclient/components/matrix/matrix-service.js31
6 files changed, 397 insertions, 121 deletions
diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js
index d2bb31053f..705a5a07f2 100644
--- a/webclient/components/matrix/event-handler-service.js
+++ b/webclient/components/matrix/event-handler-service.js
@@ -35,13 +35,20 @@ angular.module('eventHandlerService', [])
     var POWERLEVEL_EVENT = "POWERLEVEL_EVENT";
     var CALL_EVENT = "CALL_EVENT";
     var NAME_EVENT = "NAME_EVENT";
+    var TOPIC_EVENT = "TOPIC_EVENT";
+    var RESET_EVENT = "RESET_EVENT";    // eventHandlerService has been resetted
+
+    var initialSyncDeferred;
+
+    var reset = function() {
+        initialSyncDeferred = $q.defer();
+
+        $rootScope.events = {
+            rooms: {} // will contain roomId: { messages:[], members:{userid1: event} }
+        };
+    }
+    reset();
 
-    var InitialSyncDeferred = $q.defer();
-    
-    $rootScope.events = {
-        rooms: {} // will contain roomId: { messages:[], members:{userid1: event} }
-    };
-    
     // used for dedupping events - could be expanded in future...
     // FIXME: means that we leak memory over time (along with lots of the rest
     // of the app, given we never try to reap memory yet)
@@ -55,6 +62,11 @@ angular.module('eventHandlerService', [])
             $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
+            };
         }
     };
 
@@ -64,9 +76,37 @@ angular.module('eventHandlerService', [])
         }
     };
     
-    var handleRoomCreate = function(event, isLiveEvent) {
-        initRoom(event.room_id);
+    // Generic method to handle events data
+    var handleRoomDateEvent = function(event, isLiveEvent, addToRoomMessages) {
+        // Add topic changes as if they were a room message
+        if (addToRoomMessages) {
+            if (isLiveEvent) {
+                $rootScope.events.rooms[event.room_id].messages.push(event);
+            }
+            else {
+                $rootScope.events.rooms[event.room_id].messages.unshift(event);
+            }
+        }
 
+        // live events always update, but non-live events only update if the
+        // ts is later.
+        var latestData = true;
+        if (!isLiveEvent) {
+            var eventTs = event.ts;
+            var storedEvent = $rootScope.events.rooms[event.room_id][event.type];
+            if (storedEvent) {
+                if (storedEvent.ts > eventTs) {
+                    // ignore it, we have a newer one already.
+                    latestData = false;
+                }
+            }
+        }
+        if (latestData) {
+            $rootScope.events.rooms[event.room_id][event.type] = event;         
+        }
+    };
+    
+    var handleRoomCreate = function(event, isLiveEvent) {
         // For now, we do not use the event data. Simply signal it to the app controllers
         $rootScope.$broadcast(ROOM_CREATE_EVENT, event, isLiveEvent);
     };
@@ -76,13 +116,18 @@ angular.module('eventHandlerService', [])
     };
 
     var handleMessage = function(event, isLiveEvent) {
-        initRoom(event.room_id);
-        
         if (isLiveEvent) {
             if (event.user_id === matrixService.config().user_id &&
                 (event.content.msgtype === "m.text" || event.content.msgtype === "m.emote") ) {
-                // assume we've already echoed it
-                // FIXME: track events by ID and ungrey the right message to show it's been delivered
+                // Assume we've already echoed it. So, there is a fake event in the messages list of the room
+                // Replace this fake event by the true one
+                var index = getRoomEventIndex(event.room_id, event.event_id);
+                if (index) {
+                    $rootScope.events.rooms[event.room_id].messages[index] = event;
+                }
+                else {
+                    $rootScope.events.rooms[event.room_id].messages.push(event);
+                }
             }
             else {
                 $rootScope.events.rooms[event.room_id].messages.push(event);
@@ -100,9 +145,7 @@ angular.module('eventHandlerService', [])
         $rootScope.$broadcast(MSG_EVENT, event, isLiveEvent);
     };
     
-    var handleRoomMember = function(event, isLiveEvent) {
-        initRoom(event.room_id);
-        
+    var handleRoomMember = function(event, isLiveEvent, isStateEvent) {
         // if the server is stupidly re-relaying a no-op join, discard it.
         if (event.prev_content && 
             event.content.membership === "join" &&
@@ -112,7 +155,10 @@ angular.module('eventHandlerService', [])
         }
         
         // add membership changes as if they were a room message if something interesting changed
-        if (event.content.prev !== event.content.membership) {
+        // Exception: Do not do this if the event is a room state event because such events already come
+        // as room messages events. Moreover, when they come as room messages events, they are relatively ordered
+        // with other other room messages
+        if (event.content.prev !== event.content.membership && !isStateEvent) {
             if (isLiveEvent) {
                 $rootScope.events.rooms[event.room_id].messages.push(event);
             }
@@ -121,8 +167,13 @@ angular.module('eventHandlerService', [])
             }
         }
         
-        $rootScope.events.rooms[event.room_id].members[event.state_key] = event;
-        $rootScope.$broadcast(MEMBER_EVENT, event, isLiveEvent);
+        // Use data from state event or the latest data from the stream.
+        // Do not care of events that come when paginating back
+        if (isStateEvent || isLiveEvent) {
+            $rootScope.events.rooms[event.room_id].members[event.state_key] = event;
+        }
+        
+        $rootScope.$broadcast(MEMBER_EVENT, event, isLiveEvent, isStateEvent);
     };
     
     var handlePresence = function(event, isLiveEvent) {
@@ -131,8 +182,6 @@ angular.module('eventHandlerService', [])
     };
     
     var handlePowerLevels = function(event, isLiveEvent) {
-        initRoom(event.room_id);
-
         // Keep the latest data. Do not care of events that come when paginating back
         if (!$rootScope.events.rooms[event.room_id][event.type] || isLiveEvent) {
             $rootScope.events.rooms[event.room_id][event.type] = event;
@@ -140,19 +189,50 @@ angular.module('eventHandlerService', [])
         }
     };
 
-    var handleRoomName = function(event, isLiveEvent) {
-        console.log("handleRoomName " + isLiveEvent);
-
-        initRoom(event.room_id);
-
-        $rootScope.events.rooms[event.room_id][event.type] = event;
+    var handleRoomName = function(event, isLiveEvent, isStateEvent) {
+        console.log("handleRoomName room_id: " + event.room_id + " - isLiveEvent: " + isLiveEvent + " - name: " + event.content.name);
+        handleRoomDateEvent(event, isLiveEvent, !isStateEvent);
         $rootScope.$broadcast(NAME_EVENT, event, isLiveEvent);
     };
+    
+
+    var handleRoomTopic = function(event, isLiveEvent, isStateEvent) {
+        console.log("handleRoomTopic room_id: " + event.room_id + " - isLiveEvent: " + isLiveEvent + " - topic: " + event.content.topic);
+        handleRoomDateEvent(event, isLiveEvent, !isStateEvent);
+        $rootScope.$broadcast(TOPIC_EVENT, event, isLiveEvent);
+    };
 
     var handleCallEvent = function(event, isLiveEvent) {
         $rootScope.$broadcast(CALL_EVENT, event, isLiveEvent);
+        if (event.type == 'm.call.invite') {
+            $rootScope.events.rooms[event.room_id].messages.push(event);
+        }
     };
     
+    /**
+     * Get the index of the event in $rootScope.events.rooms[room_id].messages
+     * @param {type} room_id the room id
+     * @param {type} event_id the event id to look for
+     * @returns {Number | undefined} the index. undefined if not found.
+     */
+    var getRoomEventIndex = function(room_id, event_id) {
+        var index;
+
+        var room = $rootScope.events.rooms[room_id];
+        if (room) {
+            // Start looking from the tail since the first goal of this function 
+            // is to find a messaged among the latest ones
+            for (var i = room.messages.length - 1; i > 0; i--) {
+                var message = room.messages[i];
+                if (event_id === message.event_id) {
+                    index = i;
+                    break;
+                }
+            }
+        }
+        return index;
+    }
+    
     return {
         ROOM_CREATE_EVENT: ROOM_CREATE_EVENT,
         MSG_EVENT: MSG_EVENT,
@@ -161,19 +241,37 @@ angular.module('eventHandlerService', [])
         POWERLEVEL_EVENT: POWERLEVEL_EVENT,
         CALL_EVENT: CALL_EVENT,
         NAME_EVENT: NAME_EVENT,
+        TOPIC_EVENT: TOPIC_EVENT,
+        RESET_EVENT: RESET_EVENT,
+        
+        reset: function() {
+            reset();
+            $rootScope.$broadcast(RESET_EVENT);
+        },
     
-        handleEvent: function(event, isLiveEvent) {
-            // FIXME: event duplication suppression is all broken as the code currently expect to handles
-            // events multiple times to get their side-effects...
-/*            
-            if (eventMap[event.event_id]) {
-                console.log("discarding duplicate event: " + JSON.stringify(event));
-                return;
-            }
-            else {
-                eventMap[event.event_id] = 1;
+        handleEvent: function(event, isLiveEvent, isStateEvent) {
+
+            // 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);
+
+            // Avoid duplicated events
+            // Needed for rooms where initialSync has not been done. 
+            // In this case, we do not know where to start pagination. So, it starts from the END
+            // and we can have the same event (ex: joined, invitation) coming from the pagination
+            // AND from the event stream.
+            // FIXME: This workaround should be no more required when /initialSync on a particular room
+            // will be available (as opposite to the global /initialSync done at startup)
+            if (!isStateEvent) {    // Do not consider state events
+                if (event.event_id && eventMap[event.event_id]) {
+                    console.log("discarding duplicate event: " + JSON.stringify(event, undefined, 4));
+                    return;
+                }
+                else {
+                    eventMap[event.event_id] = 1;
+                }
             }
-*/            
+
             if (event.type.indexOf('m.call.') === 0) {
                 handleCallEvent(event, isLiveEvent);
             }
@@ -189,7 +287,7 @@ angular.module('eventHandlerService', [])
                         handleMessage(event, isLiveEvent);
                         break;
                     case "m.room.member":
-                        handleRoomMember(event, isLiveEvent);
+                        handleRoomMember(event, isLiveEvent, isStateEvent);
                         break;
                     case "m.presence":
                         handlePresence(event, isLiveEvent);
@@ -202,7 +300,10 @@ angular.module('eventHandlerService', [])
                         handlePowerLevels(event, isLiveEvent);
                         break;
                     case 'm.room.name':
-                        handleRoomName(event, isLiveEvent);
+                        handleRoomName(event, isLiveEvent, isStateEvent);
+                        break;
+                    case 'm.room.topic':
+                        handleRoomTopic(event, isLiveEvent, isStateEvent);
                         break;
                     default:
                         console.log("Unable to handle event type " + event.type);
@@ -214,20 +315,30 @@ angular.module('eventHandlerService', [])
         
         // isLiveEvents determines whether notifications should be shown, whether
         // messages get appended to the start/end of lists, etc.
-        handleEvents: function(events, isLiveEvents) {
+        handleEvents: function(events, isLiveEvents, isStateEvents) {
             for (var i=0; i<events.length; i++) {
-                this.handleEvent(events[i], isLiveEvents);
+                this.handleEvent(events[i], isLiveEvents, isStateEvents);
             }
         },
 
-        handleInitialSyncDone: function() {
+        // Handle messages from /initialSync or /messages
+        handleRoomMessages: function(room_id, messages, isLiveEvents) {
+            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;
+        },
+
+        handleInitialSyncDone: function(initialSyncData) {
             console.log("# handleInitialSyncDone");
-            InitialSyncDeferred.resolve($rootScope.events, $rootScope.presence);
+            initialSyncDeferred.resolve(initialSyncData);
         },
 
         // Returns a promise that resolves when the initialSync request has been processed
         waitForInitialSyncCompletion: function() {
-            return InitialSyncDeferred.promise;
+            return initialSyncDeferred.promise;
         },
 
         resetRoomMessages: function(room_id) {
diff --git a/webclient/components/matrix/event-stream-service.js b/webclient/components/matrix/event-stream-service.js
index ed4f3b2ffc..03b805213d 100644
--- a/webclient/components/matrix/event-stream-service.js
+++ b/webclient/components/matrix/event-stream-service.js
@@ -104,15 +104,19 @@ angular.module('eventStreamService', [])
         settings.isActive = true;
         var deferred = $q.defer();
 
-        // FIXME: We are discarding all the messages.
-        matrixService.rooms().then(
+        // Initial sync: get all information and the last message of all rooms of the user
+        matrixService.initialSync(1, false).then(
             function(response) {
                 var rooms = response.data.rooms;
                 for (var i = 0; i < rooms.length; ++i) {
                     var room = rooms[i];
-                    // console.log("got room: " + room.room_id);
+
+                    if ("messages" in room) {
+                        eventHandlerService.handleRoomMessages(room.room_id, room.messages, false);
+                    }
+                    
                     if ("state" in room) {
-                        eventHandlerService.handleEvents(room.state, false);
+                        eventHandlerService.handleEvents(room.state, false, true);
                     }
                 }
 
@@ -120,8 +124,9 @@ angular.module('eventStreamService', [])
                 eventHandlerService.handleEvents(presence, false);
 
                 // Initial sync is done
-                eventHandlerService.handleInitialSyncDone();
+                eventHandlerService.handleInitialSyncDone(response);
 
+                // Start event streaming from that point
                 settings.from = response.data.end;
                 doEventStream(deferred);        
             },
diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js
index 3cb5e8b693..2e3e2b0967 100644
--- a/webclient/components/matrix/matrix-call.js
+++ b/webclient/components/matrix/matrix-call.js
@@ -35,8 +35,13 @@ var forAllTracksOnStream = function(s, f) {
     forAllAudioTracksOnStream(s, f);
 }
 
+navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
+window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection; // but not mozRTCPeerConnection because its interface is not compatible
+window.RTCSessionDescription = window.RTCSessionDescription || window.webkitRTCSessionDescription || window.mozRTCSessionDescription;
+window.RTCIceCandidate = window.RTCIceCandidate || window.webkitRTCIceCandidate || window.mozRTCIceCandidate;
+
 angular.module('MatrixCall', [])
-.factory('MatrixCall', ['matrixService', 'matrixPhoneService', '$rootScope', function MatrixCallFactory(matrixService, matrixPhoneService, $rootScope) {
+.factory('MatrixCall', ['matrixService', 'matrixPhoneService', '$rootScope', '$timeout', function MatrixCallFactory(matrixService, matrixPhoneService, $rootScope, $timeout) {
     var MatrixCall = function(room_id) {
         this.room_id = room_id;
         this.call_id = "c" + new Date().getTime();
@@ -44,93 +49,117 @@ angular.module('MatrixCall', [])
         this.didConnect = false;
     }
 
-    navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
-
-    window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection;
+    MatrixCall.prototype.createPeerConnection = function() {
+        var stunServer = 'stun:stun.l.google.com:19302';
+        var pc;
+        if (window.mozRTCPeerConnection) {
+            pc = window.mozRTCPeerConnection({'url': stunServer});
+        } else {
+            pc = new window.RTCPeerConnection({"iceServers":[{"urls":"stun:stun.l.google.com:19302"}]});
+        }
+        var self = this;
+        pc.oniceconnectionstatechange = function() { self.onIceConnectionStateChanged(); };
+        pc.onsignalingstatechange = function() { self.onSignallingStateChanged(); };
+        pc.onicecandidate = function(c) { self.gotLocalIceCandidate(c); };
+        pc.onaddstream = function(s) { self.onAddStream(s); };
+        return pc;
+    }
 
-    MatrixCall.prototype.placeCall = function() {
-        self = this;
+    MatrixCall.prototype.placeCall = function(config) {
+        var 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';
+        navigator.getUserMedia({audio: config.audio, video: config.video}, function(s) { self.gotUserMediaForInvite(s); }, function(e) { self.getUserMediaFailed(e); });
+        this.state = 'wait_local_media';
         this.direction = 'outbound';
+        this.config = config;
     };
 
     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.peerConn = this.createPeerConnection();
+        this.peerConn.setRemoteDescription(new RTCSessionDescription(this.msg.offer), this.onSetRemoteDescriptionSuccess, this.onSetRemoteDescriptionError);
         this.state = 'ringing';
         this.direction = 'inbound';
     };
 
     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';
+        console.log("Answering call "+this.call_id);
+        var self = this;
+        if (!this.localAVStream && !this.waitForLocalAVStream) {
+            navigator.getUserMedia({audio: true, video: false}, function(s) { self.gotUserMediaForAnswer(s); }, function(e) { self.getUserMediaFailed(e); });
+            this.state = 'wait_local_media';
+        } else if (this.localAVStream) {
+            this.gotUserMediaForAnswer(this.localAVStream);
+        } else if (this.waitForLocalAVStream) {
+            this.state = 'wait_local_media';
+        }
     };
 
     MatrixCall.prototype.stopAllMedia = function() {
         if (this.localAVStream) {
             forAllTracksOnStream(this.localAVStream, function(t) {
-                t.stop();
+                if (t.stop) t.stop();
             });
         }
         if (this.remoteAVStream) {
             forAllTracksOnStream(this.remoteAVStream, function(t) {
-                t.stop();
+                if (t.stop) t.stop();
             });
         }
     };
 
-    MatrixCall.prototype.hangup = function() {
-        console.trace("Ending call "+this.call_id);
+    MatrixCall.prototype.hangup = function(suppressEvent) {
+        console.log("Ending call "+this.call_id);
 
         this.stopAllMedia();
+        if (this.peerConn) this.peerConn.close();
+
+        this.hangupParty = 'local';
 
         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.sendEventWithRetry('m.call.hangup', content);
         this.state = 'ended';
+        if (this.onHangup && !suppressEvent) this.onHangup(this);
     };
 
     MatrixCall.prototype.gotUserMediaForInvite = function(stream) {
+        if (this.successor) {
+            this.successor.gotUserMediaForAnswer(stream);
+            return;
+        }
+        if (this.state == 'ended') return;
+
         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 = this.createPeerConnection();
         this.peerConn.addStream(stream);
+        var self = this;
         this.peerConn.createOffer(function(d) {
             self.gotLocalOffer(d);
         }, function(e) {
             self.getLocalOfferFailed(e);
         });
-        this.state = 'create_offer';
+        $rootScope.$apply(function() {
+            self.state = 'create_offer';
+        });
     };
 
     MatrixCall.prototype.gotUserMediaForAnswer = function(stream) {
+        if (this.state == 'ended') return;
+
         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 self = this;
         var constraints = {
             'mandatory': {
                 'OfferToReceiveAudio': true,
@@ -138,23 +167,28 @@ angular.module('MatrixCall', [])
             },
         };
         this.peerConn.createAnswer(function(d) { self.createdAnswer(d); }, function(e) {}, constraints);
-        this.state = 'create_answer';
+        // This can't be in an apply() because it's called by a predecessor call under glare conditions :(
+        self.state = 'create_answer';
     };
 
     MatrixCall.prototype.gotLocalIceCandidate = function(event) {
-        console.trace(event);
+        console.log(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);
+            this.sendEventWithRetry('m.call.candidate', content);
         }
     }
 
     MatrixCall.prototype.gotRemoteIceCandidate = function(cand) {
-        console.trace("Got ICE candidate from remote: "+cand);
+        console.log("Got ICE candidate from remote: "+cand);
+        if (this.state == 'ended') {
+            console.log("Ignoring remote ICE candidate because call has ended");
+            return;
+        }
         var candidateObject = new RTCIceCandidate({
             sdpMLineIndex: cand.label,
             candidate: cand.candidate
@@ -163,12 +197,18 @@ angular.module('MatrixCall', [])
     };
 
     MatrixCall.prototype.receivedAnswer = function(msg) {
-        this.peerConn.setRemoteDescription(new RTCSessionDescription(msg.answer), self.onSetRemoteDescriptionSuccess, self.onSetRemoteDescriptionError);
+        this.peerConn.setRemoteDescription(new RTCSessionDescription(msg.answer), this.onSetRemoteDescriptionSuccess, this.onSetRemoteDescriptionError);
         this.state = 'connecting';
     };
 
     MatrixCall.prototype.gotLocalOffer = function(description) {
-        console.trace("Created offer: "+description);
+        console.log("Created offer: "+description);
+
+        if (this.state == 'ended') {
+            console.log("Ignoring newly created offer on call ID "+this.call_id+" because the call has ended");
+            return;
+        }
+
         this.peerConn.setLocalDescription(description);
 
         var content = {
@@ -176,26 +216,27 @@ angular.module('MatrixCall', [])
             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';
+        this.sendEventWithRetry('m.call.invite', content);
+
+        var self = this;
+        $rootScope.$apply(function() {
+            self.state = 'invite_sent';
+        });
     };
 
     MatrixCall.prototype.createdAnswer = function(description) {
-        console.trace("Created answer: "+description);
+        console.log("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) {
+        this.sendEventWithRetry('m.call.answer', content);
+        var self = this;
+        $rootScope.$apply(function() {
+            self.state = 'connecting';
+        });
     };
 
     MatrixCall.prototype.getLocalOfferFailed = function(error) {
@@ -204,33 +245,36 @@ angular.module('MatrixCall', [])
 
     MatrixCall.prototype.getUserMediaFailed = function() {
         this.onError("Couldn't start capturing audio! Is your microphone set up?");
+        this.hangup();
     };
 
     MatrixCall.prototype.onIceConnectionStateChanged = function() {
         if (this.state == 'ended') return; // because ICE can still complete as we're ending the call
-        console.trace("Ice connection state changed to: "+this.peerConn.iceConnectionState);
+        console.log("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';
-            this.didConnect = true;
-            $rootScope.$apply();
+            var self = this;
+            $rootScope.$apply(function() {
+                self.state = 'connected';
+                self.didConnect = true;
+            });
         }
     };
 
     MatrixCall.prototype.onSignallingStateChanged = function() {
-        console.trace("Signalling state changed to: "+this.peerConn.signalingState);
+        console.log("call "+this.call_id+": Signalling state changed to: "+this.peerConn.signalingState);
     };
 
     MatrixCall.prototype.onSetRemoteDescriptionSuccess = function() {
-        console.trace("Set remote description");
+        console.log("Set remote description");
     };
     
     MatrixCall.prototype.onSetRemoteDescriptionError = function(e) {
-        console.trace("Failed to set remote description"+e);
+        console.log("Failed to set remote description"+e);
     };
 
     MatrixCall.prototype.onAddStream = function(event) {
-        console.trace("Stream added"+event);
+        console.log("Stream added"+event);
 
         var s = event.stream;
 
@@ -251,23 +295,79 @@ angular.module('MatrixCall', [])
     };
 
     MatrixCall.prototype.onRemoteStreamStarted = function(event) {
-        this.state = 'connected';
+        var self = this;
+        $rootScope.$apply(function() {
+            self.state = 'connected';
+        });
     };
 
     MatrixCall.prototype.onRemoteStreamEnded = function(event) {
-        this.state = 'ended';
-        this.stopAllMedia();
-        this.onHangup();
+        console.log("Remote stream ended");
+        var self = this;
+        $rootScope.$apply(function() {
+            self.state = 'ended';
+            self.hangupParty = 'remote';
+            self.stopAllMedia();
+            if (self.peerConn.signalingState != 'closed') self.peerConn.close();
+            if (self.onHangup) self.onHangup(self);
+        });
     };
 
     MatrixCall.prototype.onRemoteStreamTrackStarted = function(event) {
-        this.state = 'connected';
+        var self = this;
+        $rootScope.$apply(function() {
+            self.state = 'connected';
+        });
     };
 
     MatrixCall.prototype.onHangupReceived = function() {
+        console.log("Hangup received");
         this.state = 'ended';
+        this.hangupParty = 'remote';
         this.stopAllMedia();
-        this.onHangup();
+        if (this.peerConn.signalingState != 'closed') this.peerConn.close();
+        if (this.onHangup) this.onHangup(this);
+    };
+
+    MatrixCall.prototype.replacedBy = function(newCall) {
+        console.log(this.call_id+" being replaced by "+newCall.call_id);
+        if (this.state == 'wait_local_media') {
+            console.log("Telling new call to wait for local media");
+            newCall.waitForLocalAVStream = true;
+        } else if (this.state == 'create_offer') {
+            console.log("Handing local stream to new call");
+            newCall.localAVStream = this.localAVStream;
+            delete(this.localAVStream);
+        } else if (this.state == 'invite_sent') {
+            console.log("Handing local stream to new call");
+            newCall.localAVStream = this.localAVStream;
+            delete(this.localAVStream);
+        }
+        this.successor = newCall;
+        this.hangup(true);
+    };
+
+    MatrixCall.prototype.sendEventWithRetry = function(evType, content) {
+        var ev = { type:evType, content:content, tries:1 };
+        var self = this;
+        matrixService.sendEvent(this.room_id, evType, undefined, content).then(this.eventSent, function(error) { self.eventSendFailed(ev, error); } );
+    };
+
+    MatrixCall.prototype.eventSent = function() {
+    };
+
+    MatrixCall.prototype.eventSendFailed = function(ev, error) {
+        if (ev.tries > 5) {
+            console.log("Failed to send event of type "+ev.type+" on attempt "+ev.tries+". Giving up.");
+            return;
+        }
+        var delayMs = 500 * Math.pow(2, ev.tries);
+        console.log("Failed to send event of type "+ev.type+". Retrying in "+delayMs+"ms");
+        ++ev.tries;
+        var self = this;
+        $timeout(function() {
+            matrixService.sendEvent(self.room_id, ev.type, undefined, ev.content).then(self.eventSent, function(error) { self.eventSendFailed(ev, error); } );
+        }, delayMs);
     };
 
     return MatrixCall;
diff --git a/webclient/components/matrix/matrix-filter.js b/webclient/components/matrix/matrix-filter.js
index 260e0827df..015a88bcad 100644
--- a/webclient/components/matrix/matrix-filter.js
+++ b/webclient/components/matrix/matrix-filter.js
@@ -31,15 +31,17 @@ angular.module('matrixFilter', [])
         }
 
         if (undefined === roomName) {
-            // Else, build the name from its users
+
             var room = $rootScope.events.rooms[room_id];
             if (room) {
+                // Get name from room state date
                 var room_name_event = room["m.room.name"];
-
                 if (room_name_event) {
                     roomName = room_name_event.content.name;
                 }
                 else if (room.members) {
+                    // Else, build the name from its users
+                    // FIXME: Is it still required?
                     // Limit the room renaming to 1:1 room
                     if (2 === Object.keys(room.members).length) {
                         for (var i in room.members) {
diff --git a/webclient/components/matrix/matrix-phone-service.js b/webclient/components/matrix/matrix-phone-service.js
index ca86b473e7..b0dcf19100 100644
--- a/webclient/components/matrix/matrix-phone-service.js
+++ b/webclient/components/matrix/matrix-phone-service.js
@@ -22,6 +22,7 @@ angular.module('matrixPhoneService', [])
     };
 
     matrixPhoneService.INCOMING_CALL_EVENT = "INCOMING_CALL_EVENT";
+    matrixPhoneService.REPLACED_CALL_EVENT = "REPLACED_CALL_EVENT";
     matrixPhoneService.allCalls = {};
 
     matrixPhoneService.callPlaced = function(call) {
@@ -38,29 +39,59 @@ angular.module('matrixPhoneService', [])
             call.call_id = msg.call_id;
             call.initWithInvite(msg);
             matrixPhoneService.allCalls[call.call_id] = call;
-            $rootScope.$broadcast(matrixPhoneService.INCOMING_CALL_EVENT, call);
+
+            // Were we trying to call that user (room)?
+            var existingCall;
+            var callIds = Object.keys(matrixPhoneService.allCalls);
+            for (var i = 0; i < callIds.length; ++i) {
+                var thisCallId = callIds[i];
+                var thisCall = matrixPhoneService.allCalls[thisCallId];
+
+                if (call.room_id == thisCall.room_id && thisCall.direction == 'outbound'
+                     && (thisCall.state == 'wait_local_media' || thisCall.state == 'create_offer' || thisCall.state == 'invite_sent')) {
+                    existingCall = thisCall;
+                    break;
+                }
+            }
+
+            if (existingCall) {
+                // If we've only got to wait_local_media or create_offer and we've got an invite,
+                // pick the incoming call because we know we haven't sent our invite yet
+                // otherwise, pick whichever call has the lowest call ID (by string comparison)
+                if (existingCall.state == 'wait_local_media' || existingCall.state == 'create_offer' || existingCall.call_id > call.call_id) {
+                    console.log("Glare detected: answering incoming call "+call.call_id+" and canceling outgoing call "+existingCall.call_id);
+                    existingCall.replacedBy(call);
+                    call.answer();
+                    $rootScope.$broadcast(matrixPhoneService.REPLACED_CALL_EVENT, existingCall, call);
+                } else {
+                    console.log("Glare detected: rejecting incoming call "+call.call_id+" and keeping outgoing call "+existingCall.call_id);
+                    call.hangup();
+                }
+            } else {
+                $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);
+                console.log("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);
+                console.log("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);
+                console.log("Got hangup for unknown call ID "+msg.call_id);
                 return;
             }
             call.onHangupReceived();
-            matrixPhoneService.allCalls[msg.call_id] = undefined;
+            delete(matrixPhoneService.allCalls[msg.call_id]);
         }
     });
     
diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js
index 3c28c52fbe..68ef16800b 100644
--- a/webclient/components/matrix/matrix-service.js
+++ b/webclient/components/matrix/matrix-service.js
@@ -130,8 +130,9 @@ angular.module('matrixService', [])
             return doRequest("POST", path, undefined, req);
         },
 
-        // List all rooms joined or been invited to
-        rooms: function(limit, feedback) {
+        // Get the user's current state: his presence, the list of his rooms with
+        // the last {limit} events
+        initialSync: function(limit, feedback) {
             // The REST path spec
 
             var path = "/initialSync";
@@ -234,6 +235,32 @@ angular.module('matrixService', [])
 
             return doRequest("GET", path, undefined, {});
         },
+        
+        setName: function(room_id, name) {
+            var data = {
+                name: name
+            };
+            return this.sendStateEvent(room_id, "m.room.name", data);
+        },
+        
+        setTopic: function(room_id, topic) {
+            var data = {
+                topic: topic
+            };
+            return this.sendStateEvent(room_id, "m.room.topic", data);
+        },
+        
+        
+        sendStateEvent: function(room_id, eventType, content, state_key) {
+            var path = "/rooms/$room_id/state/"+eventType;
+            if (state_key !== undefined) {
+                path += "/" + state_key;
+            }
+            room_id = encodeURIComponent(room_id);
+            path = path.replace("$room_id", room_id);
+
+            return doRequest("PUT", path, undefined, content);
+        },
 
         sendEvent: function(room_id, eventType, txn_id, content) {
             // The REST path spec