summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--docs/client-server/OLD_specification.rst2
-rw-r--r--docs/implementation-notes/architecture.rst68
-rw-r--r--docs/implementation-notes/python_architecture.rst6
-rw-r--r--synapse/http/content_repository.py8
-rw-r--r--syweb/webclient/app-controller.js8
-rwxr-xr-xsyweb/webclient/app.css169
-rw-r--r--syweb/webclient/components/matrix/event-handler-service.js19
-rw-r--r--syweb/webclient/components/matrix/matrix-call.js151
-rw-r--r--syweb/webclient/components/matrix/matrix-filter.js4
-rw-r--r--syweb/webclient/img/attach.pngbin0 -> 473 bytes
-rw-r--r--syweb/webclient/img/settings.pngbin0 -> 864 bytes
-rw-r--r--syweb/webclient/img/video.pngbin0 -> 604 bytes
-rw-r--r--syweb/webclient/img/voice.pngbin0 -> 659 bytes
-rw-r--r--syweb/webclient/index.html6
-rw-r--r--syweb/webclient/js/angular-peity.js69
-rw-r--r--syweb/webclient/js/jquery.peity.min.js13
-rw-r--r--syweb/webclient/mobile.css18
-rw-r--r--syweb/webclient/room/room-controller.js23
-rw-r--r--syweb/webclient/room/room.html131
-rw-r--r--syweb/webclient/test/karma.conf.js2
20 files changed, 513 insertions, 184 deletions
diff --git a/docs/client-server/OLD_specification.rst b/docs/client-server/OLD_specification.rst
index 47fba5eeac..425ae57d93 100644
--- a/docs/client-server/OLD_specification.rst
+++ b/docs/client-server/OLD_specification.rst
@@ -4,7 +4,7 @@ Matrix Client-Server API
 
 
 .. WARNING::
-  This specification is old. Please see /docs/specification.rst instead.
+  This specification is old. Please see matrix-doc/specification instead.
 
 
 
diff --git a/docs/implementation-notes/architecture.rst b/docs/implementation-notes/architecture.rst
new file mode 100644
index 0000000000..98050428b9
--- /dev/null
+++ b/docs/implementation-notes/architecture.rst
@@ -0,0 +1,68 @@
+Synapse Architecture
+====================
+
+As of the end of Oct 2014, Synapse's overall architecture looks like::
+
+        synapse
+        .-----------------------------------------------------.
+        |                          Notifier                   |
+        |                            ^  |                     |
+        |                            |  |                     |
+        |                  .------------|------.              |
+        |                  | handlers/  |      |              |
+        |                  |            v      |              |
+        |                  | Event*Handler <--------> rest/* <=> Client
+        |                  | Rooms*Handler     |              |
+  HSes <=> federation/* <==> FederationHandler |              |
+        |      |           | PresenceHandler   |              |
+        |      |           | TypingHandler     |              |
+        |      |           '-------------------'              |
+        |      |                 |     |                      |
+        |      |              state/*  |                      |
+        |      |                 |     |                      |
+        |      |                 v     v                      |
+        |      `--------------> storage/*                     |
+        |                          |                          |
+        '--------------------------|--------------------------'
+                                   v
+                                .----.
+                                | DB |
+                                '----'
+
+* Handlers: business logic of synapse itself.  Follows a set contract of BaseHandler:
+
+  - BaseHandler gives us onNewRoomEvent which: (TODO: flesh this out and make it less cryptic):
+ 
+    + handle_state(event)
+    + auth(event)
+    + persist_event(event)
+    + notify notifier or federation(event)
+   
+  - PresenceHandler: use distributor to get EDUs out of Federation.  Very
+    lightweight logic built on the distributor
+  - TypingHandler: use distributor to get EDUs out of Federation.  Very
+    lightweight logic built on the distributor
+  - EventsHandler: handles the events stream...
+  - FederationHandler: - gets PDU from Federation Layer; turns into an event;
+    follows basehandler functionality.
+  - RoomsHandler: does all the room logic, including members - lots of classes in
+    RoomsHandler.
+  - ProfileHandler: talks to the storage to store/retrieve profile info.
+
+* EventFactory: generates events of particular event types.
+* Notifier: Backs the events handler
+* REST: Interfaces handlers and events to the outside world via HTTP/JSON.
+  Converts events back and forth from JSON.
+* Federation: holds the HTTP client & server to talk to other servers.  Does
+  replication to make sure there's nothing missing in the graph.  Handles
+  reliability.  Handles txns.
+* Distributor: generic event bus. used for presence & typing only currently. 
+  Notifier could be implemented using Distributor - so far we are only using for
+  things which actually /require/ dynamic pluggability however as it can
+  obfuscate the actual flow of control.
+* Auth: helper singleton to say whether a given event is allowed to do a given
+  thing  (TODO: put this on the diagram)
+* State: helper singleton: does state conflict resolution. You give it an event
+  and it tells you if it actually updates the state or not, and annotates the
+  event up properly and handles merge conflict resolution.
+* Storage: abstracts the storage engine.
diff --git a/docs/implementation-notes/python_architecture.rst b/docs/implementation-notes/python_architecture.rst
index 8beaa615d0..2a5a2613c4 100644
--- a/docs/implementation-notes/python_architecture.rst
+++ b/docs/implementation-notes/python_architecture.rst
@@ -1,3 +1,9 @@
+.. WARNING::
+  These architecture notes are spectacularly old, and date back to when Synapse 
+  was just federation code in isolation.  This should be merged into the main
+  spec.
+  
+
 = Server to Server =
 
 == Server to Server Stack ==
diff --git a/synapse/http/content_repository.py b/synapse/http/content_repository.py
index 3159ffff0a..1306b35271 100644
--- a/synapse/http/content_repository.py
+++ b/synapse/http/content_repository.py
@@ -129,6 +129,14 @@ class ContentRepoResource(resource.Resource):
             logger.info("Sending file %s", file_path)
             f = open(file_path, 'rb')
             request.setHeader('Content-Type', content_type)
+
+            # cache for at least a day.
+            # XXX: we might want to turn this off for data we don't want to recommend
+            # caching as it's sensitive or private - or at least select private.
+            # don't bother setting Expires as all our matrix clients are smart enough to
+            # be happy with Cache-Control (right?)
+            request.setHeader('Cache-Control', 'public,max-age=86400,s-maxage=86400')
+            
             d = FileSender().beginFileTransfer(f, request)
 
             # after the file has been sent, clean up and finish the request
diff --git a/syweb/webclient/app-controller.js b/syweb/webclient/app-controller.js
index 2d82a42cf8..bbcf4ab5f6 100644
--- a/syweb/webclient/app-controller.js
+++ b/syweb/webclient/app-controller.js
@@ -112,8 +112,8 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
         if (!$rootScope.currentCall) {
             // This causes the still frame to be flushed out of the video elements,
             // avoiding a flash of the last frame of the previous call when starting the next
-            angular.element('#localVideo')[0].load();
-            angular.element('#remoteVideo')[0].load();
+            if (angular.element('#localVideo')[0].load) angular.element('#localVideo')[0].load();
+            if (angular.element('#remoteVideo')[0].load) angular.element('#remoteVideo')[0].load();
             return;
         }
 
@@ -187,8 +187,8 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
         }
         call.onError = $scope.onCallError;
         call.onHangup = $scope.onCallHangup;
-        call.localVideoElement = angular.element('#localVideo')[0];
-        call.remoteVideoElement = angular.element('#remoteVideo')[0];
+        call.localVideoSelector  = '#localVideo';
+        call.remoteVideoSelector  = '#remoteVideo';
         $rootScope.currentCall = call;
     });
 
diff --git a/syweb/webclient/app.css b/syweb/webclient/app.css
index 5ab8e2b8fd..23ec42f128 100755
--- a/syweb/webclient/app.css
+++ b/syweb/webclient/app.css
@@ -136,17 +136,17 @@ textarea, input {
     transition: left linear 500ms, top linear 500ms, width linear 500ms, height linear 500ms;
 }
 
-#localVideo.mini {
+.mini #localVideo {
     top: 0px;
     left: 130px;
 }
 
-#localVideo.large {
+.large #localVideo {
     top: 70px;
     left: 20px;
 }
 
-#localVideo.ended {
+.ended #localVideo {
     -webkit-filter: grayscale(1);
     filter: grayscale(1);
 }
@@ -157,19 +157,19 @@ textarea, input {
     transition: left linear 500ms, top linear 500ms, width linear 500ms, height linear 500ms;
 }
 
-#remoteVideo.mini {
+.mini #remoteVideo {
     left: 260px;
     top: 0px;
     width: 128px;
 }
 
-#remoteVideo.large {
+.large #remoteVideo {
     left: 0px;
     top: 50px;
     width: 100%;
 }
 
-#remoteVideo.ended {
+.ended #remoteVideo {
     -webkit-filter: grayscale(1);
     filter: grayscale(1);
 }
@@ -318,7 +318,7 @@ textarea, input {
     position: absolute;
     bottom: 0px;
     width: 100%;
-    height: 100px;
+    height: 70px;
     background-color: #f8f8f8;
     border-top: #aaa 1px solid;
 }
@@ -326,7 +326,9 @@ textarea, input {
 #controls {
     max-width: 1280px;
     padding: 12px;
+    padding-right: 42px;
     margin: auto;
+    position: relative;
 }
 
 #buttonsCell {
@@ -343,7 +345,19 @@ textarea, input {
 
 #mainInput {
     width: 100%;
-    resize: none;
+    padding: 5px;
+    resize: vertical;
+}
+
+#attachButton {
+    position: absolute;
+    cursor: pointer;
+    margin-top: 3px;
+    right: 0px;
+    background: url('img/attach.png');
+    width: 25px;
+    height: 25px;
+    border: 0px;
 }
 
 .blink {
@@ -415,7 +429,8 @@ textarea, input {
 .roomHeaderInfo {
     text-align: right;
     float: right;
-    margin-top: 15px;
+    margin-top: 0px;
+    margin-right: 30px;
 }
 
 /*** Room Info Dialog ***/
