summary refs log tree commit diff
path: root/webclient
diff options
context:
space:
mode:
authorErik Johnston <erik@matrix.org>2014-08-26 13:55:37 +0100
committerErik Johnston <erik@matrix.org>2014-08-26 13:55:37 +0100
commit485bb64ddbcc372c5ed3d74190eda06f02cb3f82 (patch)
treee89ec33366b907bea794e9140f962d2951b29ff9 /webclient
parentUse new StreamToken in pagination config (diff)
parentAdd the ability to turn on the twisted manhole telnet service. (diff)
downloadsynapse-485bb64ddbcc372c5ed3d74190eda06f02cb3f82.tar.xz
Merge branch 'develop' of github.com:matrix-org/synapse into stream_refactor
Diffstat (limited to 'webclient')
-rw-r--r--webclient/app-controller.js28
-rw-r--r--webclient/app-filter.js7
-rw-r--r--webclient/app.css181
-rw-r--r--webclient/app.js17
-rw-r--r--webclient/components/fileUpload/file-upload-service.js144
-rw-r--r--webclient/components/matrix/event-handler-service.js30
-rw-r--r--webclient/components/matrix/event-stream-service.js51
-rw-r--r--webclient/components/matrix/matrix-service.js91
-rw-r--r--webclient/components/utilities/utilities-service.js110
-rw-r--r--webclient/home/home-controller.js162
-rw-r--r--webclient/home/home.html63
-rw-r--r--webclient/index.html20
-rw-r--r--webclient/login/login-controller.js6
-rw-r--r--webclient/login/login.html4
-rw-r--r--webclient/room/room-controller.js81
-rw-r--r--webclient/room/room-directive.js30
-rw-r--r--webclient/room/room.html61
-rw-r--r--webclient/rooms/rooms-controller.js264
-rw-r--r--webclient/rooms/rooms.html101
-rw-r--r--webclient/settings/settings-controller.js146
-rw-r--r--webclient/settings/settings.html73
-rw-r--r--webclient/user/user.html1
22 files changed, 1071 insertions, 600 deletions
diff --git a/webclient/app-controller.js b/webclient/app-controller.js
index 96656e12c3..84cb94dc74 100644
--- a/webclient/app-controller.js
+++ b/webclient/app-controller.js
@@ -31,31 +31,15 @@ angular.module('MatrixWebClientController', ['matrixService'])
     $rootScope.$on('$routeChangeSuccess', function (event, current, previous) {
         $scope.location = $location.path();
     });
-    
-    
-    // Manage the display of the current config
-    $scope.config;
-    
-    // Toggles the config display
-    $scope.showConfig = function() {
-        if ($scope.config) {
-            $scope.config = undefined;
-        }
-        else {
-            $scope.config = matrixService.config();        
-        }
-    };
-    
-    $scope.closeConfig = function() {
-        if ($scope.config) {
-            $scope.config = undefined;
-        }
-    };
 
     if (matrixService.isUserLoggedIn()) {
-        eventStreamService.resume();
+        // eventStreamService.resume();
     }
     
+    $scope.go = function(url) {
+        $location.url(url);
+    };
+    
     // Logs the user out 
     $scope.logout = function() {
         // kill the event stream
@@ -66,7 +50,7 @@ angular.module('MatrixWebClientController', ['matrixService'])
         matrixService.saveConfig();
         
         // And go to the login page
-        $location.path("login");
+        $location.url("login");
     };
 
     // Listen to the event indicating that the access token is no longer valid.
diff --git a/webclient/app-filter.js b/webclient/app-filter.js
index 64c3bb04de..b8f4ed25bc 100644
--- a/webclient/app-filter.js
+++ b/webclient/app-filter.js
@@ -54,12 +54,15 @@ angular.module('matrixWebClient')
         });
 
         // FIXME: we shouldn't disambiguate displayNames on every orderMembersList
-        // invocation but keep track of duplicates incrementally somewhere            
+        // invocation but keep track of duplicates incrementally somewhere
         angular.forEach(displayNames, function(value, key) {
             if (value.length > 1) {
                 // console.log(key + ": " + value);
-                for (i=0; i < value.length; i++) {
+                for (var i=0; i < value.length; i++) {
                     var v = value[i];
+                    // FIXME: this permenantly rewrites the displayname for a given
+                    // room member. which means we can't reset their name if it is
+                    // no longer ambiguous!
                     members[v].displayname += " (" + v + ")";
                     // console.log(v + " " + members[v]);
                 };
diff --git a/webclient/app.css b/webclient/app.css
index 869db69cd6..dfa17fae62 100644
--- a/webclient/app.css
+++ b/webclient/app.css
@@ -1,3 +1,71 @@
+/*** Mobile voodoo ***/
+@media all and (max-device-width: 640px) {
+            
+    #messageTableWrapper {
+        margin-right: 0px ! important;
+    }
+    
+    .leftBlock {
+        width: 8em ! important;
+    }
+    
+    #header,
+    #messageTable,
+    #wrapper,
+    #roomName,
+    #controls {
+        max-width: 640px ! important;
+    }    
+    
+    #userIdCell,
+    #usersTableWrapper,
+    #extraControls {
+        display: none;
+    }
+    
+    #buttonsCell {
+        width: 60px ! important;
+        padding-left: 20px ! important;
+    }
+    
+    #roomLogo {
+        display: none;
+    }
+    
+    #roomName {
+        text-align: left ! important;
+        top: -35px ! important;
+    }
+    
+    .bubble {
+        font-size: 12px ! important;
+        min-height: 20px ! important;
+    }
+    
+    #page {
+        top: 35px ! important;
+        bottom: 70px ! important;
+    }
+    
+    #header,
+    #page {
+        margin: 5px ! important;
+    }
+    
+    #header {
+        padding: 5px ! important;
+    }
+        
+    /* stop zoom on select */
+    select:focus,
+    textarea,
+    input
+    {
+        font-size: 16px ! important;
+    }
+    
+}
+
 body {
     font-family: "Myriad Pro", "Myriad", Helvetica, Arial, sans-serif;
     font-size: 12pt;
@@ -17,7 +85,6 @@ h1 {
     left: 0px;
     right: 0px;
     margin: 20px;
-    margin: 20px;
 }
 
 #wrapper {
@@ -32,8 +99,7 @@ h1 {
     text-align: right;
     top: -40px;
     position: absolute;
-    font-size: 16pt;
-    margin-bottom: 10px;
+    font-size: 16px;
 }
 
 #controlPanel {
@@ -50,6 +116,10 @@ h1 {
     margin: auto;
 }
 
+#buttonsCell {
+    width: 150px;
+}
+
 #inputBarTable {
     width: 100%;
 }
@@ -66,6 +136,10 @@ h1 {
     background-color: #faa;
 }
 
+.mouse-pointer {
+    cursor: pointer;
+}
+
 /*** Participant list ***/
 
 #usersTableWrapper {
@@ -89,7 +163,6 @@ h1 {
     height: 100px;
     position: relative;
     background-color: #000;
-    cursor: pointer;
 }
 
 .userAvatar .userAvatarImage {
@@ -108,13 +181,13 @@ h1 {
     color: #fff;
     margin: 2px;
     bottom: 0px;
-    font-size: 8pt;
+    font-size: 12px;
     word-break: break-all;
 }
 
 .userPresence {
     text-align: center;
-    font-size: 8pt;
+    font-size: 12px;
     color: #fff;
     background-color: #aaa;
     border-bottom: 1px #ddd solid;
@@ -142,6 +215,7 @@ h1 {
     max-width: 1280px;
     width: 100%;
     border-collapse: collapse;
+    table-layout: fixed;
 }
         
 #messageTable td {
@@ -149,12 +223,13 @@ h1 {
 }
 
 .leftBlock {
-    width: 10em;
+    width: 14em;
+    word-wrap: break-word;
     vertical-align: top;
     background-color: #fff;
     color: #888;
     font-weight: medium;
-    font-size: 8pt;
+    font-size: 12px;
     text-align: right;
     border-top: 1px #ddd solid;
 }
@@ -187,24 +262,13 @@ h1 {
     object-fit: cover;
 }
         
-.text {
-    background-color: #eee;
-    border: 1px solid #d8d8d8;
-    height: 31px;
-    display: inline-table;
-    max-width: 90%;
-    font-size: 16px;
-    /* word-wrap: break-word; */
-    word-break: break-all;
-}
-
 .emote {
-    background-color: #fff ! important;
+    background-color: transparent ! important;
     border: 0px ! important;
 }
 
 .membership {
-    background-color: #fff ! important;
+    background-color: transparent ! important;
     border: 0px ! important;
 }
 
@@ -216,33 +280,66 @@ h1 {
     height: auto;
 }
 
+.text {
+    vertical-align: top;
+}
+
 .bubble {
+    background-color: #eee;
+    border: 1px solid #d8d8d8;
+    display: inline-block;
+    margin-bottom: -1px;
+    max-width: 90%;
+    font-size: 16px;
+    word-wrap: break-word;
     padding-top: 7px;
     padding-bottom: 5px;
     padding-left: 1em;
     padding-right: 1em;
     vertical-align: middle;
+    -webkit-text-size-adjust:100%
 }
 
 .differentUser td {
-    padding-top: 5px ! important;
-    margin-top: 5px ! important;
+    padding-bottom: 5px ! important;
 }
 
 .mine {
     text-align: right;
 }
 
-.mine .text {
+.text.emote .bubble,
+.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;
 }
 
-.mine .emote {
-    background-color: #fff ! important;    
+#room-fullscreen-image {
+    position: absolute;
+    top: 0px;
+    height: 0px;
+    width: 100%;
+    height: 100%;
 }
 
-.mine .text .bubble {
-    text-align: left ! important;
+#room-fullscreen-image img {
+    max-width: 100%;
+    max-height: 100%;
+    bottom: 0;
+    left: 0;
+    margin: auto;
+    overflow: auto;
+    position: fixed;
+    right: 0;
+    top: 0;
 }
 
 /*** Profile ***/
@@ -250,7 +347,7 @@ h1 {
 .profile-avatar {
     width: 160px;
     height: 160px;
-    display:table-cell;
+    display: table-cell;
     vertical-align: middle;
     text-align: center;
 }
@@ -266,31 +363,25 @@ h1 {
 }
 
 #user-displayname {
-    font-size: 16pt;
+    font-size: 24px;
 }
 /******************************/
 
-#header {
-    padding-left: 20px;
-    padding-right: 20px;
+#header
+{
+    padding: 20px;
     max-width: 1280px;
     margin: auto;
 }
 
-#header-buttons {
-    float: right;
+#logo,
+#roomLogo {
+    max-width: 1280px;
+    margin: auto;
 }
 
-#config {
-    position: absolute;
-    z-index: 100;
-    top: 100px;
-    left: 50%;
-    width: 500px;
-    margin-left: -250px;
-    text-align: center;
-    padding: 20px;
-    background-color: #aaa;
+#header-buttons {
+    float: right;
 }
 
 .text_entry_section {
diff --git a/webclient/app.js b/webclient/app.js
index f27ebedc6f..6cd50c5e54 100644
--- a/webclient/app.js
+++ b/webclient/app.js
@@ -19,7 +19,8 @@ var matrixWebClient = angular.module('matrixWebClient', [
     'MatrixWebClientController',
     'LoginController',
     'RoomController',
-    'RoomsController',
+    'HomeController',
+    'SettingsController',
     'UserController',
     'matrixService',
     'eventStreamService',
@@ -44,16 +45,20 @@ matrixWebClient.config(['$routeProvider', '$provide', '$httpProvider',
                 templateUrl: 'room/room.html',
                 controller: 'RoomController'
             }).
-            when('/rooms', {
-                templateUrl: 'rooms/rooms.html',
-                controller: 'RoomsController'
+            when('/', {
+                templateUrl: 'home/home.html',
+                controller: 'HomeController'
+            }).
+            when('/settings', {
+                templateUrl: 'settings/settings.html',
+                controller: 'SettingsController'
             }).
             when('/user/:user_matrix_id', {
                 templateUrl: 'user/user.html',
                 controller: 'UserController'
             }).
             otherwise({
-                redirectTo: '/rooms'
+                redirectTo: '/'
             });
             
         $provide.factory('AccessTokenInterceptor', ['$q', '$rootScope', 
@@ -80,6 +85,6 @@ matrixWebClient.run(['$location', 'matrixService', 'eventStreamService', functio
         $location.path("login");
     }
     else {
-        eventStreamService.resume();
+        // eventStreamService.resume();
     }
 }]);
diff --git a/webclient/components/fileUpload/file-upload-service.js b/webclient/components/fileUpload/file-upload-service.js
index 65c24f309c..5f01478fd1 100644
--- a/webclient/components/fileUpload/file-upload-service.js
+++ b/webclient/components/fileUpload/file-upload-service.js
@@ -20,19 +20,20 @@
 /*
  * Upload an HTML5 file to a server
  */
-angular.module('mFileUpload', [])
-.service('mFileUpload', ['matrixService', '$q', function (matrixService, $q) {
+angular.module('mFileUpload', ['matrixService', 'mUtilities'])
+.service('mFileUpload', ['$q', 'matrixService', 'mUtilities', function ($q, matrixService, mUtilities) {
         
     /*
-     * Upload an HTML5 file to a server and returned a promise
+     * Upload an HTML5 file or blob to a server and returned a promise
      * that will provide the URL of the uploaded file.
+     * @param {File|Blob} file the file data to send
      */
-    this.uploadFile = function(file, body) {
+    this.uploadFile = function(file) {
         var deferred = $q.defer();
         console.log("Uploading " + file.name + "... to /matrix/content");
-        matrixService.uploadContent(file, body).then(
+        matrixService.uploadContent(file).then(
             function(response) {
-                var content_url = location.origin + "/matrix/content/" + response.data.content_token;
+                var content_url = response.data.content_token;
                 console.log("   -> Successfully uploaded! Available at " + content_url);
                 deferred.resolve(content_url);
             },
@@ -44,4 +45,135 @@ angular.module('mFileUpload', [])
         
         return deferred.promise;
     };
+    
+    /*
+     * Upload an image file plus generate a thumbnail of it and upload it so that
+     * we will have all information to fulfill an image message request data.
+     * @param {File} imageFile the imageFile to send
+     * @param {Integer} thumbnailSize the max side size of the thumbnail to create
+     * @returns {promise} A promise that will be resolved by a image message object
+     *   ready to be send with the Matrix API
+     */
+    this.uploadImageAndThumbnail = function(imageFile, thumbnailSize) {
+        var self = this;
+        var deferred = $q.defer();
+
+        console.log("uploadImageAndThumbnail " + imageFile.name + " - thumbnailSize: " + thumbnailSize);
+
+        // The message structure that will be returned in the promise
+        var imageMessage = {
+            msgtype: "m.image",
+            url: undefined,
+            body: {
+                size: undefined,
+                w: undefined,
+                h: undefined,
+                mimetype: undefined
+            },
+            thumbnail_url: undefined,
+            thumbnail_info: {
+                size: undefined,
+                w: undefined,
+                h: undefined,
+                mimetype: undefined
+            }
+        };
+
+        // First, get the image size
+        mUtilities.getImageSize(imageFile).then(
+            function(size) {
+                console.log("image size: " + JSON.stringify(size));
+
+                // The final operation: send imageFile
+                var uploadImage = function() {
+                    self.uploadFile(imageFile).then(
+                        function(url) {
+                            // Update message metadata
+                            imageMessage.url = url;
+                            imageMessage.body = {
+                                size: imageFile.size,
+                                w: size.width,
+                                h: size.height,
+                                mimetype: imageFile.type
+                            };
+
+                            // If there is no thumbnail (because the original image is smaller than thumbnailSize),
+                            // reuse the original image info for thumbnail data
+                            if (!imageMessage.thumbnail_url) {
+                                imageMessage.thumbnail_url = imageMessage.url;
+                                imageMessage.thumbnail_info = imageMessage.body;
+                            }
+
+                            // We are done
+                            deferred.resolve(imageMessage);
+                        },
+                        function(error) {
+                            console.log("      -> Can't upload image");
+                            deferred.reject(error); 
+                        }
+                    );
+                };
+
+                // Create a thumbnail if the image size exceeds thumbnailSize
+                if (Math.max(size.width, size.height) > thumbnailSize) {
+                    console.log("    Creating thumbnail...");
+                    mUtilities.resizeImage(imageFile, thumbnailSize).then(
+                        function(thumbnailBlob) {
+
+                            // Get its size
+                            mUtilities.getImageSize(thumbnailBlob).then(
+                                function(thumbnailSize) {
+                                    console.log("      -> Thumbnail size: " + JSON.stringify(thumbnailSize));
+
+                                    // Upload it to the server
+                                    self.uploadFile(thumbnailBlob).then(
+                                        function(thumbnailUrl) {
+
+                                            // Update image message data
+                                            imageMessage.thumbnail_url = thumbnailUrl;
+                                            imageMessage.thumbnail_info = {
+                                                size: thumbnailBlob.size,
+                                                w: thumbnailSize.width,
+                                                h: thumbnailSize.height,
+                                                mimetype: thumbnailBlob.type
+                                            };
+
+                                            // Then, upload the original image
+                                            uploadImage();
+                                        },
+                                        function(error) {
+                                            console.log("      -> Can't upload thumbnail");
+                                            deferred.reject(error); 
+                                        }
+                                    );
+                                },
+                                function(error) {
+                                    console.log("      -> Failed to get thumbnail size");
+                                    deferred.reject(error); 
+                                }
+                            );
+
+                        },
+                        function(error) {
+                            console.log("      -> Failed to create thumbnail: " + error);
+                            deferred.reject(error); 
+                        }
+                    );
+                }
+                else {
+                    // No need of thumbnail
+                    console.log("   Thumbnail is not required");
+                    uploadImage();
+                }
+
+            },
+            function(error) {
+                console.log("   -> Failed to get image size");
+                deferred.reject(error); 
+            }
+        );
+
+        return deferred.promise;
+    };
+
 }]);
diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js
index b8529895fe..6ea0f58bc5 100644
--- a/webclient/components/matrix/event-handler-service.js
+++ b/webclient/components/matrix/event-handler-service.js
@@ -35,6 +35,8 @@ angular.module('eventHandlerService', [])
     $rootScope.events = {
         rooms: {}, // will contain roomId: { messages:[], members:{userid1: event} }
     };
+
+    $rootScope.presence = {};
     
     var initRoom = function(room_id) {
         if (!(room_id in $rootScope.events.rooms)) {
@@ -44,12 +46,14 @@ angular.module('eventHandlerService', [])
             $rootScope.events.rooms[room_id].members = {};
         }
     }
+
+    var reInitRoom = function(room_id) {
+        $rootScope.events.rooms[room_id] = {};
+        $rootScope.events.rooms[room_id].messages = [];
+        $rootScope.events.rooms[room_id].members = {};
+    }
     
     var handleMessage = function(event, isLiveEvent) {
-        if ("membership_target" in event.content) {
-            event.user_id = event.content.membership_target;
-        }
-        
         initRoom(event.room_id);
         
         if (isLiveEvent) {
@@ -69,11 +73,23 @@ angular.module('eventHandlerService', [])
     
     var handleRoomMember = function(event, isLiveEvent) {
         initRoom(event.room_id);
+        
+        // add membership changes as if they were a room message if something interesting changed
+        if (event.content.prev !== event.content.membership) {
+            if (isLiveEvent) {
+                $rootScope.events.rooms[event.room_id].messages.push(event);
+            }
+            else {
+                $rootScope.events.rooms[event.room_id].messages.unshift(event);
+            }
+        }
+        
         $rootScope.events.rooms[event.room_id].members[event.user_id] = event;
         $rootScope.$broadcast(MEMBER_EVENT, event, isLiveEvent);
     };
     
     var handlePresence = function(event, isLiveEvent) {
+        $rootScope.presence[event.content.user_id] = event;
         $rootScope.$broadcast(PRESENCE_EVENT, event, isLiveEvent);
     };
     
@@ -107,6 +123,10 @@ angular.module('eventHandlerService', [])
             for (var i=0; i<events.length; i++) {
                 this.handleEvent(events[i], isLiveEvents);
             }
-        }
+        },
+
+        reInitRoom: function(room_id) {
+            reInitRoom(room_id);
+        },
     };
 }]);
diff --git a/webclient/components/matrix/event-stream-service.js b/webclient/components/matrix/event-stream-service.js
index a446fad5d4..a1a98b2a36 100644
--- a/webclient/components/matrix/event-stream-service.js
+++ b/webclient/components/matrix/event-stream-service.js
@@ -48,11 +48,12 @@ angular.module('eventStreamService', [])
     var saveStreamSettings = function() {
         localStorage.setItem("streamSettings", JSON.stringify(settings));
     };
-    
-    var startEventStream = function() {
+
+    var doEventStream = function(deferred) {
         settings.shouldPoll = true;
         settings.isActive = true;
-        var deferred = $q.defer();
+        deferred = deferred || $q.defer();
+
         // run the stream from the latest token
         matrixService.getEventStream(settings.from, TIMEOUT_MS).then(
             function(response) {
@@ -63,13 +64,16 @@ angular.module('eventStreamService', [])
                 
                 settings.from = response.data.end;
                 
-                console.log("[EventStream] Got response from "+settings.from+" to "+response.data.end);
+                console.log(
+                    "[EventStream] Got response from "+settings.from+
+                    " to "+response.data.end
+                );
                 eventHandlerService.handleEvents(response.data.chunk, true);
                 
                 deferred.resolve(response);
                 
                 if (settings.shouldPoll) {
-                    $timeout(startEventStream, 0);
+                    $timeout(doEventStream, 0);
                 }
                 else {
                     console.log("[EventStream] Stopping poll.");
@@ -83,13 +87,48 @@ angular.module('eventStreamService', [])
                 deferred.reject(error);
                 
                 if (settings.shouldPoll) {
-                    $timeout(startEventStream, ERR_TIMEOUT_MS);
+                    $timeout(doEventStream, ERR_TIMEOUT_MS);
                 }
                 else {
                     console.log("[EventStream] Stopping polling.");
                 }
             }
         );
+
+        return deferred.promise;
+    }    
+
+    var startEventStream = function() {
+        settings.shouldPoll = true;
+        settings.isActive = true;
+        var deferred = $q.defer();
+
+        // FIXME: We are discarding all the messages.
+        matrixService.rooms().then(
+            function(response) {
+                var rooms = response.data.rooms;
+                for (var i = 0; i < rooms.length; ++i) {
+                    var room = rooms[i];
+                    if ("state" in room) {
+                        for (var j = 0; j < room.state.length; ++j) {
+                            eventHandlerService.handleEvents(room.state[j], false);
+                        }
+                    }
+                }
+
+                var presence = response.data.presence;
+                for (var i = 0; i < presence.length; ++i) {
+                    eventHandlerService.handleEvent(presence[i], false);
+                }
+
+                settings.from = response.data.end
+                doEventStream(deferred);        
+            },
+            function(error) {
+                $scope.feedback = "Failure: " + error.data;
+            }
+        );
+
         return deferred.promise;
     };
     
diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js
index cd37a0c234..b5b1815cf9 100644
--- a/webclient/components/matrix/matrix-service.js
+++ b/webclient/components/matrix/matrix-service.js
@@ -61,16 +61,23 @@ angular.module('matrixService', [])
         return doBaseRequest(config.homeserver, method, path, params, data, undefined);
     };
 
-    var doBaseRequest = function(baseUrl, method, path, params, data, headers) {
-        return $http({
+    var doBaseRequest = function(baseUrl, method, path, params, data, headers, $httpParams) {
+
+        var request = {
             method: method,
             url: baseUrl + path,
             params: params,
             data: data,
             headers: headers
-        });
-    };
+        };
+
+        // Add additional $http parameters
+        if ($httpParams) {
+            angular.extend(request, $httpParams);
+        }
 
+        return $http(request);
+    };
 
     return {
         /****** Home server API ******/
@@ -108,19 +115,7 @@ angular.module('matrixService', [])
 
         // Joins a room
         join: function(room_id) {
-            // The REST path spec
-            var path = "/rooms/$room_id/members/$user_id/state";
-
-            // Like the cmd client, escape room ids
-            room_id = encodeURIComponent(room_id);
-
-            // Customize it
-            path = path.replace("$room_id", room_id);
-            path = path.replace("$user_id", config.user_id);
-
-            return doRequest("PUT", path, undefined, {
-                 membership: "join"
-            });
+            return this.membershipChange(room_id, config.user_id, "join");
         },
 
         joinAlias: function(room_alias) {
@@ -134,34 +129,23 @@ angular.module('matrixService', [])
 
         // Invite a user to a room
         invite: function(room_id, user_id) {
-            // The REST path spec
-            var path = "/rooms/$room_id/members/$user_id/state";
-
-            // Like the cmd client, escape room ids
-            room_id = encodeURIComponent(room_id);
-
-            // Customize it
-            path = path.replace("$room_id", room_id);
-            path = path.replace("$user_id", user_id);
-
-            return doRequest("PUT", path, undefined, {
-                 membership: "invite"
-            });
+            return this.membershipChange(room_id, user_id, "invite");
         },
 
         // Leaves a room
         leave: function(room_id) {
-            // The REST path spec
-            var path = "/rooms/$room_id/members/$user_id/state";
-
-            // Like the cmd client, escape room ids
-            room_id = encodeURIComponent(room_id);
+            return this.membershipChange(room_id, config.user_id, "leave");
+        },
 
-            // Customize it
-            path = path.replace("$room_id", room_id);
-            path = path.replace("$user_id", config.user_id);
+        membershipChange: function(room_id, user_id, membershipValue) {
+            // The REST path spec
+            var path = "/rooms/$room_id/state/m.room.member/$user_id";
+            path = path.replace("$room_id", encodeURIComponent(room_id));
+            path = path.replace("$user_id", encodeURIComponent(user_id));
 
-            return doRequest("DELETE", path, undefined, undefined);
+            return doRequest("PUT", path, undefined, {
+                 membership: membershipValue
+            });
         },
 
         // Retrieves the room ID corresponding to a room alias
@@ -302,17 +286,25 @@ angular.module('matrixService', [])
         },
 
         // hit the Identity Server for a 3PID request.
-        linkEmail: function(email) {
+        linkEmail: function(email, clientSecret, sendAttempt) {
             var path = "/matrix/identity/api/v1/validate/email/requestToken"
-            var data = "clientSecret=abc123&email=" + encodeURIComponent(email);
+            var data = "clientSecret="+clientSecret+"&email=" + encodeURIComponent(email)+"&sendAttempt="+sendAttempt;
             var headers = {};
             headers["Content-Type"] = "application/x-www-form-urlencoded";
             return doBaseRequest(config.identityServer, "POST", path, {}, data, headers); 
         },
 
-        authEmail: function(userId, tokenId, code) {
+        authEmail: function(clientSecret, tokenId, code) {
             var path = "/matrix/identity/api/v1/validate/email/submitToken";
-            var data = "token="+code+"&mxId="+encodeURIComponent(userId)+"&tokenId="+tokenId;
+            var data = "token="+code+"&sid="+tokenId+"&clientSecret="+clientSecret;
+            var headers = {};
+            headers["Content-Type"] = "application/x-www-form-urlencoded";
+            return doBaseRequest(config.identityServer, "POST", path, {}, data, headers);
+        },
+
+        bindEmail: function(userId, tokenId, clientSecret) {
+            var path = "/matrix/identity/api/v1/3pid/bind";
+            var data = "mxid="+encodeURIComponent(userId)+"&sid="+tokenId+"&clientSecret="+clientSecret;
             var headers = {};
             headers["Content-Type"] = "application/x-www-form-urlencoded";
             return doBaseRequest(config.identityServer, "POST", path, {}, data, headers); 
@@ -326,7 +318,17 @@ angular.module('matrixService', [])
             var params = {
                 access_token: config.access_token
             };
-            return doBaseRequest(config.homeserver, "POST", path, params, file, headers);
+
+            // If the file is actually a Blob object, prevent $http from JSON-stringified it before sending
+            // (Equivalent to jQuery ajax processData = false)
+            var $httpParams;
+            if (file instanceof Blob) {
+                $httpParams = {
+                    transformRequest: angular.identity
+                };
+            }
+
+            return doBaseRequest(config.homeserver, "POST", path, params, file, headers, $httpParams);
         },
         
         // start listening on /events
@@ -375,6 +377,7 @@ angular.module('matrixService', [])
         // Set a new config (Use saveConfig to actually store it permanently)
         setConfig: function(newConfig) {
             config = newConfig;
+            console.log("new IS: "+config.identityServer);
         },
         
         // Commits config into permanent storage
diff --git a/webclient/components/utilities/utilities-service.js b/webclient/components/utilities/utilities-service.js
index fc0ee580dc..3df2f04458 100644
--- a/webclient/components/utilities/utilities-service.js
+++ b/webclient/components/utilities/utilities-service.js
@@ -22,8 +22,8 @@
 angular.module('mUtilities', [])
 .service('mUtilities', ['$q', function ($q) {
     /*
-     * Gets the size of an image
-     * @param {File} imageFile the file containing the image
+     * Get the size of an image
+     * @param {File|Blob} imageFile the file containing the image
      * @returns {promise} A promise that will be resolved by an object with 2 members:
      *   width & height
      */
@@ -38,10 +38,15 @@ angular.module('mUtilities', [])
             img.src = e.target.result;
             
             // Once ready, returns its size
-            deferred.resolve({
-                width: img.width,
-                height: img.height
-            });
+            img.onload = function() {
+                deferred.resolve({
+                    width: img.width,
+                    height: img.height
+                });
+            };
+            img.onerror = function(e) {
+                deferred.reject(e);
+            };
         };
         reader.onerror = function(e) {
             deferred.reject(e);
@@ -50,4 +55,97 @@ angular.module('mUtilities', [])
         
         return deferred.promise;
     };
+
+    /*
+     * Resize the image to fit in a square of the side maxSize. 
+     * The aspect ratio is kept. The returned image data uses JPEG compression.
+     * Source: http://hacks.mozilla.org/2011/01/how-to-develop-a-html5-image-uploader/
+     * @param {File} imageFile the file containing the image 
+     * @param {Integer} maxSize the max side size 
+     * @returns {promise} A promise that will be resolved by a Blob object containing
+     *   the resized image data
+     */
+    this.resizeImage = function(imageFile, maxSize) {
+        var self = this;
+        var deferred = $q.defer();
+
+        var canvas = document.createElement("canvas");
+
+        var img = document.createElement("img");
+        var reader = new FileReader();  
+        reader.onload = function(e) {
+
+            img.src = e.target.result;
+            
+            // Once ready, returns its size
+            img.onload = function() {
+                var ctx = canvas.getContext("2d");
+                ctx.drawImage(img, 0, 0);
+
+                var MAX_WIDTH = maxSize;
+                var MAX_HEIGHT = maxSize;
+                var width = img.width;
+                var height = img.height;
+
+                if (width > height) {
+                    if (width > MAX_WIDTH) {
+                        height *= MAX_WIDTH / width;
+                        width = MAX_WIDTH;
+                    }
+                } else {
+                    if (height > MAX_HEIGHT) {
+                        width *= MAX_HEIGHT / height;
+                        height = MAX_HEIGHT;
+                    }
+                }
+                canvas.width = width;
+                canvas.height = height;
+                var ctx = canvas.getContext("2d");
+                ctx.drawImage(img, 0, 0, width, height);
+
+                // Extract image data in the same format as the original one.
+                // The 0.7 compression value will work with formats that supports it like JPEG.
+                var dataUrl = canvas.toDataURL(imageFile.type, 0.7); 
+                deferred.resolve(self.dataURItoBlob(dataUrl));
+            };
+            img.onerror = function(e) {
+                deferred.reject(e);
+            };
+        };
+        reader.onerror = function(e) {
+            deferred.reject(e);
+        };
+        reader.readAsDataURL(imageFile);
+
+        return deferred.promise;
+    };
+
+    /*
+     * Convert a dataURI string to a blob 
+     * Source: http://stackoverflow.com/a/17682951
+     * @param {String} dataURI the dataURI can be a base64 encoded string or an URL encoded string.
+     * @returns {Blob} the blob
+     */
+    this.dataURItoBlob = function(dataURI) {
+        // convert base64 to raw binary data held in a string
+        // doesn't handle URLEncoded DataURIs
+        var byteString;
+        if (dataURI.split(',')[0].indexOf('base64') >= 0)
+            byteString = atob(dataURI.split(',')[1]);
+        else
+            byteString = unescape(dataURI.split(',')[1]);
+        // separate out the mime component
+        var mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0];
+
+        // write the bytes of the string to an ArrayBuffer
+        var ab = new ArrayBuffer(byteString.length);
+        var ia = new Uint8Array(ab);
+        for (var i = 0; i < byteString.length; i++) {
+            ia[i] = byteString.charCodeAt(i);
+        }
+
+        // write the ArrayBuffer to a blob, and you're done
+        return new Blob([ab],{type: mimeString});
+    };
+
 }]);
\ No newline at end of file
diff --git a/webclient/home/home-controller.js b/webclient/home/home-controller.js
new file mode 100644
index 0000000000..a5576759fa
--- /dev/null
+++ b/webclient/home/home-controller.js
@@ -0,0 +1,162 @@
+/*
+Copyright 2014 matrix.org
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+angular.module('HomeController', ['matrixService', 'mFileInput', 'mFileUpload', 'eventHandlerService'])
+.controller('HomeController', ['$scope', '$location', 'matrixService', 'mFileUpload', 'eventHandlerService', 'eventStreamService', 
+                               function($scope, $location, matrixService, mFileUpload, eventHandlerService, eventStreamService) {
+
+    $scope.config = matrixService.config();
+    $scope.rooms = {};
+    $scope.public_rooms = [];
+    $scope.newRoomId = "";
+    $scope.feedback = "";
+    
+    $scope.newRoom = {
+        room_id: "",
+        private: false
+    };
+    
+    $scope.goToRoom = {
+        room_id: "",
+    };
+
+    $scope.joinAlias = {
+        room_alias: "",
+    };
+    
+    $scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) {
+        var config = matrixService.config();
+        if (event.state_key === config.user_id && event.content.membership === "invite") {
+            console.log("Invited to room " + event.room_id);
+            // FIXME push membership to top level key to match /im/sync
+            event.membership = event.content.membership;
+            // FIXME bodge a nicer name than the room ID for this invite.
+            event.room_display_name = event.user_id + "'s room";
+            $scope.rooms[event.room_id] = event;
+        }
+    });
+    
+    var assignRoomAliases = function(data) {
+        for (var i=0; i<data.length; i++) {
+            var alias = matrixService.getRoomIdToAliasMapping(data[i].room_id);
+            if (alias) {
+                // use the existing alias from storage
+                data[i].room_alias = alias;
+                data[i].room_display_name = alias;
+            }
+            else if (data[i].aliases && data[i].aliases[0]) {
+                // save the mapping
+                // TODO: select the smarter alias from the array
+                matrixService.createRoomIdToAliasMapping(data[i].room_id, data[i].aliases[0]);
+                data[i].room_display_name = data[i].aliases[0];
+            }
+            else if (data[i].membership == "invite" && "inviter" in data[i]) {
+                data[i].room_display_name = data[i].inviter + "'s room"
+            }
+            else {
+                // last resort use the room id
+                data[i].room_display_name = data[i].room_id;
+            }
+        }
+        return data;
+    };
+
+    $scope.refresh = function() {
+        // List all rooms joined or been invited to
+        matrixService.rooms().then(
+            function(response) {
+                var data = assignRoomAliases(response.data.rooms);
+                $scope.feedback = "Success";
+                for (var i=0; i<data.length; i++) {
+                    $scope.rooms[data[i].room_id] = data[i];
+                }
+
+                var presence = response.data.presence;
+                for (var i = 0; i < presence.length; ++i) {
+                    eventHandlerService.handleEvent(presence[i], false);
+                }
+            },
+            function(error) {
+                $scope.feedback = "Failure: " + error.data;
+            });
+        
+        matrixService.publicRooms().then(
+            function(response) {
+                $scope.public_rooms = assignRoomAliases(response.data.chunk);
+            }
+        );
+
+        eventStreamService.resume();
+    };
+    
+    $scope.createNewRoom = function(room_id, isPrivate) {
+        
+        var visibility = "public";
+        if (isPrivate) {
+            visibility = "private";
+        }
+        
+        matrixService.create(room_id, visibility).then(
+            function(response) { 
+                // This room has been created. Refresh the rooms list
+                console.log("Created room " + response.data.room_alias + " with id: "+
+                response.data.room_id);
+                matrixService.createRoomIdToAliasMapping(
+                    response.data.room_id, response.data.room_alias);
+                $scope.refresh();
+            },
+            function(error) {
+                $scope.feedback = "Failure: " + error.data;
+            });
+    };
+    
+    // Go to a room
+    $scope.goToRoom = function(room_id) {
+        // Simply open the room page on this room id
+        //$location.url("room/" + room_id);
+        matrixService.join(room_id).then(
+            function(response) {
+                if (response.data.hasOwnProperty("room_id")) {
+                    if (response.data.room_id != room_id) {
+                        $location.url("room/" + response.data.room_id);
+                        return;
+                     }
+                }
+
+                $location.url("room/" + room_id);
+            },
+            function(error) {
+                $scope.feedback = "Can't join room: " + error.data;
+            }
+        );
+    };
+
+    $scope.joinAlias = function(room_alias) {
+        matrixService.joinAlias(room_alias).then(
+            function(response) {
+                // Go to this room
+                $location.url("room/" + room_alias);
+            },
+            function(error) {
+                $scope.feedback = "Can't join room: " + error.data;
+            }
+        );
+    };
+ 
+    $scope.refresh();
+}]);
diff --git a/webclient/home/home.html b/webclient/home/home.html
new file mode 100644
index 0000000000..4818d414b6
--- /dev/null
+++ b/webclient/home/home.html
@@ -0,0 +1,63 @@
+<div ng-controller="HomeController">
+
+    <div id="page">
+    <div id="wrapper">
+        
+    <div>
+        <form>
+            <table>
+                <tr>
+                    <td>
+                        <div class="profile-avatar">
+                            <img ng-src="{{ config.avatarUrl || 'img/default-profile.jpg' }}"/>
+                        </div>
+                    </td>
+                    <td>
+                        <div id="user-ids">
+                            <div id="user-displayname">{{ config.displayName }}</div>
+                            <div>{{ config.user_id }}</div>                        
+                        </div>
+                    </td>
+                </tr>
+            </table>
+        </form>
+    </div>
+    
+    <h3>My rooms</h3>
+    
+    <div class="rooms" ng-repeat="(rm_id, room) in rooms">
+        <div>
+            <a href="#/room/{{ room.room_alias ? room.room_alias : rm_id }}" >{{ room.room_display_name }}</a> {{room.membership === 'invite' ? ' (invited)' : ''}}
+        </div>
+    </div>
+    <br/>
+
+    <h3>Public rooms</h3>
+    
+    <div class="public_rooms" ng-repeat="room in public_rooms">
+        <div>
+            <a href="#/room/{{ room.room_alias ? room.room_alias : room.room_id }}" >{{ room.room_alias }}</a>
+        </div>
+    </div>
+    <br/>
+    
+    <div>
+        <form>
+            <input size="40" ng-model="newRoom.room_id" ng-enter="createNewRoom(newRoom.room_id, newRoom.private)" placeholder="(e.g. foo_channel)"/>
+            <input type="checkbox" ng-model="newRoom.private">private
+            <button ng-disabled="!newRoom.room_id" ng-click="createNewRoom(newRoom.room_id, newRoom.private)">Create room</button>    
+        </form>
+    </div>
+    <div>
+        <form>
+            <input size="40" ng-model="joinAlias.room_alias" ng-enter="joinAlias(joinAlias.room_alias)" placeholder="(e.g. #foo_channel:example.org)"/>
+            <button ng-disabled="!joinAlias.room_alias" ng-click="joinAlias(joinAlias.room_alias)">Join room</button>    
+        </form>
+    </div>
+    <br/>
+    
+    {{ feedback }}
+
+    </div>    
+    </div>
+</div>
diff --git a/webclient/index.html b/webclient/index.html
index 27d9208193..938d70c86d 100644
--- a/webclient/index.html
+++ b/webclient/index.html
@@ -2,10 +2,12 @@
 <html xmlns:ng="http://angularjs.org" ng-app="matrixWebClient" ng-controller="MatrixWebClientController">
 <head>
     <title>[matrix]</title>
-    
+        
     <link rel="stylesheet" href="app.css">
     <link rel="icon" href="favicon.ico">
    
+    <meta name="viewport" content="width=device-width">
+   
     <script type='text/javascript' src='js/jquery-1.8.3.min.js'></script> 
     <script src="js/angular.min.js"></script>
     <script src="js/angular-route.min.js"></script>
@@ -15,10 +17,11 @@
     <script src="app-controller.js"></script>
     <script src="app-directive.js"></script>
     <script src="app-filter.js"></script>
+    <script src="home/home-controller.js"></script>
     <script src="login/login-controller.js"></script>
     <script src="room/room-controller.js"></script>
     <script src="room/room-directive.js"></script>
-    <script src="rooms/rooms-controller.js"></script>
+    <script src="settings/settings-controller.js"></script>
     <script src="user/user-controller.js"></script>
     <script src="components/matrix/matrix-service.js"></script>
     <script src="components/matrix/event-stream-service.js"></script>
@@ -33,22 +36,11 @@
     <header id="header">
         <!-- Do not show buttons on the login page -->
         <div id="header-buttons" ng-hide="'/login' == location ">
-            <button ng-click="showConfig()">Config</button>
+            <button ng-click='go("settings")'>Settings</button>
             <button ng-click="logout()">Log out</button>
         </div>
-
-        <h1>[matrix]</h1>
     </header>
 
-    <div id="config" ng-hide="!config">
-        <div>Home server: {{ config.homeserver }} </div>
-        <div>User ID: {{ config.user_id }} </div>
-        <div>Access token: {{ config.access_token }} </div>
-        <div><button ng-click="requestNotifications()">Request notifications</button></div>
-        <div><button ng-click="closeConfig()">Close</button></div>
-    </div>
-
-
     <div ng-view></div>
 
 </body>
diff --git a/webclient/login/login-controller.js b/webclient/login/login-controller.js
index 67d0b7b90c..51f9a3bdf4 100644
--- a/webclient/login/login-controller.js
+++ b/webclient/login/login-controller.js
@@ -53,7 +53,7 @@ angular.module('LoginController', ['matrixService'])
                 matrixService.saveConfig();
                 eventStreamService.resume();
                  // Go to the user's rooms list page
-                $location.path("rooms");
+                $location.url("home");
             },
             function(error) {
                 if (error.data) {
@@ -70,6 +70,7 @@ angular.module('LoginController', ['matrixService'])
     $scope.login = function() {
         matrixService.setConfig({
             homeserver: $scope.account.homeserver,
+            identityServer: $scope.account.identityServer,
             user_id: $scope.account.user_id
         });
         // try to login
@@ -79,12 +80,13 @@ angular.module('LoginController', ['matrixService'])
                     $scope.feedback = "Login successful.";
                     matrixService.setConfig({
                         homeserver: $scope.account.homeserver,
+                        identityServer: $scope.account.identityServer,
                         user_id: response.data.user_id,
                         access_token: response.data.access_token
                     });
                     matrixService.saveConfig();
                     eventStreamService.resume();
-                    $location.path("rooms");
+                    $location.url("home");
                 }
                 else {
                     $scope.feedback = "Failed to login: " + JSON.stringify(response.data);
diff --git a/webclient/login/login.html b/webclient/login/login.html
index b1488b37f0..4b2ea60928 100644
--- a/webclient/login/login.html
+++ b/webclient/login/login.html
@@ -1,4 +1,6 @@
-<div ng-controller="LoginController" class="login">
+<div ng-controller="LoginController" class="login">    
+    <h1 id="logo">[matrix]</h1>
+
     <div id="page">
     <div id="wrapper">
 
diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js
index 35abeeca06..f49deaa489 100644
--- a/webclient/room/room-controller.js
+++ b/webclient/room/room-controller.js
@@ -15,10 +15,11 @@ limitations under the License.
 */
 
 angular.module('RoomController', ['ngSanitize', 'mUtilities'])
-.controller('RoomController', ['$scope', '$http', '$timeout', '$routeParams', '$location', 'matrixService', 'eventStreamService', 'eventHandlerService', 'mFileUpload', 'mUtilities',
-                               function($scope, $http, $timeout, $routeParams, $location, matrixService, eventStreamService, eventHandlerService, mFileUpload, mUtilities) {
+.controller('RoomController', ['$scope', '$http', '$timeout', '$routeParams', '$location', 'matrixService', 'eventStreamService', 'eventHandlerService', 'mFileUpload', 'mUtilities', '$rootScope',
+                               function($scope, $http, $timeout, $routeParams, $location, matrixService, eventStreamService, eventHandlerService, mFileUpload, mUtilities, $rootScope) {
    'use strict';
     var MESSAGES_PER_PAGINATION = 30;
+    var THUMBNAIL_SIZE = 320;
 
     // Room ids. Computed and resolved in onInit
     $scope.room_id = undefined;
@@ -28,9 +29,11 @@ angular.module('RoomController', ['ngSanitize', 'mUtilities'])
         user_id: matrixService.config().user_id,
         events_from: "END", // when to start the event stream from.
         earliest_token: "END", // stores how far back we've paginated.
+        first_pagination: true, // this is toggled off when the first pagination is done
         can_paginate: true, // this is toggled off when we run out of items
         paginating: false, // used to avoid concurrent pagination requests pulling in dup contents
         stream_failure: undefined, // the response when the stream fails
+        // FIXME: sending has been disabled, as surely messages should be sent in the background rather than locking the UI synchronously --Matthew
         sending: false // true when a message is being sent. It helps to disable the UI when a process is running
     };
     $scope.members = {};
@@ -99,7 +102,6 @@ angular.module('RoomController', ['ngSanitize', 'mUtilities'])
         var originalTopRow = $("#messageTable>tbody>tr:first")[0];
         matrixService.paginateBackMessages($scope.room_id, $scope.state.earliest_token, numItems).then(
             function(response) {
-                var firstPagination = !$scope.events.rooms[$scope.room_id];
                 eventHandlerService.handleEvents(response.data.chunk, false);
                 $scope.state.earliest_token = response.data.end;
                 if (response.data.chunk.length < MESSAGES_PER_PAGINATION) {
@@ -125,8 +127,9 @@ angular.module('RoomController', ['ngSanitize', 'mUtilities'])
                     }, 0);
                 }
                 
-                if (firstPagination) {
+                if ($scope.state.first_pagination) {
                     scrollToBottom();
+                    $scope.state.first_pagination = false;
                 }
                 else {
                     // lock the scroll position
@@ -149,7 +152,12 @@ angular.module('RoomController', ['ngSanitize', 'mUtilities'])
     };
 
     var updateMemberList = function(chunk) {
-        var isNewMember = !(chunk.target_user_id in $scope.members);
+        if (chunk.room_id != $scope.room_id) return;
+
+        // set target_user_id to keep things clear
+        var target_user_id = chunk.state_key;
+
+        var isNewMember = !(target_user_id in $scope.members);
         if (isNewMember) {
             // FIXME: why are we copying these fields around inside chunk?
             if ("state" in chunk.content) {
@@ -158,8 +166,7 @@ angular.module('RoomController', ['ngSanitize', 'mUtilities'])
             if ("mtime_age" in chunk.content) {
                 chunk.mtime_age = chunk.content.mtime_age;
             }
-/*            
-            // FIXME: once the HS reliably returns the displaynames & avatar_urls for both
+            // Once the HS reliably returns the displaynames & avatar_urls for both
             // local and remote users, we should use this rather than the evalAsync block
             // below
             if ("displayname" in chunk.content) {
@@ -168,9 +175,11 @@ angular.module('RoomController', ['ngSanitize', 'mUtilities'])
             if ("avatar_url" in chunk.content) {
                 chunk.avatar_url = chunk.content.avatar_url;
             }
- */      
-            $scope.members[chunk.target_user_id] = chunk;
+            $scope.members[target_user_id] = chunk;
 
+/*
+            // Stale code for explicitly hammering the homeserver for every displayname & avatar_url
+            
             // get their display name and profile picture and set it to their
             // member entry in $scope.members. We HAVE to use $timeout with 0 delay 
             // to make this function run AFTER the current digest cycle, else the 
@@ -194,10 +203,15 @@ angular.module('RoomController', ['ngSanitize', 'mUtilities'])
                     }
                 );
             });
+*/            
+
+            if (target_user_id in $rootScope.presence) {
+                updatePresence($rootScope.presence[target_user_id]);
+            }
         }
         else {
             // selectively update membership else it will nuke the picture and displayname too :/
-            var member = $scope.members[chunk.target_user_id];
+            var member = $scope.members[target_user_id];
             member.content.membership = chunk.content.membership;
         }
     }
@@ -235,7 +249,7 @@ angular.module('RoomController', ['ngSanitize', 'mUtilities'])
         }
 
         $scope.state.sending = true;
-
+        
         // Send the text message
         var promise;
         // FIXME: handle other commands too
@@ -259,9 +273,8 @@ angular.module('RoomController', ['ngSanitize', 'mUtilities'])
     };
 
     $scope.onInit = function() {
-        // $timeout(function() { document.getElementById('textInput').focus() }, 0);
         console.log("onInit");
-        
+
         // Does the room ID provided in the URL?
         var room_id_or_alias;
         if ($routeParams.room_id_or_alias) {
@@ -289,7 +302,7 @@ angular.module('RoomController', ['ngSanitize', 'mUtilities'])
                 else {
                     // In case of issue, go to the default page
                     console.log("Error: cannot extract room alias");
-                    $location.path("/");
+                    $location.url("/");
                     return;
                 }
             }
@@ -306,12 +319,14 @@ angular.module('RoomController', ['ngSanitize', 'mUtilities'])
             function () {
                 // In case of issue, go to the default page
                 console.log("Error: cannot resolve room alias");
-                $location.path("/");
+                $location.url("/");
             });
         }
     };
 
     var onInit2 = function() {
+        eventHandlerService.reInitRoom($scope.room_id); 
+
         // Join the room
         matrixService.join($scope.room_id).then(
             function() {
@@ -324,6 +339,7 @@ angular.module('RoomController', ['ngSanitize', 'mUtilities'])
                             var chunk = response.data.chunk[i];
                             updateMemberList(chunk);
                         }
+                        eventStreamService.resume();
                     },
                     function(error) {
                         $scope.feedback = "Failed get member list: " + error.data.error;
@@ -359,7 +375,7 @@ angular.module('RoomController', ['ngSanitize', 'mUtilities'])
         matrixService.leave($scope.room_id).then(
             function(response) {
                 console.log("Left room ");
-                $location.path("rooms");
+                $location.url("home");
             },
             function(error) {
                 $scope.feedback = "Failed to leave room: " + error.data.error;
@@ -386,33 +402,22 @@ angular.module('RoomController', ['ngSanitize', 'mUtilities'])
 
             $scope.state.sending = true;
 
-            // First, get the image sise
-            mUtilities.getImageSize($scope.imageFileToSend).then(
-                function(size) {
-
-                    // Upload the image to the Internet
-                    console.log("Uploading image...");
-                    mFileUpload.uploadFile($scope.imageFileToSend).then(
-                        function(url) {
-                            // Build the image info data
-                            var imageInfo = {
-                                size: $scope.imageFileToSend.size,
-                                mimetype: $scope.imageFileToSend.type,
-                                w: size.width,
-                                h: size.height
-                            };
-
-                            // Then share the URL and the metadata
-                            $scope.sendImage(url, imageInfo);
+            // Upload this image with its thumbnail to Internet
+            mFileUpload.uploadImageAndThumbnail($scope.imageFileToSend, THUMBNAIL_SIZE).then(
+                function(imageMessage) {
+                    // imageMessage is complete message structure, send it as is
+                    matrixService.sendMessage($scope.room_id, undefined, imageMessage).then(
+                        function() {
+                            console.log("Image message sent");
+                            $scope.state.sending = false;
                         },
                         function(error) {
-                            $scope.feedback = "Can't upload image";
+                            $scope.feedback = "Failed to send image message: " + error.data.error;
                             $scope.state.sending = false;
-                        }
-                    );
+                        });
                 },
                 function(error) {
-                    $scope.feedback = "Can't get selected image size";
+                    $scope.feedback = "Can't upload image";
                     $scope.state.sending = false;
                 }
             );
diff --git a/webclient/room/room-directive.js b/webclient/room/room-directive.js
index 94655336df..1a99a37abb 100644
--- a/webclient/room/room-directive.js
+++ b/webclient/room/room-directive.js
@@ -17,30 +17,30 @@
 'use strict';
 
 angular.module('RoomController')
-.directive('autoComplete', ['$timeout', function ($timeout) {
+.directive('tabComplete', ['$timeout', function ($timeout) {
     return function (scope, element, attrs) {
         element.bind("keydown keypress", function (event) {
             // console.log("event: " + event.which);
             if (event.which === 9) {
-                if (!scope.autoCompleting) { // cache our starting text
+                if (!scope.tabCompleting) { // cache our starting text
                     // console.log("caching " + element[0].value);
-                    scope.autoCompleteOriginal = element[0].value;
-                    scope.autoCompleting = true;
+                    scope.tabCompleteOriginal = element[0].value;
+                    scope.tabCompleting = true;
                 }
                 
                 if (event.shiftKey) {
-                    scope.autoCompleteIndex--;
-                    if (scope.autoCompleteIndex < 0) {
-                        scope.autoCompleteIndex = 0;
+                    scope.tabCompleteIndex--;
+                    if (scope.tabCompleteIndex < 0) {
+                        scope.tabCompleteIndex = 0;
                     }
                 }
                 else {
-                    scope.autoCompleteIndex++;
+                    scope.tabCompleteIndex++;
                 }
                 
                 var searchIndex = 0;
-                var targetIndex = scope.autoCompleteIndex;
-                var text = scope.autoCompleteOriginal;
+                var targetIndex = scope.tabCompleteIndex;
+                var text = scope.tabCompleteOriginal;
                 
                 // console.log("targetIndex: " + targetIndex + ", text=" + text);
                                     
@@ -90,17 +90,17 @@ angular.module('RoomController')
                              element[0].className = "";
                         }, 150);
                         element[0].value = text;
-                        scope.autoCompleteIndex = 0;
+                        scope.tabCompleteIndex = 0;
                     }
                 }
                 else {
-                    scope.autoCompleteIndex = 0;
+                    scope.tabCompleteIndex = 0;
                 }
                 event.preventDefault();
             }
-            else if (event.which !== 16 && scope.autoCompleting) {
-                scope.autoCompleting = false;
-                scope.autoCompleteIndex = 0;
+            else if (event.which !== 16 && scope.tabCompleting) {
+                scope.tabCompleting = false;
+                scope.tabCompleteIndex = 0;
             }
         });
     };
diff --git a/webclient/room/room.html b/webclient/room/room.html
index db6add4ee7..c167819f15 100644
--- a/webclient/room/room.html
+++ b/webclient/room/room.html
@@ -1,4 +1,5 @@
 <div ng-controller="RoomController" data-ng-init="onInit()" class="room">
+    <h1 id="roomLogo">[matrix]</h1>
 
     <div id="page">
     <div id="wrapper">
@@ -10,7 +11,7 @@
     <div id="usersTableWrapper">
         <table id="usersTable">
             <tr ng-repeat="member in members | orderMembersList">
-                <td class="userAvatar" ng-click="goToUserPage(member.id)">
+                <td class="userAvatar mouse-pointer" ng-click="goToUserPage(member.id)">
                     <img class="userAvatarImage" 
                          ng-src="{{member.avatar_url || 'img/default-profile.jpg'}}" 
                          alt="{{ member.displayname || member.id.substr(0, member.id.indexOf(':')) }}"
@@ -26,24 +27,35 @@
     </div>
     
     <div id="messageTableWrapper" keep-scroll>
+        <!-- FIXME: need to have better timestamp semantics than the (msg.content.hsob_ts || msg.ts) hack below -->
         <table id="messageTable" infinite-scroll="paginateMore()">
             <tr ng-repeat="msg in events.rooms[room_id].messages"
-                ng-class="(events.rooms[room_id].messages[$index - 1].user_id !== msg.user_id ? 'differentUser' : '') + (msg.user_id === state.user_id ? ' mine' : '')" scroll-item>
+                ng-class="(events.rooms[room_id].messages[$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="events.rooms[room_id].messages[$index - 1].user_id === msg.user_id">{{ members[msg.user_id].displayname || msg.user_id }}</div>
-                    <div class="timestamp">{{ msg.content.hsob_ts | date:'MMM d HH:mm:ss' }}</div>
+                    <div class="timestamp">{{ (msg.content.hsob_ts || msg.ts) | date:'MMM d HH:mm' }}</div>
                 </td>
                 <td class="avatar">
                     <img class="avatarImage" ng-src="{{ members[msg.user_id].avatar_url || 'img/default-profile.jpg' }}" width="32" height="32"
                          ng-hide="events.rooms[room_id].messages[$index - 1].user_id === msg.user_id || msg.user_id === state.user_id"/>
                 </td>
-                <td ng-class="!msg.content.membership_target ? (msg.content.msgtype === 'm.emote' ? 'emote text' : 'text') : 'membership text'">
+                <td ng-class="!msg.content.membership ? (msg.content.msgtype === 'm.emote' ? 'emote text' : 'text') : 'membership text'">
                     <div class="bubble">
+                        <span ng-hide='msg.type !== "m.room.member"'>
+                            {{ members[msg.user_id].displayname || msg.user_id }}
+                            {{ {"join": "joined", "leave": "left", "invite": "invited"}[msg.content.membership] }}
+                            {{ msg.content.membership === "invite" ? (msg.state_key || '') : '' }}
+                        </span>
                         <span ng-hide='msg.content.msgtype !== "m.emote"' ng-bind-html="'* ' + (members[msg.user_id].displayname || msg.user_id) + ' ' + msg.content.body | linky:'_blank'"/>
                         <span ng-hide='msg.content.msgtype !== "m.text"' ng-bind-html="((msg.content.msgtype === 'm.text') ? msg.content.body : '') | linky:'_blank'"/>
-                        <div ng-hide='msg.content.msgtype !== "m.image"'
-                             ng-style="msg.content.body.h && { 'height' : (msg.content.body.h < 320) ? msg.content.body.h : 320}">
-                            <img class="image" ng-src="{{ msg.content.url }}"/>
+                        <div ng-show='msg.content.msgtype === "m.image"'>
+                            <div ng-hide='msg.content.thumbnail_url' ng-style="msg.content.body.h && { 'height' : (msg.content.body.h < 320) ? msg.content.body.h : 320}">
+                                <img class="image" ng-src="{{ msg.content.url }}"/>
+                            </div>
+                            <div ng-show='msg.content.thumbnail_url' ng-style="{ 'height' : msg.content.thumbnail_info.h }">
+                                <img class="image mouse-pointer" ng-src="{{ msg.content.thumbnail_url }}"
+                                     ng-click="$parent.fullScreenImageURL = msg.content.url"/>
+                            </div>
                         </div>
                     </div>
                 </td>
@@ -62,29 +74,28 @@
         <div id="controls">
             <table id="inputBarTable">
                 <tr>
-                    <td width="1">
+                    <td id="userIdCell" width="1px">
                         {{ state.user_id }} 
                     </td>
-                    <td width="*" style="min-width: 100px">
-                        <input id="mainInput" ng-model="textInput" ng-enter="send()" ng-disabled="state.sending" ng-focus="true" auto-complete/>
+                    <td width="*">
+                        <input id="mainInput" ng-model="textInput" ng-enter="send()" ng-focus="true" autocomplete="off" tab-complete/>
                     </td>
-                    <td width="150px">
-                        <button ng-click="send()" ng-disabled="state.sending">Send</button>
-                        <button m-file-input="imageFileToSend">Send Image</button>
-                    </td>
-                    <td width="1">
-                        
+                    <td id="buttonsCell">
+                        <button ng-click="send()">Send</button>
+                        <button m-file-input="imageFileToSend">Image</button>
                     </td>
                 </tr>
             </table>
 
-            <span>
-               Invite a user: 
-                    <input ng-model="userIDToInvite" size="32" type="text" placeholder="User ID (ex:@user:homeserver)"/>     
-                    <button ng-click="inviteUser(userIDToInvite)">Invite</button>
-            </span>
-            <button ng-click="leaveRoom()">Leave</button>
-            <button ng-click="loadMoreHistory()" ng-disabled="!state.can_paginate">Load more history</button>
+            <div id="extraControls">
+                <span>
+                   Invite a user: 
+                        <input ng-model="userIDToInvite" size="32" type="text" placeholder="User ID (ex:@user:homeserver)"/>     
+                        <button ng-click="inviteUser(userIDToInvite)">Invite</button>
+                </span>
+                <button ng-click="leaveRoom()">Leave</button>
+            </div>
+        
             {{ feedback }}
             <div ng-hide="!state.stream_failure">
                 {{ state.stream_failure.data.error || "Connection failure" }}
@@ -92,4 +103,8 @@
         </div>
     </div>
 
+    <div id="room-fullscreen-image" ng-show="fullScreenImageURL" ng-click="fullScreenImageURL = undefined;">
+        <img ng-src="{{ fullScreenImageURL }}"/>
+    </div>
+
  </div>
diff --git a/webclient/rooms/rooms-controller.js b/webclient/rooms/rooms-controller.js
deleted file mode 100644
index c25e24c8bc..0000000000
--- a/webclient/rooms/rooms-controller.js
+++ /dev/null
@@ -1,264 +0,0 @@
-/*
-Copyright 2014 matrix.org
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-angular.module('RoomsController', ['matrixService', 'mFileInput', 'mFileUpload', 'eventHandlerService'])
-.controller('RoomsController', ['$scope', '$location', 'matrixService', 'mFileUpload', 'eventHandlerService',
-                               function($scope, $location, matrixService, mFileUpload, eventHandlerService) {
-                                   
-    $scope.rooms = {};
-    $scope.public_rooms = [];
-    $scope.newRoomId = "";
-    $scope.feedback = "";
-    
-    $scope.newRoom = {
-        room_id: "",
-        private: false
-    };
-    
-    $scope.goToRoom = {
-        room_id: "",
-    };
-
-    $scope.joinAlias = {
-        room_alias: "",
-    };
-
-    $scope.newProfileInfo = {
-        name: matrixService.config().displayName,
-        avatar: matrixService.config().avatarUrl,
-        avatarFile: undefined
-    };
-
-    $scope.linkedEmails = {
-        linkNewEmail: "", // the email entry box
-        emailBeingAuthed: undefined, // to populate verification text
-        authTokenId: undefined, // the token id from the IS
-        emailCode: "", // the code entry box
-        linkedEmailList: matrixService.config().emailList // linked email list
-    };
-    
-    $scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) {
-        var config = matrixService.config();
-        if (event.target_user_id === config.user_id && event.content.membership === "invite") {
-            console.log("Invited to room " + event.room_id);
-            // FIXME push membership to top level key to match /im/sync
-            event.membership = event.content.membership;
-            // FIXME bodge a nicer name than the room ID for this invite.
-            event.room_alias = event.user_id + "'s room";
-            $scope.rooms[event.room_id] = event;
-        }
-    });
-    
-    var assignRoomAliases = function(data) {
-        for (var i=0; i<data.length; i++) {
-            var alias = matrixService.getRoomIdToAliasMapping(data[i].room_id);
-            if (alias) {
-                // use the existing alias from storage
-                data[i].room_alias = alias;
-            }
-            else if (data[i].aliases && data[i].aliases[0]) {
-                // save the mapping
-                // TODO: select the smarter alias from the array
-                matrixService.createRoomIdToAliasMapping(data[i].room_id, data[i].aliases[0]);
-            }
-            else {
-                // last resort use the room id
-                data[i].room_alias = data[i].room_id;
-            }
-        }
-        return data;
-    };
-
-    $scope.refresh = function() {
-        // List all rooms joined or been invited to
-        matrixService.rooms().then(
-            function(response) {
-                var data = assignRoomAliases(response.data);
-                $scope.feedback = "Success";
-                for (var i=0; i<data.length; i++) {
-                    $scope.rooms[data[i].room_id] = data[i];
-                }
-            },
-            function(error) {
-                $scope.feedback = "Failure: " + error.data;
-            });
-        
-        matrixService.publicRooms().then(
-            function(response) {
-                $scope.public_rooms = assignRoomAliases(response.data.chunk);
-            }
-        );
-    };
-    
-    $scope.createNewRoom = function(room_id, isPrivate) {
-        
-        var visibility = "public";
-        if (isPrivate) {
-            visibility = "private";
-        }
-        
-        matrixService.create(room_id, visibility).then(
-            function(response) { 
-                // This room has been created. Refresh the rooms list
-                console.log("Created room " + response.data.room_alias + " with id: "+
-                response.data.room_id);
-                matrixService.createRoomIdToAliasMapping(
-                    response.data.room_id, response.data.room_alias);
-                $scope.refresh();
-            },
-            function(error) {
-                $scope.feedback = "Failure: " + error.data;
-            });
-    };
-    
-    // Go to a room
-    $scope.goToRoom = function(room_id) {
-        // Simply open the room page on this room id
-        //$location.path("room/" + room_id);
-        matrixService.join(room_id).then(
-            function(response) {
-                if (response.data.hasOwnProperty("room_id")) {
-                    if (response.data.room_id != room_id) {
-                        $location.path("room/" + response.data.room_id);
-                        return;
-                     }
-                }
-
-                $location.path("room/" + room_id);
-            },
-            function(error) {
-                $scope.feedback = "Can't join room: " + error.data;
-            }
-        );
-    };
-
-    $scope.joinAlias = function(room_alias) {
-        matrixService.joinAlias(room_alias).then(
-            function(response) {
-                // Go to this room
-                $location.path("room/" + room_alias);
-            },
-            function(error) {
-                $scope.feedback = "Can't join room: " + error.data;
-            }
-        );
-    };
-
-    $scope.setDisplayName = function(newName) {
-        matrixService.setDisplayName(newName).then(
-            function(response) {
-                $scope.feedback = "Updated display name.";
-                var config = matrixService.config();
-                config.displayName = newName;
-                matrixService.setConfig(config);
-                matrixService.saveConfig();
-            },
-            function(error) {
-                $scope.feedback = "Can't update display name: " + error.data;
-            }
-        );
-    };
-
-
-    $scope.$watch("newProfileInfo.avatarFile", function(newValue, oldValue) {
-        if ($scope.newProfileInfo.avatarFile) {
-            console.log("Uploading new avatar file...");
-            mFileUpload.uploadFile($scope.newProfileInfo.avatarFile).then(
-                function(url) {
-                    $scope.newProfileInfo.avatar = url;
-                    $scope.setAvatar($scope.newProfileInfo.avatar);
-                },
-                function(error) {
-                    $scope.feedback = "Can't upload image";
-                } 
-            );
-        }
-    });
-
-    $scope.setAvatar = function(newUrl) {
-        console.log("Updating avatar to "+newUrl);
-        matrixService.setProfilePictureUrl(newUrl).then(
-            function(response) {
-                console.log("Updated avatar");
-                $scope.feedback = "Updated avatar.";
-                var config = matrixService.config();
-                config.avatarUrl = newUrl;
-                matrixService.setConfig(config);
-                matrixService.saveConfig();
-            },
-            function(error) {
-                $scope.feedback = "Can't update avatar: " + error.data;
-            }
-        );
-    };
-
-    $scope.linkEmail = function(email) {
-        matrixService.linkEmail(email).then(
-            function(response) {
-                if (response.data.success === true) {
-                    $scope.linkedEmails.authTokenId = response.data.tokenId;
-                    $scope.emailFeedback = "You have been sent an email.";
-                    $scope.linkedEmails.emailBeingAuthed = email;
-                }
-                else {
-                    $scope.emailFeedback = "Failed to send email.";
-                }
-            },
-            function(error) {
-                $scope.emailFeedback = "Can't send email: " + error.data;
-            }
-        );
-    };
-
-    $scope.submitEmailCode = function(code) {
-        var tokenId = $scope.linkedEmails.authTokenId;
-        if (tokenId === undefined) {
-            $scope.emailFeedback = "You have not requested a code with this email.";
-            return;
-        }
-        matrixService.authEmail(matrixService.config().user_id, tokenId, code).then(
-            function(response) {
-                if ("success" in response.data && response.data.success === false) {
-                    $scope.emailFeedback = "Failed to authenticate email.";
-                    return;
-                }
-                var config = matrixService.config();
-                var emailList = {};
-                if ("emailList" in config) {
-                    emailList = config.emailList;
-                }
-                emailList[response.address] = response;
-                // save the new email list
-                config.emailList = emailList;
-                matrixService.setConfig(config);
-                matrixService.saveConfig();
-                // invalidate the email being authed and update UI.
-                $scope.linkedEmails.emailBeingAuthed = undefined;
-                $scope.emailFeedback = "";
-                $scope.linkedEmails.linkedEmailList = emailList;
-                $scope.linkedEmails.linkNewEmail = "";
-                $scope.linkedEmails.emailCode = "";
-            },
-            function(reason) {
-                $scope.emailFeedback = "Failed to auth email: " + reason;
-            }
-        );
-    };
-    
-    $scope.refresh();
-}]);
diff --git a/webclient/rooms/rooms.html b/webclient/rooms/rooms.html
deleted file mode 100644
index 2602209bd3..0000000000
--- a/webclient/rooms/rooms.html
+++ /dev/null
@@ -1,101 +0,0 @@
-<div ng-controller="RoomsController" class="rooms">
-
-    <div id="page">
-    <div id="wrapper">
-            
-    <div>
-        <form>
-            <table>
-                <tr>
-                    <td>
-                        <div class="profile-avatar">
-                            <img  ng-src="{{ newProfileInfo.avatar || 'img/default-profile.jpg' }}" m-file-input="newProfileInfo.avatarFile"/>
-                        </div>
-                    </td>
-                    <td>
-                         <!-- TODO: To enable once we have an upload server
-                        <button  m-file-input="newProfileInfo.avatarFile">Upload new Avatar</button> 
-                        or use an existing image URL:
-                         -->
-                        <div>
-                            <input size="40" ng-model="newProfileInfo.avatar" ng-enter="setAvatar(newProfileInfo.avatar)" placeholder="Image URL"/>
-                            <button ng-disabled="!newProfileInfo.avatar" ng-click="setAvatar(newProfileInfo.avatar)">Update Avatar</button>   
-                        </div>
-                    </td>
-                </tr>
-            </table>
-        </form>
-    </div>
-
-    <div>
-        <form>
-            <input size="40" ng-model="newProfileInfo.name" ng-enter="setDisplayName(newProfileInfo.name)" />
-            <button ng-disabled="!newProfileInfo.name" ng-click="setDisplayName(newProfileInfo.name)">Update Name</button>    
-        </form>
-    </div>
-
-    <br/>
-
-    <div>
-        <form>
-            <input size="40" ng-model="linkedEmails.linkNewEmail" ng-enter="linkEmail(linkedEmails.linkNewEmail)" />
-            <button ng-disabled="!linkedEmails.linkNewEmail" ng-click="linkEmail(linkedEmails.linkNewEmail)">
-                Link Email
-            </button>
-            {{ emailFeedback }}    
-        </form>
-        <form ng-hide="!linkedEmails.emailBeingAuthed">
-            Enter validation token for {{ linkedEmails.emailBeingAuthed }}:
-            <br />
-            <input size="20" ng-model="linkedEmails.emailCode" ng-enter="submitEmailCode(linkedEmails.emailCode)" />
-            <button ng-disabled="!linkedEmails.emailCode || !linkedEmails.linkNewEmail" ng-click="submitEmailCode(linkedEmails.emailCode)">
-                Submit Code
-            </button>   
-        </form>
-        Linked emails:
-        <table>
-            <tr ng-repeat="(address, info) in linkedEmails.linkedEmailList">
-                <td>{{address}}</td>
-            </tr>
-        </table>
-    </div>
-    <br/>
-    
-    <h3>My rooms</h3>
-    
-    <div class="rooms" ng-repeat="(rm_id, room) in rooms">
-        <div>
-            <a href="#/room/{{ room.room_alias ? room.room_alias : rm_id }}" >{{ room.room_alias }}</a> {{room.membership === 'invite' ? ' (invited)' : ''}}
-        </div>
-    </div>
-    <br/>
-
-    <h3>Public rooms</h3>
-    
-    <div class="public_rooms" ng-repeat="room in public_rooms">
-        <div>
-            <a href="#/room/{{ room.room_alias ? room.room_alias : room.room_id }}" >{{ room.room_alias }}</a>
-        </div>
-    </div>
-    <br/>
-    
-    <div>
-        <form>
-            <input size="40" ng-model="newRoom.room_id" ng-enter="createNewRoom(newRoom.room_id, newRoom.private)" placeholder="(e.g. foo_channel)"/>
-            <input type="checkbox" ng-model="newRoom.private">private
-            <button ng-disabled="!newRoom.room_id" ng-click="createNewRoom(newRoom.room_id, newRoom.private)">Create room</button>    
-        </form>
-    </div>
-    <div>
-        <form>
-            <input size="40" ng-model="joinAlias.room_alias" ng-enter="joinAlias(joinAlias.room_alias)" placeholder="(e.g. #foo_channel:example.org)"/>
-            <button ng-disabled="!joinAlias.room_alias" ng-click="joinAlias(joinAlias.room_alias)">Join room</button>    
-        </form>
-    </div>
-    <br/>
-    
-    {{ feedback }}
-
-    </div>    
-    </div>
-</div>
diff --git a/webclient/settings/settings-controller.js b/webclient/settings/settings-controller.js
new file mode 100644
index 0000000000..5d3f7cb2b8
--- /dev/null
+++ b/webclient/settings/settings-controller.js
@@ -0,0 +1,146 @@
+/*
+Copyright 2014 matrix.org
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+angular.module('SettingsController', ['matrixService', 'mFileUpload'])
+.controller('SettingsController', ['$scope', 'matrixService', 'mFileUpload',
+                              function($scope, matrixService, mFileUpload) {                 
+    $scope.config = matrixService.config();
+
+    $scope.profile = {
+        displayName: $scope.config.displayName,
+        avatarUrl: $scope.config.avatarUrl
+    };
+
+    $scope.$watch("profile.avatarFile", function(newValue, oldValue) {
+        if ($scope.profile.avatarFile) {
+            console.log("Uploading new avatar file...");
+            mFileUpload.uploadFile($scope.profile.avatarFile).then(
+                function(url) {
+                    $scope.profile.avatarUrl = url;
+                },
+                function(error) {
+                    $scope.feedback = "Can't upload image";
+                } 
+            );
+        }
+    });
+    
+    $scope.saveProfile = function() {
+        if ($scope.profile.displayName !== $scope.config.displayName) {
+            setDisplayName($scope.profile.displayName);
+        }
+        if ($scope.profile.avatarUrl !== $scope.config.avatarUrl) {
+            setAvatar($scope.profile.avatarUrl);
+        }
+    };
+    
+    var setDisplayName = function(displayName) {
+        matrixService.setDisplayName(displayName).then(
+            function(response) {
+                $scope.feedback = "Updated display name.";
+                
+                var config = matrixService.config();
+                config.displayName = displayName;
+                matrixService.setConfig(config);
+                matrixService.saveConfig();
+            },
+            function(error) {
+                $scope.feedback = "Can't update display name: " + error.data;
+            }
+        );
+    };
+
+    var setAvatar = function(avatarURL) {
+        console.log("Updating avatar to " + avatarURL);
+        matrixService.setProfilePictureUrl(avatarURL).then(
+            function(response) {
+                console.log("Updated avatar");
+                $scope.feedback = "Updated avatar.";
+                
+                var config = matrixService.config();
+                config.avatarUrl = avatarURL;
+                matrixService.setConfig(config);
+                matrixService.saveConfig();
+            },
+            function(error) {
+                $scope.feedback = "Can't update avatar: " + error.data;
+            }
+        );
+    };
+
+    $scope.linkedEmails = {
+        linkNewEmail: "", // the email entry box
+        emailBeingAuthed: undefined, // to populate verification text
+        authTokenId: undefined, // the token id from the IS
+        emailCode: "", // the code entry box
+        linkedEmailList: matrixService.config().emailList // linked email list
+    };
+    
+    $scope.linkEmail = function(email) {
+        matrixService.linkEmail(email).then(
+            function(response) {
+                if (response.data.success === true) {
+                    $scope.linkedEmails.authTokenId = response.data.tokenId;
+                    $scope.emailFeedback = "You have been sent an email.";
+                    $scope.linkedEmails.emailBeingAuthed = email;
+                }
+                else {
+                    $scope.emailFeedback = "Failed to send email.";
+                }
+            },
+            function(error) {
+                $scope.emailFeedback = "Can't send email: " + error.data;
+            }
+        );
+    };
+
+    $scope.submitEmailCode = function(code) {
+        var tokenId = $scope.linkedEmails.authTokenId;
+        if (tokenId === undefined) {
+            $scope.emailFeedback = "You have not requested a code with this email.";
+            return;
+        }
+        matrixService.authEmail(matrixService.config().user_id, tokenId, code).then(
+            function(response) {
+                if ("success" in response.data && response.data.success === false) {
+                    $scope.emailFeedback = "Failed to authenticate email.";
+                    return;
+                }
+                var config = matrixService.config();
+                var emailList = {};
+                if ("emailList" in config) {
+                    emailList = config.emailList;
+                }
+                emailList[response.address] = response;
+                // save the new email list
+                config.emailList = emailList;
+                matrixService.setConfig(config);
+                matrixService.saveConfig();
+                // invalidate the email being authed and update UI.
+                $scope.linkedEmails.emailBeingAuthed = undefined;
+                $scope.emailFeedback = "";
+                $scope.linkedEmails.linkedEmailList = emailList;
+                $scope.linkedEmails.linkNewEmail = "";
+                $scope.linkedEmails.emailCode = "";
+            },
+            function(reason) {
+                $scope.emailFeedback = "Failed to auth email: " + reason;
+            }
+        );
+    };
+}]);
\ No newline at end of file
diff --git a/webclient/settings/settings.html b/webclient/settings/settings.html
new file mode 100644
index 0000000000..453a4fc35f
--- /dev/null
+++ b/webclient/settings/settings.html
@@ -0,0 +1,73 @@
+<div ng-controller="SettingsController" class="user">
+
+    <div id="page">
+    <div id="wrapper">
+        
+        <h3>Me</h3>
+        <div>
+            <form>
+                <table>
+                    <tr>
+                        <td>
+                            <div class="profile-avatar">
+                                <img ng-src="{{ profile.avatarUrl || 'img/default-profile.jpg' }}" m-file-input="profile.avatarFile"/>
+                            </div>
+                        </td>
+                        <td>
+                            <div id="user-ids">
+                                <input size="40" ng-model="profile.displayName" placeholder="Your name"/>            
+                            </div>
+                        </td>
+                        <td>
+                            <button ng-disabled="(profile.displayName == config.displayName) && (profile.avatarUrl == config.avatarUrl)"
+                                    ng-click="saveProfile()">Save</button>    
+                        </td>
+                    </tr>
+                </table>
+            </form>
+        </div>
+        <br/>
+
+        <h3>Linked emails</h3>
+        <div>
+            <form>
+                <input size="40" ng-model="linkedEmails.linkNewEmail" ng-enter="linkEmail(linkedEmails.linkNewEmail)" />
+                <button ng-disabled="!linkedEmails.linkNewEmail" ng-click="linkEmail(linkedEmails.linkNewEmail)">
+                    Link Email
+                </button>
+                {{ emailFeedback }}    
+            </form>
+            <form ng-hide="!linkedEmails.emailBeingAuthed">
+                Enter validation token for {{ linkedEmails.emailBeingAuthed }}:
+                <br />
+                <input size="20" ng-model="linkedEmails.emailCode" ng-enter="submitEmailCode(linkedEmails.emailCode)" />
+                <button ng-disabled="!linkedEmails.emailCode || !linkedEmails.linkNewEmail" ng-click="submitEmailCode(linkedEmails.emailCode)">
+                    Submit Code
+                </button>   
+            </form>
+            <table>
+                <tr ng-repeat="(address, info) in linkedEmails.linkedEmailList">
+                    <td>{{address}}</td>
+                </tr>
+            </table>
+        </div>
+        <br/>
+
+        <h3>Configuration</h3>
+        <div>
+            <div>Home server: {{ config.homeserver }} </div>
+            <div>User ID: {{ config.user_id }} </div>
+            <div>Access token: {{ config.access_token }} </div>
+        </div>
+        <br/>
+        
+        <div>
+            <div><button ng-click="requestNotifications()">Request notifications</button></div>
+        </div>
+        <br/>
+
+        {{ feedback }}
+
+    </div>    
+    </div>
+</div>
diff --git a/webclient/user/user.html b/webclient/user/user.html
index 47db09d1ee..4c91c8a48a 100644
--- a/webclient/user/user.html
+++ b/webclient/user/user.html
@@ -1,4 +1,5 @@
 <div ng-controller="UserController" class="user">
+    <h1 id="logo">[matrix]</h1>
 
     <div id="page">
     <div id="wrapper">