@@ -449,15 +464,33 @@ textarea, input {
     resize: vertical;
 }
 
+/*** Control Buttons ***/
+#controlButtons {
+    float: right;
+    margin-right: -4px;
+    padding-bottom: 6px;
+}
+
+.controlButton {
+    cursor: pointer;
+    border: 0px;
+    width: 30px;
+    height: 30px;
+    margin-left: 3px;
+    margin-right: 3px;
+}
+
 /*** Participant list ***/
 
 #usersTableWrapper {
     float: right;
-    width: 120px;
+    clear: right;
+    width: 100px;
     height: 100%;
     overflow-y: auto;
 }
 
+/*
 #usersTable {
     width: 100%;
     border-collapse: collapse;
@@ -473,36 +506,66 @@ textarea, input {
     position: relative;
     background-color: #000;
 }
+*/
 
-.userAvatar .userAvatarImage {
-    position: absolute;
-    top: 0px;
+.userAvatar {
+}
+
+.userAvatarFrame {
+    border-radius: 46px;
+    width: 80px;
+    margin: auto;
+    position: relative;
+    border: 3px solid #aaa;
+    background-color: #aaa;
+}
+
+.userAvatarImage {
+    border-radius: 40px;
+    text-align: center;
     object-fit: cover;
-    width: 100%;
+    display: block;
 }
 
+/*
 .userAvatar .userAvatarGradient {
     position: absolute;
     bottom: 20px;
     width: 100%;
 }
+*/
 
-.userAvatar .userName {
-    position: absolute;
-    color: #fff;
-    margin: 2px;
-    bottom: 0px;
+.userName {
+    margin-top: 3px;
+    margin-bottom: 6px;
+    text-align: center;
     font-size: 12px;
-    word-break: break-all;
+    word-wrap: break-word;
+}
+
+.userPowerLevel {
+    position: absolute;
+    bottom: -1px;
+    height: 1px;
+    background-color: #f00;
 }
 
-.userAvatar .userPowerLevel {
+.userPowerLevelBar {
+    display: inline;
     position: absolute;
+    width: 2px;
+    height: 10px;
+/*    border: 1px solid #000;
+*/    background-color: #aaa;
+}
+
+.userPowerLevelMeter {
+    position: relative;
     bottom: 0px;
-    height: 2px;
     background-color: #f00;
 }
 
+/*
 .userPresence {
     text-align: center;
     font-size: 12px;
@@ -510,12 +573,15 @@ textarea, input {
     background-color: #aaa;
     border-bottom: 1px #ddd solid;
 }
+*/
 
 .online {
+    border-color: #38AF00;
     background-color: #38AF00;
 }
 
 .unavailable {
+    border-color: #FFCC00;
     background-color: #FFCC00;
 }
 
@@ -538,18 +604,21 @@ textarea, input {
         
 #messageTable td {
     padding: 0px;
+/*    border: 1px solid #888; */
 }
 
 .leftBlock {
-    width: 14em;
+    width: 7em;
     word-wrap: break-word;
     vertical-align: top;
     background-color: #fff;
-    color: #888;
+    color: #aaa;
     font-weight: medium;
     font-size: 12px;
     text-align: right;
+/*
     border-top: 1px #ddd solid;
+*/
 }
 
 .rightBlock {
@@ -560,13 +629,24 @@ textarea, input {
 }        
 
 .sender, .timestamp {
-    padding-right: 1em;
-    padding-left: 1em;
-    padding-top: 3px;
+/*    padding-top: 3px;
+*/}
+
+.timestamp {
+    font-size: 10px;
+    color: #ccc;
+    height: 13px;
+    margin-top: 4px;
+*/    transition-property: opacity;
+    transition-duration: 0.3s;
 }
 
 .sender {
-    margin-bottom: -3px;
+    font-size: 12px;
+/*    
+    margin-top: 5px;
+    margin-bottom: -9px;
+*/
 }
 
 .avatar {
@@ -577,7 +657,11 @@ textarea, input {
 }
 
 .avatarImage {
+    position: relative;
+    top: 5px;
     object-fit: cover;
+    border-radius: 32px;
+    margin-top: 4px;
 }
         
 .emote {
@@ -591,6 +675,7 @@ textarea, input {
 }
 
 .image {
+    border: 1px solid #888;
     display: block;
     max-width:320px;
     max-height:320px;
@@ -603,19 +688,23 @@ textarea, input {
 }
 
 .bubble {
+/*
     background-color: #eee;
     border: 1px solid #d8d8d8;
-    display: inline-block;
     margin-bottom: -1px;
-    max-width: 90%;
-    font-size: 14px;
-    word-wrap: break-word;
     padding-top: 7px;
     padding-bottom: 5px;
+    -webkit-text-size-adjust:100%
+    vertical-align: middle;
+*/
+    display: inline-block;
+    max-width: 90%;
     padding-left: 1em;
     padding-right: 1em;
-    vertical-align: middle;
-    -webkit-text-size-adjust:100%
+    padding-top: 2px;
+    padding-bottom: 2px;
+    font-size: 14px;
+    word-wrap: break-word;
 }
 
 .bubble img {
@@ -623,8 +712,8 @@ textarea, input {
     max-height: auto;
 }
 
-.differentUser td {
-    padding-bottom: 5px ! important;
+.differentUser .msg {
+    padding-top: 14px ! important;
 }
 
 .mine {
@@ -635,13 +724,15 @@ textarea, input {
 .text.membership .bubble,
 .mine .text.emote .bubble,
 .mine .text.membership .bubble
-  {
+{
     background-color: transparent ! important;    
     border: 0px ! important;
 }
 
 .mine .text .bubble {
+/*
     background-color: #f8f8ff ! important;    
+*/
     text-align: left ! important;
 }
 
@@ -701,6 +792,8 @@ textarea, input {
     overflow: hidden;
     white-space: nowrap;
     text-overflow: ellipsis;
+    padding-left: 0.5em;
+    padding-right: 0.5em;
 }
 
 .recentsRoom {
@@ -751,7 +844,7 @@ textarea, input {
     padding-right: 10px;
     margin-right: 10px;
     height: 100%;
-    border-right: 1px solid #ddd;
+/*    border-right: 1px solid #ddd; */
     overflow-y: auto;
 }
 
diff --git a/syweb/webclient/components/matrix/event-handler-service.js b/syweb/webclient/components/matrix/event-handler-service.js
index a9c6eb34c7..f51031f4cd 100644
--- a/syweb/webclient/components/matrix/event-handler-service.js
+++ b/syweb/webclient/components/matrix/event-handler-service.js
@@ -299,10 +299,12 @@ function(matrixService, $rootScope, $q, $timeout, $filter, mPresence, notificati
      * Return the display name of an user acccording to data already downloaded
      * @param {String} room_id the room id
      * @param {String} user_id the id of the user
+     * @param {boolean} wrap whether to insert whitespace into the userid (if displayname not available) to help it wrap
      * @returns {String} the user displayname or user_id if not available
      */
-    var getUserDisplayName = function(room_id, user_id) {
+    var getUserDisplayName = function(room_id, user_id, wrap) {
         var displayName;
+        // XXX: this is getting called *way* too often - at least once per every room member per every digest...
 
         // Get the user display name from the member list of the room
         var member = modelService.getMember(room_id, user_id);
@@ -336,8 +338,16 @@ function(matrixService, $rootScope, $q, $timeout, $filter, mPresence, notificati
 
         if (undefined === displayName) {
             // By default, use the user ID
-            displayName = user_id;
+            if (wrap && user_id.indexOf(':') >= 0) {
+                displayName = user_id.substr(0, user_id.indexOf(':')) + " " + user_id.substr(user_id.indexOf(':'));
+            }
+            else {
+                displayName = user_id;
+            }
         }
+        
+        //console.log("getUserDisplayName(" + room_id + ", " + user_id + ", " + wrap +") = " + displayName);
+        
         return displayName;
     };
 
@@ -589,10 +599,11 @@ function(matrixService, $rootScope, $q, $timeout, $filter, mPresence, notificati
          * Return the display name of an user acccording to data already downloaded
          * @param {String} room_id the room id
          * @param {String} user_id the id of the user
+         * @param {boolean} wrap whether to insert whitespace into the userid (if displayname not available) to help it wrap
          * @returns {String} the user displayname or user_id if not available
          */
-        getUserDisplayName: function(room_id, user_id) {
-            return getUserDisplayName(room_id, user_id);
+        getUserDisplayName: function(room_id, user_id, wrap) {
+            return getUserDisplayName(room_id, user_id, wrap);
         }
     };
 }]);
diff --git a/syweb/webclient/components/matrix/matrix-call.js b/syweb/webclient/components/matrix/matrix-call.js
index c13083298e..b560cf7daa 100644
--- a/syweb/webclient/components/matrix/matrix-call.js
+++ b/syweb/webclient/components/matrix/matrix-call.js
@@ -35,14 +35,14 @@ 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', 'modelService', '$rootScope', '$timeout', function MatrixCallFactory(matrixService, matrixPhoneService, modelService, $rootScope, $timeout) {
     $rootScope.isWebRTCSupported = function () {
+        navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
+        window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection; // but not mozRTCPeerConnection because its interface is not compatible
+        window.RTCSessionDescription = window.RTCSessionDescription || window.webkitRTCSessionDescription || window.mozRTCSessionDescription;
+        window.RTCIceCandidate = window.RTCIceCandidate || window.webkitRTCIceCandidate || window.mozRTCIceCandidate;
+
         return !!(navigator.getUserMedia || window.RTCPeerConnection || window.RTCSessionDescription || window.RTCIceCandidate);
     };
 
@@ -57,7 +57,7 @@ angular.module('MatrixCall', [])
         this.candidateSendTries = 0;
 
         var self = this;
-        $rootScope.$watch(this.remoteVideoElement, function (oldValue, newValue) {
+        $rootScope.$watch(this.getRemoteVideoElement(), function (oldValue, newValue) {
             self.tryPlayRemoteStream();
         });
 
@@ -175,7 +175,8 @@ angular.module('MatrixCall', [])
         this.state = 'ringing';
         this.direction = 'inbound';
 
-        if (window.mozRTCPeerConnection) {
+        // This also applied to the Safari OpenWebRTC extension so let's just do this all the time at least for now
+        //if (window.mozRTCPeerConnection) {
             // firefox's RTCPeerConnection doesn't add streams until it starts getting media on them
             // so we need to figure out whether a video channel has been offered by ourselves.
             if (this.msg.offer.sdp.indexOf('m=video') > -1) {
@@ -183,7 +184,7 @@ angular.module('MatrixCall', [])
             } else {
                 this.type = 'voice';
             }
-        }
+        //}
 
         var self = this;
         $timeout(function() {
@@ -251,8 +252,8 @@ angular.module('MatrixCall', [])
 
         // pausing now keeps the last frame (ish) of the video call in the video element
         // rather than it just turning black straight away
-        if (this.remoteVideoElement) this.remoteVideoElement.pause();
-        if (this.localVideoElement) this.localVideoElement.pause();
+        if (this.getRemoteVideoElement() && this.getRemoteVideoElement().pause) this.getRemoteVideoElement().pause();
+        if (this.getLocalVideoElement() && this.getLocalVideoElement().pause) this.getLocalVideoElement().pause();
 
         this.stopAllMedia();
         if (this.peerConn) this.peerConn.close();
@@ -277,11 +278,18 @@ angular.module('MatrixCall', [])
         }
         if (this.state == 'ended') return;
 
-        if (this.localVideoElement && this.type == 'video') {
+        var videoEl = this.getLocalVideoElement();
+
+        if (videoEl && this.type == 'video') {
             var vidTrack = stream.getVideoTracks()[0];
-            this.localVideoElement.src = URL.createObjectURL(stream);
-            this.localVideoElement.muted = true;
-            this.localVideoElement.play();
+            videoEl.autoplay = true;
+            videoEl.src = URL.createObjectURL(stream);
+            videoEl.muted = true;
+            var self = this;
+            $timeout(function() {
+                var vel = self.getLocalVideoElement();
+                if (vel.play) vel.play();
+            });
         }
 
         this.localAVStream = stream;
@@ -305,11 +313,18 @@ angular.module('MatrixCall', [])
     MatrixCall.prototype.gotUserMediaForAnswer = function(stream) {
         if (this.state == 'ended') return;
 
-        if (this.localVideoElement && this.type == 'video') {
+        var localVidEl = this.getLocalVideoElement();
+
+        if (localVidEl && this.type == 'video') {
+            localVidEl.autoplay = true;
             var vidTrack = stream.getVideoTracks()[0];
-            this.localVideoElement.src = URL.createObjectURL(stream);
-            this.localVideoElement.muted = true;
-            this.localVideoElement.play();
+            localVidEl.src = URL.createObjectURL(stream);
+            localVidEl.muted = true;
+            var self = this;
+            $timeout(function() {
+                var vel = self.getLocalVideoElement();
+                if (vel.play) vel.play();
+            });
         }
 
         this.localAVStream = stream;
@@ -338,11 +353,11 @@ angular.module('MatrixCall', [])
     }
 
     MatrixCall.prototype.gotRemoteIceCandidate = function(cand) {
-        console.log("Got remote ICE "+cand.sdpMid+" candidate: "+cand.candidate);
         if (this.state == 'ended') {
-            console.log("Ignoring remote ICE candidate because call has ended");
+            //console.log("Ignoring remote ICE candidate because call has ended");
             return;
         }
+        console.log("Got remote ICE "+cand.sdpMid+" candidate: "+cand.candidate);
         this.peerConn.addIceCandidate(new RTCIceCandidate(cand), function() {}, function(e) {});
     };
 
@@ -362,41 +377,46 @@ angular.module('MatrixCall', [])
             return;
         }
 
-        this.peerConn.setLocalDescription(description);
-
-        var content = {
-            version: 0,
-            call_id: this.call_id,
-            offer: description,
-            lifetime: MatrixCall.CALL_TIMEOUT
-        };
-        this.sendEventWithRetry('m.call.invite', content);
-
         var self = this;
-        $timeout(function() {
-            if (self.state == 'invite_sent') {
-                self.hangup('invite_timeout');
-            }
-        }, MatrixCall.CALL_TIMEOUT);
+        this.peerConn.setLocalDescription(description, function() {
+            var content = {
+                version: 0,
+                call_id: self.call_id,
+                // OpenWebRTC appears to add extra stuff (like the DTLS fingerprint) to the description
+                // when setting it on the peerconnection. According to the spec it should only add ICE
+                // candidates. Any ICE candidates that have already been generated at this point will
+                // probably be sent both in the offer and separately. Ho hum.
+                offer: self.peerConn.localDescription,
+                lifetime: MatrixCall.CALL_TIMEOUT
+            };
+            self.sendEventWithRetry('m.call.invite', content);
+
+            $timeout(function() {
+                if (self.state == 'invite_sent') {
+                    self.hangup('invite_timeout');
+                }
+            }, MatrixCall.CALL_TIMEOUT);
 
-        $rootScope.$apply(function() {
-            self.state = 'invite_sent';
-        });
+            $rootScope.$apply(function() {
+                self.state = 'invite_sent';
+            });
+        }, function() { console.log("Error setting local description!"); });
     };
 
     MatrixCall.prototype.createdAnswer = function(description) {
         console.log("Created answer: "+description);
-        this.peerConn.setLocalDescription(description);
-        var content = {
-            version: 0,
-            call_id: this.call_id,
-            answer: description
-        };
-        this.sendEventWithRetry('m.call.answer', content);
         var self = this;
-        $rootScope.$apply(function() {
-            self.state = 'connecting';
-        });
+        this.peerConn.setLocalDescription(description, function() {
+            var content = {
+                version: 0,
+                call_id: self.call_id,
+                answer: self.peerConn.localDescription
+            };
+            self.sendEventWithRetry('m.call.answer', content);
+            $rootScope.$apply(function() {
+                self.state = 'connecting';
+            });
+        }, function() { console.log("Error setting local description!"); } );
     };
 
     MatrixCall.prototype.getLocalOfferFailed = function(error) {
@@ -464,10 +484,15 @@ angular.module('MatrixCall', [])
     };
 
     MatrixCall.prototype.tryPlayRemoteStream = function(event) {
-        if (this.remoteVideoElement && this.remoteAVStream) {
-            var player = this.remoteVideoElement;
+        if (this.getRemoteVideoElement() && this.remoteAVStream) {
+            var player = this.getRemoteVideoElement();
+            player.autoplay = true;
             player.src = URL.createObjectURL(this.remoteAVStream);
-            player.play();
+            var self = this;
+            $timeout(function() {
+                var vel = self.getRemoteVideoElement();
+                if (vel.play) vel.play();
+            });
         }
     };
 
@@ -499,8 +524,8 @@ angular.module('MatrixCall', [])
 
     MatrixCall.prototype.onHangupReceived = function(msg) {
         console.log("Hangup received");
-        if (this.remoteVideoElement) this.remoteVideoElement.pause();
-        if (this.localVideoElement) this.localVideoElement.pause();
+        if (this.getRemoteVideoElement() && this.getRemoteVideoElement().pause) this.getRemoteVideoElement().pause();
+        if (this.getLocalVideoElement() && this.getLocalVideoElement().pause) this.getLocalVideoElement().pause();
         this.state = 'ended';
         this.hangupParty = 'remote';
         this.hangupReason = msg.reason;
@@ -523,8 +548,8 @@ angular.module('MatrixCall', [])
             newCall.gotUserMediaForAnswer(this.localAVStream);
             delete(this.localAVStream);
         }
-        newCall.localVideoElement = this.localVideoElement;
-        newCall.remoteVideoElement = this.remoteVideoElement;
+        newCall.localVideoSelector = this.localVideoSelector;
+        newCall.remoteVideoSelector = this.remoteVideoSelector;
         this.successor = newCall;
         this.hangup(true);
     };
@@ -600,5 +625,21 @@ angular.module('MatrixCall', [])
         }, delayMs);
     };
 
+    MatrixCall.prototype.getLocalVideoElement = function() {
+        if (this.localVideoSelector) {
+            var t = angular.element(this.localVideoSelector);
+            if (t.length) return t[0];
+        }
+        return null;
+    };
+
+    MatrixCall.prototype.getRemoteVideoElement = function() {
+        if (this.remoteVideoSelector) {
+            var t = angular.element(this.remoteVideoSelector);
+            if (t.length) return t[0];
+        }
+        return null;
+    };
+
     return MatrixCall;
 }]);
diff --git a/syweb/webclient/components/matrix/matrix-filter.js b/syweb/webclient/components/matrix/matrix-filter.js
index aeebedc784..69de97b055 100644
--- a/syweb/webclient/components/matrix/matrix-filter.js
+++ b/syweb/webclient/components/matrix/matrix-filter.js
@@ -114,7 +114,7 @@ function($rootScope, matrixService, eventHandlerService, modelService) {
 
 // Return the user display name
 .filter('mUserDisplayName', ['eventHandlerService', function(eventHandlerService) {
-    return function(user_id, room_id) {
-        return eventHandlerService.getUserDisplayName(room_id, user_id);
+    return function(user_id, room_id, wrap) {
+        return eventHandlerService.getUserDisplayName(room_id, user_id, wrap);
     };
 }]);
diff --git a/syweb/webclient/img/attach.png b/syweb/webclient/img/attach.png
new file mode 100644
index 0000000000..d95eabaf00
--- /dev/null
+++ b/syweb/webclient/img/attach.png
Binary files differdiff --git a/syweb/webclient/img/settings.png b/syweb/webclient/img/settings.png
new file mode 100644
index 0000000000..ac99fe402b
--- /dev/null
+++ b/syweb/webclient/img/settings.png
Binary files differdiff --git a/syweb/webclient/img/video.png b/syweb/webclient/img/video.png
new file mode 100644
index 0000000000..e90afea0c1
--- /dev/null
+++ b/syweb/webclient/img/video.png
Binary files differdiff --git a/syweb/webclient/img/voice.png b/syweb/webclient/img/voice.png
new file mode 100644
index 0000000000..fe464999c0
--- /dev/null
+++ b/syweb/webclient/img/voice.png
Binary files differdiff --git a/syweb/webclient/index.html b/syweb/webclient/index.html
index 992e8d3377..f6487f381d 100644
--- a/syweb/webclient/index.html
+++ b/syweb/webclient/index.html
@@ -17,6 +17,8 @@
     <script src="js/angular-route.min.js"></script>
     <script src="js/angular-sanitize.min.js"></script>
     <script src="js/angular-animate.min.js"></script>
+    <script src="js/jquery.peity.min.js"></script>
+    <script src="js/angular-peity.js"></script>
     <script type='text/javascript' src="js/ui-bootstrap-tpls-0.11.2.js"></script>
     <script type='text/javascript' src='js/ng-infinite-scroll-matrix.js'></script>
     <script type='text/javascript' src='js/autofill-event.js'></script>
@@ -53,8 +55,8 @@
     <div id="videoBackground" ng-class="videoMode">
         <div id="videoContainer" ng-class="videoMode">
             <div id="videoContainerPadding"></div>
-            <video id="localVideo" ng-class="[videoMode, currentCall.state]" ng-show="currentCall && currentCall.type == 'video' && (currentCall.state == 'connected' || currentCall.state == 'connecting' || currentCall.state == 'invite_sent' || currentCall.state == 'ended')"></video>
-            <video id="remoteVideo" ng-class="[videoMode, currentCall.state]" ng-show="currentCall && currentCall.type == 'video' && (currentCall.state == 'connected' || (currentCall.state == 'ended' && currentCall.didConnect))"></video>
+            <div ng-class="[videoMode, currentCall.state]" ng-show="currentCall && currentCall.type == 'video' && (currentCall.state == 'connected' || currentCall.state == 'connecting' || currentCall.state == 'invite_sent' || currentCall.state == 'ended')"><video id="localVideo"></video></div>
+            <div ng-class="[videoMode, currentCall.state]" ng-show="currentCall && currentCall.type == 'video' && (currentCall.state == 'connected' || (currentCall.state == 'ended' && currentCall.didConnect))"><video id="remoteVideo"></video></div>
         </div>
     </div>
 
diff --git a/syweb/webclient/js/angular-peity.js b/syweb/webclient/js/angular-peity.js
new file mode 100644
index 0000000000..2acb647d91
--- /dev/null
+++ b/syweb/webclient/js/angular-peity.js
@@ -0,0 +1,69 @@
+var angularPeity = angular.module( 'angular-peity', [] );
+
+$.fn.peity.defaults.pie = {
+  fill: ["#ff0000", "#aaaaaa"],
+  radius: 4,
+}
+
+var buildChartDirective = function ( chartType ) {
+	return {
+		restrict: 'E',
+		scope: {
+			data: "=",
+			options: "="
+		},
+		link: function ( scope, element, attrs ) {
+
+            var options = {};
+            if ( scope.options ) {
+				options = scope.options;
+            }
+            
+            // N.B. live-binding to data by Matthew
+            scope.$watch('data', function () {
+    			var span = document.createElement( 'span' );
+    			span.textContent = scope.data.join();
+
+                if ( !attrs.class ) {
+                    span.className = "";
+                } else {
+                    span.className = attrs.class;
+                }
+
+                if (element[0].nodeType === 8) {
+                    element.replaceWith( span );
+                }
+                else if (element[0].firstChild) {
+                    element.empty();
+                    element[0].appendChild( span );
+                }
+                else {
+                    element[0].appendChild( span );
+                }
+
+                jQuery( span ).peity( chartType, options );
+            });
+		}
+	};
+};
+
+
+angularPeity.directive( 'pieChart', function () {
+
+	return buildChartDirective( "pie" );
+
+} );
+
+
+angularPeity.directive( 'barChart', function () {
+
+	return buildChartDirective( "bar" );
+
+} );
+
+
+angularPeity.directive( 'lineChart', function () {
+
+	return buildChartDirective( "line" );
+
+} );
diff --git a/syweb/webclient/js/jquery.peity.min.js b/syweb/webclient/js/jquery.peity.min.js
new file mode 100644
index 0000000000..054b83c5d8
--- /dev/null
+++ b/syweb/webclient/js/jquery.peity.min.js
@@ -0,0 +1,13 @@
+// Peity jQuery plugin version 3.0.2
+// (c) 2014 Ben Pickles
+//
+// http://benpickles.github.io/peity
+//
+// Released under MIT license.
+(function(h,w,i,v){var p=function(a,b){var d=w.createElementNS("http://www.w3.org/2000/svg",a);h(d).attr(b);return d},y="createElementNS"in w&&p("svg",{}).createSVGRect,e=h.fn.peity=function(a,b){y&&this.each(function(){var d=h(this),c=d.data("peity");c?(a&&(c.type=a),h.extend(c.opts,b)):(c=new x(d,a,h.extend({},e.defaults[a],b)),d.change(function(){c.draw()}).data("peity",c));c.draw()});return this},x=function(a,b,d){this.$el=a;this.type=b;this.opts=d},r=x.prototype;r.draw=function(){e.graphers[this.type].call(this,
+this.opts)};r.fill=function(){var a=this.opts.fill;return h.isFunction(a)?a:function(b,d){return a[d%a.length]}};r.prepare=function(a,b){this.svg||this.$el.hide().after(this.svg=p("svg",{"class":"peity"}));return h(this.svg).empty().data("peity",this).attr({height:b,width:a})};r.values=function(){return h.map(this.$el.text().split(this.opts.delimiter),function(a){return parseFloat(a)})};e.defaults={};e.graphers={};e.register=function(a,b,d){this.defaults[a]=b;this.graphers[a]=d};e.register("pie",
+{fill:["#ff9900","#fff4dd","#ffc66e"],radius:8},function(a){if(!a.delimiter){var b=this.$el.text().match(/[^0-9\.]/);a.delimiter=b?b[0]:","}b=this.values();if("/"==a.delimiter)var d=b[0],b=[d,i.max(0,b[1]-d)];for(var c=0,d=b.length,n=0;c<d;c++)n+=b[c];for(var c=2*a.radius,f=this.prepare(a.width||c,a.height||c),c=f.width(),f=f.height(),s=c/2,k=f/2,f=i.min(s,k),a=a.innerRadius,e=i.PI,q=this.fill(),g=this.scale=function(a,b){var c=2*a/n*e-e/2;return[b*i.cos(c)+s,b*i.sin(c)+k]},l=0,c=0;c<d;c++){var t=
+b[c],j=t/n;if(0!=j){if(1==j)if(a)var j=s-0.01,o=k-f,m=k-a,j=p("path",{d:["M",s,o,"A",f,f,0,1,1,j,o,"L",j,m,"A",a,a,0,1,0,s,m].join(" ")});else j=p("circle",{cx:s,cy:k,r:f});else o=l+t,m=["M"].concat(g(l,f),"A",f,f,0,0.5<j?1:0,1,g(o,f),"L"),a?m=m.concat(g(o,a),"A",a,a,0,0.5<j?1:0,0,g(l,a)):m.push(s,k),l+=t,j=p("path",{d:m.join(" ")});h(j).attr("fill",q.call(this,t,c,b));this.svg.appendChild(j)}}});e.register("donut",h.extend(!0,{},e.defaults.pie),function(a){a.innerRadius||(a.innerRadius=0.5*a.radius);
+e.graphers.pie.call(this,a)});e.register("line",{delimiter:",",fill:"#c6d9fd",height:16,min:0,stroke:"#4d89f9",strokeWidth:1,width:32},function(a){var b=this.values();1==b.length&&b.push(b[0]);for(var d=i.max.apply(i,a.max==v?b:b.concat(a.max)),c=i.min.apply(i,a.min==v?b:b.concat(a.min)),n=this.prepare(a.width,a.height),f=a.strokeWidth,e=n.width(),k=n.height()-f,h=d-c,d=this.x=function(a){return a*(e/(b.length-1))},n=this.y=function(a){var b=k;h&&(b-=(a-c)/h*k);return b+f/2},q=n(i.max(c,0)),g=[0,
+q],l=0;l<b.length;l++)g.push(d(l),n(b[l]));g.push(e,q);this.svg.appendChild(p("polygon",{fill:a.fill,points:g.join(" ")}));f&&this.svg.appendChild(p("polyline",{fill:"transparent",points:g.slice(2,g.length-2).join(" "),stroke:a.stroke,"stroke-width":f,"stroke-linecap":"square"}))});e.register("bar",{delimiter:",",fill:["#4D89F9"],height:16,min:0,padding:0.1,width:32},function(a){for(var b=this.values(),d=i.max.apply(i,a.max==v?b:b.concat(a.max)),c=i.min.apply(i,a.min==v?b:b.concat(a.min)),e=this.prepare(a.width,
+a.height),f=e.width(),h=e.height(),k=d-c,a=a.padding,e=this.fill(),r=this.x=function(a){return a*f/b.length},q=this.y=function(a){return h-(k?(a-c)/k*h:1)},g=0;g<b.length;g++){var l=r(g+a),t=r(g+1-a)-l,j=b[g],o=q(j),m=o,u;k?0>j?m=q(i.min(d,0)):o=q(i.max(c,0)):u=1;u=o-m;0==u&&(u=1,0<d&&k&&m--);this.svg.appendChild(p("rect",{fill:e.call(this,j,g,b),x:l,y:m,width:t,height:u}))}})})(jQuery,document,Math);
diff --git a/syweb/webclient/mobile.css b/syweb/webclient/mobile.css
index 6fa9221ccf..32b01c503d 100644
--- a/syweb/webclient/mobile.css
+++ b/syweb/webclient/mobile.css
@@ -1,4 +1,13 @@
 /*** Mobile voodoo ***/
+
+/** iPads **/
+@media all and (max-device-width: 768px) {
+    #roomRecentsTableWrapper {
+        display: none;
+    }
+}
+
+/** iPhones **/
 @media all and (max-device-width: 640px) {
             
     #messageTableWrapper {
@@ -37,11 +46,16 @@
         max-width: 640px ! important;
     }    
     
+    #controls {
+        padding: 0px;
+    }
+    
     #headerUserId,
     #roomHeader img,
     #userIdCell,
     #roomRecentsTableWrapper,
     #usersTableWrapper,
+    #controlButtons,
     .extraControls {
         display: none;
     }
@@ -64,6 +78,10 @@
         padding-top: 10px;
     }
     
+    .roomHeaderInfo {
+        margin-right: 0px;
+    }
+    
     #roomName {
         font-size: 12px ! important;
         margin-top: 0px ! important;
diff --git a/syweb/webclient/room/room-controller.js b/syweb/webclient/room/room-controller.js
index d3fb85b9dc..be433d6e80 100644
--- a/syweb/webclient/room/room-controller.js
+++ b/syweb/webclient/room/room-controller.js
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
+angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput', 'angular-peity'])
 .controller('RoomController', ['$modal', '$filter', '$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'mPresence', 'eventHandlerService', 'mFileUpload', 'matrixPhoneService', 'MatrixCall', 'notificationService', 'modelService',
                                function($modal, $filter, $scope, $timeout, $routeParams, $location, $rootScope, matrixService, mPresence, eventHandlerService, mFileUpload, matrixPhoneService, MatrixCall, notificationService, modelService) {
    'use strict';
@@ -905,7 +905,20 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         paginate(MESSAGES_PER_PAGINATION);
     };
 
-    $scope.startVoiceCall = function() {
+    $scope.checkWebRTC = function() {
+        if (!$rootScope.isWebRTCSupported()) {
+            alert("Your browser does not support WebRTC");
+            return false;
+        }
+        if ($scope.memberCount() != 2) {
+            alert("WebRTC calls are currently only supported on rooms with two members");
+            return false;
+        }
+        return true;
+    };
+    
+    $scope.startVoiceCall = function() {        
+        if (!$scope.checkWebRTC()) return;
         var call = new MatrixCall($scope.room_id);
         call.onError = $rootScope.onCallError;
         call.onHangup = $rootScope.onCallHangup;
@@ -916,11 +929,13 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
     };
 
     $scope.startVideoCall = function() {
+        if (!$scope.checkWebRTC()) return;
+
         var call = new MatrixCall($scope.room_id);
         call.onError = $rootScope.onCallError;
         call.onHangup = $rootScope.onCallHangup;
-        call.localVideoElement = angular.element('#localVideo')[0];
-        call.remoteVideoElement = angular.element('#remoteVideo')[0];
+        call.localVideoSelector = '#localVideo';
+        call.remoteVideoSelector = '#remoteVideo';
         call.placeVideoCall();
         $rootScope.currentCall = call;
     };
diff --git a/syweb/webclient/room/room.html b/syweb/webclient/room/room.html
index e59cc30edc..430a37afd4 100644
--- a/syweb/webclient/room/room.html
+++ b/syweb/webclient/room/room.html
@@ -15,6 +15,15 @@
 
     <script type="text/ng-template" id="roomInfoTemplate.html">
         <div class="modal-body">
+            <span>
+               Invite a user: 
+                    <input ng-model="userIDToInvite" size="32" type="text" ng-enter="inviteUser()" ng-disabled="state.permission_denied" placeholder="User ID (ex:@user:homeserver)"/>     
+                    <button ng-click="inviteUser()" ng-disabled="state.permission_denied">Invite</button>
+            </span>
+            <br/>
+            <br/>
+            <button ng-click="leaveRoom()" ng-disabled="state.permission_denied">Leave Room</button>
+            </br/>
             <table class="room-info">
             <tr ng-repeat="(key, event) in roomInfo.stateEvents" class="room-info-event">
                 <td class="room-info-event-meta" width="30%">
@@ -57,6 +66,26 @@
 
     <div id="roomHeader">
         <a href ng-click="goToPage('/')"><img src="img/logo-small.png" width="100" height="43" alt="[matrix]"/></a>
+
+        <div id="controlButtons">
+            <button ng-click="startVoiceCall()" class="controlButton"
+                    style="background: url('img/voice.png')"
+                    ng-show="(currentCall == undefined || currentCall.state == 'ended')"
+                    ng-disabled="state.permission_denied"
+                    >
+            </button>
+            <button ng-click="startVideoCall()" class="controlButton"
+                    style="background: url('img/video.png')"
+                    ng-show="(currentCall == undefined || currentCall.state == 'ended')"
+                    ng-disabled="state.permission_denied"
+                    >
+            </button>
+            <button ng-click="openRoomInfo()" class="controlButton"
+                    style="background: url('img/settings.png')"
+                    >
+            </button>
+        </div>
+
         <div class="roomHeaderInfo">
 
             <div class="roomNameSection">
@@ -74,8 +103,8 @@
                     Set Topic
                 </button>
                 <div ng-show="room.current_room_state.state_events['m.room.topic'].content.topic || topic.isEditing">
-                    <div ng-hide="topic.isEditing" ng-dblclick="topic.editTopic()" id="roomTopic">
-                        {{ room.current_room_state.state_events['m.room.topic'].content.topic | limitTo: 200}}
+                    <div ng-hide="topic.isEditing" ng-dblclick="topic.editTopic()" id="roomTopic"
+                         ng-bind-html="room.current_room_state.state_events['m.room.topic'].content.topic | limitTo: 200 | linky:'_blank'">
                     </div>
                     <form ng-submit="topic.updateTopic()" ng-show="topic.isEditing" class="roomTopicForm">
                         <input ng-model="topic.newTopicText" ng-blur="topic.cancelEdit()" class="roomTopicInput"  placeholder="Topic"/>
@@ -91,32 +120,24 @@
     <div id="roomRecentsTableWrapper">
         <div ng-include="'recents/recents.html'"></div>
     </div>
-
+    
     <div id="usersTableWrapper" ng-hide="state.permission_denied">
-        <table id="usersTable">
-            <tr ng-repeat="member in members | orderMembersList">
-                <td class="userAvatar mouse-pointer" ng-click="$parent.goToUserPage(member.id)" ng-class="member.membership == 'invite' ? 'invited' : ''">
-                    <img class="userAvatarImage" 
-                         ng-src="{{member.avatar_url || 'img/default-profile.png'}}" 
-                         alt="{{ member.displayname || member.id.substr(0, member.id.indexOf(':')) }}"
-                         title="{{ member.id }} - power: {{ member.powerLevel }}"
-                         width="80" height="80"/>
-                    <img class="userAvatarGradient" src="img/gradient.png" title="{{ member.id }}" width="80" height="24"/>
-                    <div class="userPowerLevel" ng-style="{'width': member.powerLevelNorm +'%'}"></div>
-                    <div class="userName">
-                        <div ng-show="member.displayname">
-                            {{ member.id | mUserDisplayName: room_id }}
-                        </div>
-                        <div ng-hide="member.displayname">
-                            {{ member.id.substr(0, member.id.indexOf(':')) }}<br/>
-                            {{ member.id.substr(member.id.indexOf(':')) }}
-                        </div>
-                    </div>
-                </td>
-                <td class="userPresence" ng-class="(member.presence === 'online' ? 'online' : (member.presence === 'unavailable' ? 'unavailable' : '')) + ' ' + (member.membership == 'invite' ? 'invited' : '')">
-                    <span ng-show="member.last_active_ago">{{ member.last_active_ago + (now - member.last_updated) | duration }}<br/>ago</span>
-                </td>
-        </table>
+        <div ng-repeat="member in members | orderMembersList" class="userAvatar">
+            <div class="userAvatarFrame" ng-class="(member.presence === 'online' ? 'online' : (member.presence === 'unavailable' ? 'unavailable' : '')) + ' ' + (member.membership == 'invite' ? 'invited' : '')">
+                <img class="userAvatarImage mouse-pointer" 
+                     ng-click="$parent.goToUserPage(member.id)"
+                     ng-src="{{member.avatar_url || 'img/default-profile.png'}}" 
+                     alt="{{ member.displayname || member.id.substr(0, member.id.indexOf(':')) }}"
+                     title="{{ member.id }} - power: {{ member.powerLevel }}"
+                     width="80" height="80"/>
+                <!-- <div class="userPowerLevel" ng-style="{'width': member.powerLevelNorm +'%'}"></div> -->
+            </div>
+            <div class="userName">
+                <pie-chart ng-show="member.powerLevelNorm" data="[ (member.powerLevelNorm + 0), (100 - member.powerLevelNorm) ]"></pie-chart>
+                {{ member.id | mUserDisplayName:room_id:true }}
+                <span ng-show="member.last_active_ago" style="color: #aaa">({{ member.last_active_ago + (now - member.last_updated) | duration }})</span>
+            </div>
+        </div>
     </div>
     
     <div id="messageTableWrapper" 
@@ -125,20 +146,21 @@
          keep-scroll>
         <table id="messageTable" infinite-scroll="paginateMore()">
             <tr ng-repeat="msg in room.events"
-                ng-class="(room.events[$index + 1].user_id !== msg.user_id ? 'differentUser' : '') + (msg.user_id === state.user_id ? ' mine' : '')" scroll-item>
-                <td class="leftBlock">
-                    <div class="sender" ng-hide="room.events[$index - 1].user_id === msg.user_id"> {{ msg.__room_member.cnt.displayname || msg.user_id | mUserDisplayName: room_id }}</div>
+                ng-class="(room.events[$index - 1].user_id !== msg.user_id ? 'differentUser' : '') + (msg.user_id === state.user_id ? ' mine' : '')" scroll-item>
+                <td class="leftBlock" ng-mouseover="state.showTs = 1" ng-mouseout="state.showTs = 0">
                     <div class="timestamp"
+                         ng-style="{ 'opacity': state.showTs ? 1.0 : 0.0 }"
                          ng-class="msg.echo_msg_state">
                         {{ (msg.origin_server_ts) | date:'MMM d HH:mm' }}
                     </div>
+                    <div class="sender" ng-hide="room.events[$index - 1].user_id === msg.user_id || msg.user_id === state.user_id"> {{ msg.__room_member.cnt.displayname || msg.user_id | mUserDisplayName:room_id:true }}</div>
                 </td>
                 <td class="avatar">
                     <!-- msg.__room_member.avatar_url is just backwards compat, and can be removed in the future. -->
                     <img class="avatarImage" ng-src="{{ msg.__room_member.cnt.avatar_url || msg.__room_member.avatar_url || 'img/default-profile.png' }}" width="32" height="32" title="{{msg.user_id}}"
                          ng-hide="room.events[$index - 1].user_id === msg.user_id || msg.user_id === state.user_id"/>
                 </td>
-                <td ng-class="(!msg.content.membership && ('m.room.topic' !== msg.type && 'm.room.name' !== msg.type))? (msg.content.msgtype === 'm.emote' ? 'emote text' : 'text') : 'membership text'">
+                <td class="msg" ng-class="(!msg.content.membership && ('m.room.topic' !== msg.type && 'm.room.name' !== msg.type))? (msg.content.msgtype === 'm.emote' ? 'emote text' : 'text') : 'membership text'">
                     <div class="bubble" ng-dblclick="openJson(msg)">
                         <span ng-if="'join' === msg.content.membership && msg.changedKey === 'membership'">
                             {{ msg.content.displayname || members[msg.state_key].displayname || msg.state_key }} joined
@@ -222,49 +244,10 @@
 
     <div id="controlPanel">
         <div id="controls">
-            <table id="inputBarTable">
-                <tr>
-                    <td id="userIdCell" width="1px">
-                        {{ state.user_id }} 
-                    </td>
-                    <td width="*">
-                        <textarea id="mainInput" rows="1" ng-enter="send()"
-                                  ng-disabled="state.permission_denied"
-                                  ng-focus="true" autocomplete="off" tab-complete command-history/>
-                    </td>
-                    <td id="buttonsCell">
-                        <button ng-click="send()" ng-disabled="state.permission_denied">Send</button>
-                        <button m-file-input="imageFileToSend" class="extraControls" ng-disabled="state.permission_denied">Image</button>
-                    </td>
-                </tr>
-            </table>
-
-            <div class="extraControls">
-                <span>
-                   Invite a user: 
-                        <input ng-model="userIDToInvite" size="32" type="text" ng-enter="inviteUser()" ng-disabled="state.permission_denied" placeholder="User ID (ex:@user:homeserver)"/>     
-                        <button ng-click="inviteUser()" ng-disabled="state.permission_denied">Invite</button>
-                </span>
-                <button ng-click="leaveRoom()" ng-disabled="state.permission_denied">Leave</button>
-                <button ng-click="startVoiceCall()"
-                        ng-show="(currentCall == undefined || currentCall.state == 'ended')"
-                        ng-disabled="state.permission_denied || !isWebRTCSupported() || memberCount() != 2"
-                        title ="{{ !isWebRTCSupported() ? 'VoIP requires webRTC but your browser does not support it' : (memberCount() == 2 ? '' : 'VoIP calls can only be made in rooms with two participants') }}"
-                        >
-                    Voice Call
-                </button>
-                <button ng-click="startVideoCall()"
-                        ng-show="(currentCall == undefined || currentCall.state == 'ended')"
-                        ng-disabled="state.permission_denied || !isWebRTCSupported() || memberCount() != 2"
-                        title ="{{ !isWebRTCSupported() ? 'VoIP requires webRTC but your browser does not support it' : (memberCount() == 2 ? '' : 'VoIP calls can only be made in rooms with two participants') }}"
-                        >
-                    Video Call
-                </button>
-                <button ng-click="openRoomInfo()">
-                    Room Info
-                </button>
-            </div>
-        
+            <button id="attachButton" m-file-input="imageFileToSend" class="extraControls" ng-disabled="state.permission_denied"></button>
+            <textarea id="mainInput" rows="1" ng-enter="send()"
+                      ng-disabled="state.permission_denied"
+                      ng-focus="true" autocomplete="off" tab-complete command-history/>
             {{ feedback }}
             <div ng-show="state.stream_failure">
                 {{ state.stream_failure.data.error || "Connection failure" }}
diff --git a/syweb/webclient/test/karma.conf.js b/syweb/webclient/test/karma.conf.js
index 7ce958adc9..5f0642ca33 100644
--- a/syweb/webclient/test/karma.conf.js
+++ b/syweb/webclient/test/karma.conf.js
@@ -22,6 +22,8 @@ module.exports = function(config) {
       '../js/angular-route.js',
       '../js/angular-animate.js',
       '../js/angular-sanitize.js',
+      '../js/jquery.peity.min.js',
+      '../js/angular-peity.js',
       '../js/ng-infinite-scroll-matrix.js',
       '../js/ui-bootstrap*',
       '../js/elastic.js',