summary refs log tree commit diff
path: root/syweb/webclient/room
diff options
context:
space:
mode:
Diffstat (limited to 'syweb/webclient/room')
-rw-r--r--syweb/webclient/room/room-controller.js1094
-rw-r--r--syweb/webclient/room/room-directive.js188
-rw-r--r--syweb/webclient/room/room.html279
3 files changed, 1561 insertions, 0 deletions
diff --git a/syweb/webclient/room/room-controller.js b/syweb/webclient/room/room-controller.js
new file mode 100644
index 0000000000..d3fb85b9dc
--- /dev/null
+++ b/syweb/webclient/room/room-controller.js
@@ -0,0 +1,1094 @@
+/*
+Copyright 2014 OpenMarket Ltd
+
+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.
+*/
+
+angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
+.controller('RoomController', ['$modal', '$filter', '$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'mPresence', 'eventHandlerService', 'mFileUpload', 'matrixPhoneService', 'MatrixCall', 'notificationService', 'modelService',
+                               function($modal, $filter, $scope, $timeout, $routeParams, $location, $rootScope, matrixService, mPresence, eventHandlerService, mFileUpload, matrixPhoneService, MatrixCall, notificationService, modelService) {
+   'use strict';
+    var MESSAGES_PER_PAGINATION = 30;
+    var THUMBNAIL_SIZE = 320;
+    
+    // .html needs this
+    $scope.containsBingWord = function(content) {
+        return notificationService.containsBingWord(
+            matrixService.config().user_id,
+            matrixService.config().display_name,
+            matrixService.config().bingWords,
+            content
+        );
+    };
+
+    // Room ids. Computed and resolved in onInit
+    $scope.room_id = undefined;
+    $scope.room_alias = undefined;
+
+    $scope.state = {
+        user_id: matrixService.config().user_id,
+        permission_denied: undefined, // If defined, this string contains the reason why the user cannot join the room
+        first_pagination: true, // this is toggled off when the first pagination is done
+        can_paginate: false, // this is toggled off when we are not ready yet to paginate or 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
+        waiting_for_joined_event: false,  // true when the join request is pending. Back to false once the corresponding m.room.member event is received
+        messages_visibility: "hidden", // In order to avoid flickering when scrolling down the message table at the page opening, delay the message table display
+    };
+    $scope.members = {};
+    $scope.autoCompleting = false;
+    $scope.autoCompleteIndex = 0;    
+    $scope.autoCompleteOriginal = "";
+
+    $scope.imageURLToSend = "";
+    $scope.userIDToInvite = "";
+    
+
+    // vars and functions for updating the name
+    $scope.name = {
+        isEditing: false,
+        newNameText: "",
+        editName: function() {
+            if ($scope.name.isEditing) {
+                console.log("Warning: Already editing name.");
+                return;
+            };
+
+            var nameEvent = $scope.room.current_room_state.state_events['m.room.name'];
+            if (nameEvent) {
+                $scope.name.newNameText = nameEvent.content.name;
+            }
+            else {
+                $scope.name.newNameText = "";
+            }
+
+            // Force focus to the input
+            $timeout(function() {
+                angular.element('.roomNameInput').focus(); 
+            }, 0);
+
+            $scope.name.isEditing = true;
+        },
+        updateName: function() {
+            console.log("Updating name to "+$scope.name.newNameText);
+            matrixService.setName($scope.room_id, $scope.name.newNameText).then(
+                function() {
+                },
+                function(error) {
+                    $scope.feedback = "Request failed: " + error.data.error;
+                }
+            );
+
+            $scope.name.isEditing = false;
+        },
+        cancelEdit: function() {
+            $scope.name.isEditing = false;
+        }
+    };
+
+    // vars and functions for updating the topic
+    $scope.topic = {
+        isEditing: false,
+        newTopicText: "",
+        editTopic: function() {
+            if ($scope.topic.isEditing) {
+                console.log("Warning: Already editing topic.");
+                return;
+            }
+            var topicEvent = $scope.room.current_room_state.state_events['m.room.topic'];
+            if (topicEvent) {
+                $scope.topic.newTopicText = topicEvent.content.topic;
+            }
+            else {
+                $scope.topic.newTopicText = "";
+            }
+            
+            // Force focus to the input
+            $timeout(function() {
+                angular.element('.roomTopicInput').focus(); 
+            }, 0);
+            
+            $scope.topic.isEditing = true;
+        },
+        updateTopic: function() {
+            console.log("Updating topic to "+$scope.topic.newTopicText);
+            matrixService.setTopic($scope.room_id, $scope.topic.newTopicText).then(
+                function() {
+                },
+                function(error) {
+                    $scope.feedback = "Request failed: " + error.data.error;
+                }
+            );
+
+            $scope.topic.isEditing = false;
+        },
+        cancelEdit: function() {
+            $scope.topic.isEditing = false;
+        }
+    };  
+    
+    var scrollToBottom = function(force) {
+        console.log("Scrolling to bottom");
+        
+        // Do not autoscroll to the bottom to display the new event if the user is not at the bottom.
+        // Exception: in case where the event is from the user, we want to force scroll to the bottom
+        var objDiv = document.getElementById("messageTableWrapper");
+        // add a 10px buffer to this check so if the message list is not *quite*
+        // at the bottom it still scrolls since it basically is at the bottom.
+        if ((10 + objDiv.offsetHeight + objDiv.scrollTop >= objDiv.scrollHeight) || force) {
+            
+            $timeout(function() {
+                objDiv.scrollTop = objDiv.scrollHeight;
+
+                // Show the message table once the first scrolldown is done 
+                if ("visible" !== $scope.state.messages_visibility) {
+                    $timeout(function() {
+                        $scope.state.messages_visibility = "visible";
+                    }, 0);
+                }
+            }, 0);
+        }
+    };
+
+    $scope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) {
+        if (isLive && event.room_id === $scope.room_id) {
+            
+            scrollToBottom();
+        }
+    });
+    
+    $scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) {
+        if (isLive && event.room_id === $scope.room_id) {
+            if ($scope.state.waiting_for_joined_event) {
+                // The user has successfully joined the room, we can getting data for this room
+                $scope.state.waiting_for_joined_event = false;
+                onInit3();
+            }
+            else if (event.state_key === $scope.state.user_id && "invite" !== event.membership && "join" !== event.membership) {
+                var user;
+                        
+                if ($scope.members[event.user_id]) {
+                    user = $scope.members[event.user_id].displayname;
+                }
+                if (user) {
+                    user = user + " (" + event.user_id + ")";
+                }
+                else {
+                    user = event.user_id;
+                }
+                 
+                if ("ban" === event.membership) {
+                    $scope.state.permission_denied = "You have been banned by " + user;
+                }
+                else {
+                    $scope.state.permission_denied = "You have been kicked by " + user;
+                }  
+            }
+            else {
+                scrollToBottom();
+                updateMemberList(event); 
+
+                // Notify when a user joins
+                if ((document.hidden  || matrixService.presence.unavailable === mPresence.getState())
+                        && event.state_key !== $scope.state.user_id  && "join" === event.membership) {
+                    var userName = event.content.displayname;
+                    if (!userName) {
+                        userName = event.state_key;
+                    }
+                    notificationService.showNotification(
+                        userName +
+                        " (" + $filter("mRoomName")(event.room_id) + ")",
+                        userName + " joined",
+                        event.content.avatar_url ? event.content.avatar_url : undefined,
+                        function() {
+                            console.log("notification.onclick() room=" + event.room_id);
+                            $rootScope.goToPage('room/' + event.room_id); 
+                        }
+                    );
+                }
+            }
+        }
+    });
+    
+    $scope.$on(eventHandlerService.PRESENCE_EVENT, function(ngEvent, event, isLive) {
+        if (isLive) {
+            updatePresence(event);
+        }
+    });
+    
+    $scope.$on(eventHandlerService.POWERLEVEL_EVENT, function(ngEvent, event, isLive) {
+        if (isLive && event.room_id === $scope.room_id) {
+            for (var user_id in event.content) {
+                updateUserPowerLevel(user_id);
+            }
+        }
+    });
+
+    $scope.memberCount = function() {
+        return Object.keys($scope.members).length;
+    };
+    
+    $scope.paginateMore = function() {
+        if ($scope.state.can_paginate) {
+            // console.log("Paginating more.");
+            paginate(MESSAGES_PER_PAGINATION);
+        }
+    };
+
+    var paginate = function(numItems) {
+        //console.log("paginate " + numItems + " and first_pagination is " + $scope.state.first_pagination);
+        if ($scope.state.paginating || !$scope.room_id) {
+            return;
+        }
+        else {
+            $scope.state.paginating = true;
+        }
+        
+        console.log("paginateBackMessages from " + $scope.room.old_room_state.pagination_token + " for " + numItems);
+        var originalTopRow = $("#messageTable>tbody>tr:first")[0];
+        
+        // Paginate events from the point in cache
+        matrixService.paginateBackMessages($scope.room_id, $scope.room.old_room_state.pagination_token, numItems).then(
+            function(response) {
+
+                eventHandlerService.handleRoomMessages($scope.room_id, response.data, false, 'b');
+                if (response.data.chunk.length < MESSAGES_PER_PAGINATION) {
+                    // no more messages to paginate. this currently never gets turned true again, as we never
+                    // expire paginated contents in the current implementation.
+                    $scope.state.can_paginate = false;
+                }
+                
+                $scope.state.paginating = false;
+                
+                var wrapper = $("#messageTableWrapper")[0];
+                var table = $("#messageTable")[0];
+                // console.log("wrapper height=" + wrapper.clientHeight + ", table scrollHeight=" + table.scrollHeight);
+                
+                if ($scope.state.can_paginate) {
+                    // check we don't have to pull in more messages
+                    // n.b. we dispatch through a timeout() to allow the digest to run otherwise the .height methods are stale
+                    $timeout(function() {
+                        if (table.scrollHeight < wrapper.clientHeight) {
+                            paginate(MESSAGES_PER_PAGINATION);
+                            scrollToBottom();                            
+                        }
+                    }, 0);
+                }
+                
+                if ($scope.state.first_pagination) {
+                    scrollToBottom(true);
+                    $scope.state.first_pagination = false;
+                }
+                else {
+                    // lock the scroll position
+                    $timeout(function() {
+                        // FIXME: this risks a flicker before the scrollTop is actually updated, but we have to
+                        // dispatch it into a function in order to first update the layout.  The right solution
+                        // might be to implement it as a directive, more like
+                        // http://stackoverflow.com/questions/23736647/how-to-retain-scroll-position-of-ng-repeat-in-angularjs
+                        // however, this specific solution breaks because it measures the rows height before
+                        // the contents are interpolated.
+                        wrapper.scrollTop = originalTopRow ? (originalTopRow.offsetTop + wrapper.scrollTop) : 0;
+                    }, 0);
+                }
+            },
+            function(error) {
+                console.log("Failed to paginateBackMessages: " + JSON.stringify(error));
+                $scope.state.paginating = false;
+            }
+        );
+    };
+
+    var updateMemberList = function(chunk) {
+        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) {
+            
+            // Ignore banned and kicked (leave) people
+            if ("ban" === chunk.membership || "leave" === chunk.membership) {
+                return;
+            }
+        
+            // FIXME: why are we copying these fields around inside chunk?
+            if ("presence" in chunk.content) {
+                chunk.presence = chunk.content.presence;
+            }
+            if ("last_active_ago" in chunk.content) {
+                chunk.last_active_ago = chunk.content.last_active_ago;
+                $scope.now = new Date().getTime();
+                chunk.last_updated = $scope.now;
+            }
+            if ("displayname" in chunk.content) {
+                chunk.displayname = chunk.content.displayname;
+            }
+            if ("avatar_url" in chunk.content) {
+                chunk.avatar_url = chunk.content.avatar_url;
+            }
+            $scope.members[target_user_id] = chunk;   
+
+            if (target_user_id in $rootScope.presence) {
+                updatePresence($rootScope.presence[target_user_id]);
+            }
+        }
+        else {
+            // selectively update membership and presence else it will nuke the picture and displayname too :/
+            
+            // Remove banned and kicked (leave) people
+            if ("ban" === chunk.membership || "leave" === chunk.membership) {
+                delete $scope.members[target_user_id];
+                return;
+            }
+            
+            var member = $scope.members[target_user_id];
+            member.membership = chunk.content.membership;
+            if ("presence" in chunk.content) {
+                member.presence = chunk.content.presence;
+            }
+            if ("last_active_ago" in chunk.content) {
+                member.last_active_ago = chunk.content.last_active_ago;
+                $scope.now = new Date().getTime();
+                member.last_updated = $scope.now;
+            }
+        }
+    };
+    
+    var updateMemberListPresenceAge = function() {
+        $scope.now = new Date().getTime();
+        // TODO: don't bother polling every 5s if we know none of our counters are younger than 1 minute
+        $timeout(updateMemberListPresenceAge, 5 * 1000);
+    };
+
+    var updatePresence = function(chunk) {
+        if (!(chunk.content.user_id in $scope.members)) {
+            console.log("updatePresence: Unknown member for chunk " + JSON.stringify(chunk));
+            return;
+        }
+        var member = $scope.members[chunk.content.user_id];
+
+        // XXX: why not just pass the chunk straight through?
+        if ("presence" in chunk.content) {
+            member.presence = chunk.content.presence;
+        }
+
+        if ("last_active_ago" in chunk.content) {
+            member.last_active_ago = chunk.content.last_active_ago;
+            $scope.now = new Date().getTime();
+            member.last_updated = $scope.now;
+        }
+
+        // this may also contain a new display name or avatar url, so check.
+        if ("displayname" in chunk.content) {
+            member.displayname = chunk.content.displayname;
+        }
+
+        if ("avatar_url" in chunk.content) {
+            member.avatar_url = chunk.content.avatar_url;
+        }
+    };
+
+    var updateUserPowerLevel = function(user_id) {
+        var member = $scope.members[user_id];
+        if (member) {
+            member.powerLevel = eventHandlerService.getUserPowerLevel($scope.room_id, user_id);
+            
+            normaliseMembersPowerLevels();
+        }
+    };
+
+    // Normalise users power levels so that the user with the higher power level
+    // will have a bar covering 100% of the width of his avatar
+    var normaliseMembersPowerLevels = function() {
+        // Find the max power level
+        var maxPowerLevel = 0;
+        for (var i in $scope.members) {
+            if (!$scope.members.hasOwnProperty(i)) continue;
+
+            var member = $scope.members[i];
+            if (member.powerLevel) {
+                maxPowerLevel = Math.max(maxPowerLevel, member.powerLevel);
+            }
+        }
+
+        // Normalized them on a 0..100% scale to be use in css width
+        if (maxPowerLevel) {
+            for (var i in $scope.members) {
+                if (!$scope.members.hasOwnProperty(i)) continue;
+
+                var member = $scope.members[i];
+                member.powerLevelNorm = (member.powerLevel * 100) / maxPowerLevel;
+            }
+        }
+    };
+
+    $scope.send = function() {
+        var input = $('#mainInput').val();
+        
+        if (undefined === input || input === "") {
+            return;
+        }
+        
+        scrollToBottom(true);
+
+        // Store the command in the history
+        history.push(input);
+
+        var promise;
+        var cmd;
+        var args;
+        var echo = false;
+        
+        // Check for IRC style commands first
+        // trim any trailing whitespace, as it can confuse the parser for IRC-style commands
+        input = input.replace(/\s+$/, "");
+        
+        if (input[0] === "/" && input[1] !== "/") {
+            var bits = input.match(/^(\S+?)( +(.*))?$/);
+            cmd = bits[1];
+            args = bits[3];
+            
+            console.log("cmd: " + cmd + ", args: " + args);
+            
+            switch (cmd) {
+                case "/me":
+                    promise = matrixService.sendEmoteMessage($scope.room_id, args);
+                    echo = true;
+                    break;
+                    
+                case "/nick":
+                    // Change user display name
+                    if (args) {
+                        promise = matrixService.setDisplayName(args);                     
+                    }
+                    else {
+                        $scope.feedback = "Usage: /nick <display_name>";
+                    }
+                    break;
+
+                case "/join":
+                    // Join a room
+                    if (args) {
+                        var matches = args.match(/^(\S+)$/);
+                        if (matches) {
+                            var room_alias = matches[1];
+                            if (room_alias.indexOf(':') == -1) {
+                                // FIXME: actually track the :domain style name of our homeserver
+                                // with or without port as is appropriate and append it at this point
+                            }
+                            
+                            var room_id = matrixService.getAliasToRoomIdMapping(room_alias);
+                            console.log("joining " + room_alias + " id=" + room_id);
+                            if ($scope.room) { // TODO actually check that you = join
+                                // don't send a join event for a room you're already in.
+                                $location.url("room/" + room_alias);
+                            }
+                            else {
+                                promise = matrixService.joinAlias(room_alias).then(
+                                    function(response) {
+                                        // TODO: factor out the common housekeeping whenever we try to join a room or alias
+                                        matrixService.roomState(response.room_id).then(
+                                            function(response) {
+                                                eventHandlerService.handleEvents(response.data, false, true);
+                                            },
+                                            function(error) {
+                                                $scope.feedback = "Failed to get room state for: " + response.room_id;
+                                            }
+                                        );                                        
+                                        $location.url("room/" + room_alias);
+                                    },
+                                    function(error) {
+                                        $scope.feedback = "Can't join room: " + JSON.stringify(error.data);
+                                    }
+                                );
+                            }
+                        }
+                    }
+                    else {
+                        $scope.feedback = "Usage: /join <room_alias>";
+                    }
+                    break;
+                    
+                case "/kick":
+                    // Kick a user from the room with an optional reason
+                    if (args) {
+                        var matches = args.match(/^(\S+?)( +(.*))?$/);
+                        if (matches) {
+                            promise = matrixService.kick($scope.room_id, matches[1], matches[3]);
+                        }
+                    }
+
+                    if (!promise) {
+                        $scope.feedback = "Usage: /kick <userId> [<reason>]";
+                    }
+                    break;
+
+                case "/ban":
+                    // Ban a user from the room with an optional reason
+                    if (args) {
+                        var matches = args.match(/^(\S+?)( +(.*))?$/);
+                        if (matches) {
+                            promise = matrixService.ban($scope.room_id, matches[1], matches[3]);
+                        }
+                    }
+                    
+                    if (!promise) {
+                        $scope.feedback = "Usage: /ban <userId> [<reason>]";
+                    }
+                    break;
+
+                case "/unban":
+                    // Unban a user from the room
+                    if (args) {
+                        var matches = args.match(/^(\S+)$/);
+                        if (matches) {
+                            // Reset the user membership to "leave" to unban him
+                            promise = matrixService.unban($scope.room_id, matches[1]);
+                        }
+                    }
+                    
+                    if (!promise) {
+                        $scope.feedback = "Usage: /unban <userId>";
+                    }
+                    break;
+                    
+                case "/op":
+                    // Define the power level of a user
+                    if (args) {
+                        var matches = args.match(/^(\S+?)( +(\d+))?$/);
+                        var powerLevel = 50; // default power level for op
+                        if (matches) {
+                            var user_id = matches[1];
+                            if (matches.length === 4 && undefined !== matches[3]) {
+                                powerLevel = parseInt(matches[3]);
+                            }
+                            if (powerLevel !== NaN) {
+                                var powerLevelEvent = $scope.room.current_room_state.state("m.room.power_levels");
+                                promise = matrixService.setUserPowerLevel($scope.room_id, user_id, powerLevel, powerLevelEvent);
+                            }
+                        }
+                    }
+                    
+                    if (!promise) {
+                        $scope.feedback = "Usage: /op <userId> [<power level>]";
+                    }
+                    break;
+                    
+                case "/deop":
+                    // Reset the power level of a user
+                    if (args) {
+                        var matches = args.match(/^(\S+)$/);
+                        if (matches) {
+                            var powerLevelEvent = $scope.room.current_room_state.state("m.room.power_levels");
+                            promise = matrixService.setUserPowerLevel($scope.room_id, args, undefined, powerLevelEvent);
+                        }
+                    }
+                    
+                    if (!promise) {
+                        $scope.feedback = "Usage: /deop <userId>";
+                    }
+                    break;
+                
+                default:
+                    $scope.feedback = ("Unrecognised IRC-style command: " + cmd);
+                    break;
+            }
+        }
+        
+        // By default send this as a message unless it's an IRC-style command
+        if (!promise && !cmd) {
+            // Make the request
+            promise = matrixService.sendTextMessage($scope.room_id, input);
+            echo = true;
+        }
+        
+        if (echo) {
+            // Echo the message to the room
+            // To do so, create a minimalist fake text message event and add it to the in-memory list of room messages
+            var echoMessage = {
+                content: {
+                    body: (cmd === "/me" ? args : input),
+                    msgtype: (cmd === "/me" ? "m.emote" : "m.text"),
+                },
+                origin_server_ts: new Date().getTime(), // fake a timestamp
+                room_id: $scope.room_id,
+                type: "m.room.message",
+                user_id: $scope.state.user_id,
+                echo_msg_state: "messagePending"     // Add custom field to indicate the state of this fake message to HTML
+            };
+
+            $('#mainInput').val('');
+            $scope.room.addMessageEvent(echoMessage);
+            scrollToBottom();
+        }
+
+        if (promise) {
+            // Reset previous feedback
+            $scope.feedback = "";
+
+            promise.then(
+                function(response) {
+                    console.log("Request successfully sent");
+
+                    if (echo) {
+                        // Mark this fake message event with its allocated event_id
+                        // When the true message event will come from the events stream (in handleMessage),
+                        // we will be able to replace the fake one by the true one
+                        echoMessage.event_id = response.data.event_id;
+                    }
+                    else {
+                        $('#mainInput').val('');
+                    }         
+                },
+                function(error) {
+                    $scope.feedback = "Request failed: " + error.data.error;
+
+                    if (echoMessage) {
+                        // Mark the message as unsent for the rest of the page life
+                        echoMessage.origin_server_ts = "Unsent";
+                        echoMessage.echo_msg_state = "messageUnSent";
+                    }
+                });
+        }
+    };
+
+    $scope.onInit = function() {
+        console.log("onInit");
+
+        // Does the room ID provided in the URL?
+        var room_id_or_alias;
+        if ($routeParams.room_id_or_alias) {
+            room_id_or_alias = decodeURIComponent($routeParams.room_id_or_alias);
+        }
+
+        if (room_id_or_alias && '!' === room_id_or_alias[0]) {
+            // Yes. We can go on right now
+            $scope.room_id = room_id_or_alias;
+            $scope.room_alias = matrixService.getRoomIdToAliasMapping($scope.room_id);
+            onInit2();
+        }
+        else {
+            // No. The URL contains the room alias. Get this alias.
+            if (room_id_or_alias) {
+                // The room alias was passed urlencoded, use it as is
+                $scope.room_alias = room_id_or_alias;
+            }
+            else  {
+                // Else get the room alias by hand from the URL
+                // ie: extract #public:localhost:8080 from http://127.0.0.1:8000/#/room/#public:localhost:8080
+                if (3 === location.hash.split("#").length) {
+                    $scope.room_alias = "#" + location.hash.split("#")[2];
+                }
+                else {
+                    // In case of issue, go to the default page
+                    console.log("Error: cannot extract room alias");
+                    $location.url("/");
+                    return;
+                }
+            }
+            
+            // Need a room ID required in Matrix API requests
+            console.log("Resolving alias: " + $scope.room_alias);
+            matrixService.resolveRoomAlias($scope.room_alias).then(function(response) {
+                $scope.room_id = response.data.room_id;
+                console.log("   -> Room ID: " + $scope.room_id);
+
+                // Now, we can go on
+                onInit2();
+            },
+            function () {
+                // In case of issue, go to the default page
+                console.log("Error: cannot resolve room alias");
+                $location.url("/");
+            });
+        }
+    };
+    
+    var onInit2 = function() {
+        console.log("onInit2");
+        // =============================
+        $scope.room = modelService.getRoom($scope.room_id);
+        // =============================
+        
+        // Scroll down as soon as possible so that we point to the last message
+        // if it already exists in memory
+        scrollToBottom(true);
+
+        // Make sure the initialSync has been before going further
+        eventHandlerService.waitForInitialSyncCompletion().then(
+            function() {
+                
+                var needsToJoin = true;
+                
+                // The room members is available in the data fetched by initialSync
+                if ($scope.room) {
+
+                    var messages = $scope.room.events;
+
+                    if (0 === messages.length
+                    || (1 === messages.length && "m.room.member" === messages[0].type && "invite" === messages[0].content.membership && $scope.state.user_id === messages[0].state_key)) {
+                        // If we just joined a room, we won't have this history from initial sync, so we should try to paginate it anyway    
+                        $scope.state.first_pagination = true;
+                    }
+                    else {
+                        // There is no need to do a 1st pagination (initialSync provided enough to fill a page)
+                        $scope.state.first_pagination = false;
+                    }
+
+                    var members = $scope.room.current_room_state.members;
+
+                    // Update the member list
+                    for (var i in members) {
+                        if (!members.hasOwnProperty(i)) continue;
+
+                        var member = members[i].event;
+                        updateMemberList(member);
+                    }
+
+                    // Check if the user has already join the room
+                    if ($scope.state.user_id in members) {
+                        if ("join" === members[$scope.state.user_id].event.content.membership) {
+                            needsToJoin = false;
+                        }
+                    }
+                }
+                
+                // Do we to join the room before starting?
+                if (needsToJoin) {
+                    $scope.state.waiting_for_joined_event = true;
+                    matrixService.join($scope.room_id).then(
+                        function() {
+                            // TODO: factor out the common housekeeping whenever we try to join a room or alias
+                            matrixService.roomState($scope.room_id).then(
+                                function(response) {
+                                    eventHandlerService.handleEvents(response.data, false, true);
+                                },
+                                function(error) {
+                                    console.error("Failed to get room state for: " + $scope.room_id);
+                                }
+                            );                                        
+                            
+                            // onInit3 will be called once the joined m.room.member event is received from the events stream
+                            // This avoids to get the joined information twice in parallel:
+                            //    - one from the events stream
+                            //    - one from the pagination because the pagination window covers this event ts
+                            console.log("Joined room "+$scope.room_id);
+                        },
+                        function(reason) {
+                            console.log("Can't join room: " + JSON.stringify(reason));
+                            // FIXME: what if it wasn't a perms problem?
+                            $scope.state.permission_denied = "You do not have permission to join this room";
+                        });
+                }
+                else {
+                    onInit3();
+                }
+            }
+        );
+    };
+
+    var onInit3 = function() {
+        console.log("onInit3");
+
+        // Make recents highlight the current room
+        $scope.recentsSelectedRoomID = $scope.room_id;
+
+        // Init the history for this room
+        history.init();
+
+        // Get the up-to-date the current member list
+        matrixService.getMemberList($scope.room_id).then(
+            function(response) {
+                for (var i = 0; i < response.data.chunk.length; i++) {
+                    var chunk = response.data.chunk[i];
+                    updateMemberList(chunk);
+
+                    // Add his power level
+                    updateUserPowerLevel(chunk.user_id);
+                }
+
+                // Arm list timing update timer
+                updateMemberListPresenceAge();
+
+                // Allow pagination
+                $scope.state.can_paginate = true;
+
+                // Do a first pagination only if it is required
+                // FIXME: Should be no more require when initialSync/{room_id} will be available
+                if ($scope.state.first_pagination) {
+                    paginate(MESSAGES_PER_PAGINATION);
+                }
+                else {
+                    // There are already messages, go to the last message
+                    scrollToBottom(true);
+                }
+            },
+            function(error) {
+                $scope.feedback = "Failed get member list: " + error.data.error;
+            }
+        );
+    }; 
+    
+    $scope.inviteUser = function() {
+        
+        matrixService.invite($scope.room_id, $scope.userIDToInvite).then(
+            function() {
+                console.log("Invited.");
+                $scope.feedback = "Invite successfully sent to " + $scope.userIDToInvite;
+                $scope.userIDToInvite = "";
+            },
+            function(reason) {
+                $scope.feedback = "Failure: " + reason.data.error;
+            });
+    };
+
+    $scope.leaveRoom = function() {
+        
+        matrixService.leave($scope.room_id).then(
+            function(response) {
+                console.log("Left room " + $scope.room_id);
+                $location.url("home");
+            },
+            function(error) {
+                $scope.feedback = "Failed to leave room: " + error.data.error;
+            });
+    };
+
+    $scope.sendImage = function(url, body) {
+        scrollToBottom(true);
+        
+        matrixService.sendImageMessage($scope.room_id, url, body).then(
+            function() {
+                console.log("Image sent");
+            },
+            function(error) {
+                $scope.feedback = "Failed to send image: " + error.data.error;
+            });
+    };
+    
+    $scope.imageFileToSend;
+    $scope.$watch("imageFileToSend", function(newValue, oldValue) {
+        if ($scope.imageFileToSend) {
+            // 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");
+                        },
+                        function(error) {
+                            $scope.feedback = "Failed to send image message: " + error.data.error;
+                        });
+                },
+                function(error) {
+                    $scope.feedback = "Can't upload image";
+                }
+            );
+        }
+    });
+    
+    $scope.loadMoreHistory = function() {
+        paginate(MESSAGES_PER_PAGINATION);
+    };
+
+    $scope.startVoiceCall = function() {
+        var call = new MatrixCall($scope.room_id);
+        call.onError = $rootScope.onCallError;
+        call.onHangup = $rootScope.onCallHangup;
+        // remote video element is used for playing audio in voice calls
+        call.remoteVideoElement = angular.element('#remoteVideo')[0];
+        call.placeVoiceCall();
+        $rootScope.currentCall = call;
+    };
+
+    $scope.startVideoCall = function() {
+        var call = new MatrixCall($scope.room_id);
+        call.onError = $rootScope.onCallError;
+        call.onHangup = $rootScope.onCallHangup;
+        call.localVideoElement = angular.element('#localVideo')[0];
+        call.remoteVideoElement = angular.element('#remoteVideo')[0];
+        call.placeVideoCall();
+        $rootScope.currentCall = call;
+    };
+
+    // Manage history of typed messages
+    // History is saved in sessionStoratge so that it survives when the user
+    // navigates through the rooms and when it refreshes the page
+    var history = {
+        // The list of typed messages. Index 0 is the more recents
+        data: [],
+
+        // The position in the history currently displayed
+        position: -1,
+
+        // The message the user has started to type before going into the history
+        typingMessage: undefined,
+
+        // Init/load data for the current room
+        init: function() {
+            var data = sessionStorage.getItem("history_" + $scope.room_id);
+            if (data) {
+                this.data = JSON.parse(data);
+            }
+        },
+
+        // Store a message in the history
+        push: function(message) {
+            this.data.unshift(message);
+
+            // Update the session storage
+            sessionStorage.setItem("history_" + $scope.room_id, JSON.stringify(this.data));
+
+            // Reset history position
+            this.position = -1;
+            this.typingMessage = undefined;
+        },
+
+        // Move in the history
+        go: function(offset) {
+
+            if (-1 === this.position) {
+                // User starts to go to into the history, save the current line
+                this.typingMessage = $('#mainInput').val();
+            }
+            else {
+                // If the user modified this line in history, keep the change
+                this.data[this.position] = $('#mainInput').val();
+            }
+
+            // Bounds the new position to valid data
+            var newPosition = this.position + offset;
+            newPosition = Math.max(-1, newPosition);
+            newPosition = Math.min(newPosition, this.data.length - 1);
+            this.position = newPosition;
+
+            if (-1 !== this.position) {
+                // Show the message from the history
+                $('#mainInput').val(this.data[this.position]);
+            }
+            else if (undefined !== this.typingMessage) {
+                // Go back to the message the user started to type
+                $('#mainInput').val(this.typingMessage);
+            }
+        }
+    };
+
+    // Make history singleton methods available from HTML
+    $scope.history = {
+        goUp: function($event) {
+            if ($scope.room_id) {
+                history.go(1);
+            }
+            $event.preventDefault();
+        },
+        goDown: function($event) {
+            if ($scope.room_id) {
+                history.go(-1);
+            }
+            $event.preventDefault();
+        }
+    };
+
+    $scope.openJson = function(content) {
+        $scope.event_selected = angular.copy(content);
+        
+        // FIXME: Pre-calculated event data should be stripped in a nicer way.
+        $scope.event_selected.__room_member = undefined;
+        $scope.event_selected.__target_room_member = undefined;
+        
+        // scope this so the template can check power levels and enable/disable
+        // buttons
+        $scope.pow = eventHandlerService.getUserPowerLevel;
+
+        var modalInstance = $modal.open({
+            templateUrl: 'eventInfoTemplate.html',
+            controller: 'EventInfoController',
+            scope: $scope
+        });
+
+        modalInstance.result.then(function(action) {
+            if (action === "redact") {
+                var eventId = $scope.event_selected.event_id;
+                console.log("Redacting event ID " + eventId);
+                matrixService.redactEvent(
+                    $scope.event_selected.room_id,
+                    eventId
+                ).then(function(response) {
+                    console.log("Redaction = " + JSON.stringify(response));
+                }, function(error) {
+                    console.error("Failed to redact event: "+JSON.stringify(error));
+                    if (error.data.error) {
+                        $scope.feedback = error.data.error;
+                    }
+                });
+            }
+        }, function() {
+            // any dismiss code
+        });
+    };
+
+    $scope.openRoomInfo = function() {
+        $scope.roomInfo = {};
+        $scope.roomInfo.newEvent = {
+            content: {},
+            type: "",
+            state_key: ""
+        };
+
+        var stateEvents = $scope.room.current_room_state.state_events;
+        // The modal dialog will 2-way bind this field, so we MUST make a deep
+        // copy of the state events else we will be *actually adjusing our view
+        // of the world* when fiddling with the JSON!! Apparently parse/stringify
+        // is faster than jQuery's extend when doing deep copies.
+        $scope.roomInfo.stateEvents = JSON.parse(JSON.stringify(stateEvents));
+        var modalInstance = $modal.open({
+            templateUrl: 'roomInfoTemplate.html',
+            controller: 'RoomInfoController',
+            size: 'lg',
+            scope: $scope
+        });
+    };
+
+}])
+.controller('EventInfoController', function($scope, $modalInstance) {
+    console.log("Displaying modal dialog for >>>> " + JSON.stringify($scope.event_selected));
+    $scope.redact = function() {
+        console.log("User level = "+$scope.pow($scope.room_id, $scope.state.user_id)+
+                    " Redact level = "+$scope.room.current_room_state.state_events["m.room.ops_levels"].content.redact_level);
+        console.log("Redact event >> " + JSON.stringify($scope.event_selected));
+        $modalInstance.close("redact");
+    };
+})
+.controller('RoomInfoController', function($scope, $modalInstance, $filter, matrixService) {
+    console.log("Displaying room info.");
+
+    $scope.submit = function(event) {
+        if (event.content) {
+            console.log("submit >>> " + JSON.stringify(event.content));
+            matrixService.sendStateEvent($scope.room_id, event.type, 
+                event.content, event.state_key).then(function(response) {
+                    $modalInstance.dismiss();
+                }, function(err) {
+                    $scope.feedback = err.data.error;
+                }
+            );
+        }
+    };
+
+    $scope.dismiss = $modalInstance.dismiss;
+
+});
diff --git a/syweb/webclient/room/room-directive.js b/syweb/webclient/room/room-directive.js
new file mode 100644
index 0000000000..05382cfcd3
--- /dev/null
+++ b/syweb/webclient/room/room-directive.js
@@ -0,0 +1,188 @@
+/*
+ Copyright 2014 OpenMarket Ltd
+ 
+ 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('RoomController')
+.directive('tabComplete', ['$timeout', function ($timeout) {
+    return function (scope, element, attrs) {
+        element.bind("keydown keypress", function (event) {
+            // console.log("event: " + event.which);
+            var TAB = 9;
+            var SHIFT = 16;
+            var keypressCode = event.which;
+            if (keypressCode === TAB) {
+                if (!scope.tabCompleting) { // cache our starting text
+                    scope.tabCompleteOriginal = element[0].value;
+                    scope.tabCompleting = true;
+                    scope.tabCompleteIndex = 0;
+                }
+                
+                // loop in the right direction
+                if (event.shiftKey) {
+                    scope.tabCompleteIndex--;
+                    if (scope.tabCompleteIndex < 0) {
+                        // wrap to the last search match, and fix up to a real
+                        // index value after we've matched
+                        scope.tabCompleteIndex = Number.MAX_VALUE;
+                    }
+                }
+                else {
+                    scope.tabCompleteIndex++;
+                }
+                
+
+                var searchIndex = 0;
+                var targetIndex = scope.tabCompleteIndex;
+                var text = scope.tabCompleteOriginal;
+                
+                // console.log("targetIndex: " + targetIndex + ", 
+                // text=" + text);
+                                    
+                // FIXME: use the correct regexp to recognise userIDs --M
+                //
+                // XXX: I don't really know what the point of this is. You
+                // WANT to match freeform text given you want to match display
+                // names AND user IDs. Surely you just want to get the last
+                // word out of the input text and that's that?
+                // Am I missing something here? -- Kegan
+                //
+                // You're not missing anything - my point was that we should
+                // explicitly define the syntax for user IDs /somewhere/.
+                // Meanwhile as long as the delimeters are well defined, we
+                // could just pick "the last word".  But to know what the
+                // correct delimeters are, we probably do need a formal
+                // syntax for user IDs to refer to... --Matthew
+                
+                var search = /@?([a-zA-Z0-9_\-:\.]+)$/.exec(text);
+
+                if (targetIndex === 0) { // 0 is always the original text
+                    element[0].value = text;                    
+                    // Force angular to wake up and update the input ng-model 
+                    // by firing up input event
+                    angular.element(element[0]).triggerHandler('input');
+                }
+                else if (search && search[1]) {
+                    // console.log("search found: " + search+" from "+text);
+                    var expansion;
+                    
+                    // FIXME: could do better than linear search here
+                    angular.forEach(scope.members, function(item, name) {
+                        if (item.displayname && searchIndex < targetIndex) {
+                            if (item.displayname.toLowerCase().indexOf(search[1].toLowerCase()) === 0) {
+                                expansion = item.displayname;
+                                searchIndex++;
+                            }
+                        }
+                    });
+                    if (searchIndex < targetIndex) { // then search raw mxids
+                        angular.forEach(scope.members, function(item, name) {
+                            if (searchIndex < targetIndex) {
+                                // === 1 because mxids are @username
+                                if (name.toLowerCase().indexOf(search[1].toLowerCase()) === 1) {
+                                    expansion = name;
+                                    searchIndex++;
+                                }
+                            }
+                        });
+                    }
+                    
+                    if (searchIndex === targetIndex || 
+                            targetIndex === Number.MAX_VALUE) {
+                        // xchat-style tab complete, add a colon if tab 
+                        // completing at the start of the text
+                        if (search[0].length === text.length)
+                            expansion += ": ";
+                        else
+                            expansion += " ";
+                        element[0].value = text.replace(/@?([a-zA-Z0-9_\-:\.]+)$/, expansion);
+                        // cancel blink
+                        element[0].className = "";     
+                        if (targetIndex === Number.MAX_VALUE) {
+                            // wrap the index around to the last index found
+                            scope.tabCompleteIndex = searchIndex;
+                            targetIndex = searchIndex;
+                        }
+                    }
+                    else {
+                        // console.log("wrapped!");
+                        element[0].className = "blink"; // XXX: slightly naughty to bypass angular
+                        $timeout(function() {
+                             element[0].className = "";
+                        }, 150);
+                        element[0].value = text;
+                        scope.tabCompleteIndex = 0;
+                    }
+
+                    // Force angular to wak up and update the input ng-model by
+                    // firing up input event
+                    angular.element(element[0]).triggerHandler('input');
+                }
+                else {
+                    scope.tabCompleteIndex = 0;
+                }
+                // prevent the default TAB operation (typically focus shifting)
+                event.preventDefault();
+            }
+            else if (keypressCode !== SHIFT && scope.tabCompleting) {
+                scope.tabCompleting = false;
+                scope.tabCompleteIndex = 0;
+            }
+        });
+    };
+}])
+.directive('commandHistory', [ function() {
+    return function (scope, element, attrs) {
+        element.bind("keydown", function (event) {
+            var keycodePressed = event.which;
+            var UP_ARROW = 38;
+            var DOWN_ARROW = 40;
+            if (keycodePressed === UP_ARROW) {
+                scope.history.goUp(event);
+            }
+            else if (keycodePressed === DOWN_ARROW) {
+                scope.history.goDown(event);
+            } 
+        });
+    }
+}])
+
+// A directive to anchor the scroller position at the bottom when the browser is resizing.
+// When the screen resizes, the bottom of the element remains the same, not the top.
+.directive('keepScroll', ['$window', function($window) {
+    return {
+        link: function(scope, elem, attrs) {
+
+            scope.windowHeight = $window.innerHeight;
+
+            // Listen to window size change
+            angular.element($window).bind('resize', function() {
+
+                // If the scroller is scrolled to the bottom, there is nothing to do.
+                // The browser will move it as expected
+                if (elem.scrollTop() + elem.height() !== elem[0].scrollHeight) {
+                    // Else, move the scroller position according to the window height change delta
+                    var windowHeightDelta = $window.innerHeight - scope.windowHeight;
+                    elem.scrollTop(elem.scrollTop() - windowHeightDelta);
+                }
+
+                // Store the new window height for the next screen size change
+                scope.windowHeight = $window.innerHeight;
+            });
+        }
+    };
+}]);
+
diff --git a/syweb/webclient/room/room.html b/syweb/webclient/room/room.html
new file mode 100644
index 0000000000..e59cc30edc
--- /dev/null
+++ b/syweb/webclient/room/room.html
@@ -0,0 +1,279 @@
+<div ng-controller="RoomController" data-ng-init="onInit()" class="room" style="height: 100%;">
+
+    <script type="text/ng-template" id="eventInfoTemplate.html">
+        <div class="modal-body">
+            <pre> {{event_selected | json}} </pre>
+        </div>
+        <div class="modal-footer">
+            <button ng-click="redact()" type="button" class="btn btn-danger" 
+             ng-disabled="!room.current_room_state.state('m.room.ops_levels').content.redact_level || !pow(room_id, state.user_id) || pow(room_id, state.user_id) < room.current_room_state.state('m.room.ops_levels').content.redact_level"
+             title="Delete this event on all home servers. This cannot be undone.">
+                Redact
+            </button>        
+        </div>
+    </script>
+
+    <script type="text/ng-template" id="roomInfoTemplate.html">
+        <div class="modal-body">
+            <table class="room-info">
+            <tr ng-repeat="(key, event) in roomInfo.stateEvents" class="room-info-event">
+                <td class="room-info-event-meta" width="30%">
+                    <span class="monospace">{{ event.type }}</span>
+                    <span ng-show="event.state_key" class="monospace"> ({{event.state_key}})</span>
+                    <br/>
+                    {{ (event.origin_server_ts) | date:'MMM d HH:mm' }}
+                    <br/>
+                    Set by: <span class="monospace">{{ event.user_id }}</span>
+                    <br/>
+                    <span ng-show="event.required_power_level >= 0">Required power level: {{event.required_power_level}}<br/></span>
+                    <button ng-click="submit(event)" type="button" class="btn btn-success" ng-disabled="!event.content">
+                        Submit
+                    </button>
+                </td>
+                <td class="room-info-event-content" width="70%">
+                    <textarea class="room-info-textarea-content" msd-elastic ng-model="event.content" asjson></textarea> 
+                </td>
+            </tr>
+            <tr>
+                <td class="room-info-event-meta" width="30%">
+                    <input ng-model="roomInfo.newEvent.type" placeholder="your.event.type" />
+                    <br/>
+                    <button ng-click="submit(roomInfo.newEvent)" type="button" class="btn btn-success" ng-disabled="!roomInfo.newEvent.content || !roomInfo.newEvent.type">
+                        Submit
+                    </button>
+                </td>
+                <td class="room-info-event-content" width="70%">
+                    <textarea class="room-info-textarea-content" msd-elastic ng-model="roomInfo.newEvent.content" asjson></textarea>
+                </td>
+            </tr>
+            </table>
+        </div>
+        <div class="modal-footer">
+            <button ng-click="dismiss()" type="button" class="btn">
+                Close
+            </button>
+        </div>
+    </script>
+
+    <div id="roomHeader">
+        <a href ng-click="goToPage('/')"><img src="img/logo-small.png" width="100" height="43" alt="[matrix]"/></a>
+        <div class="roomHeaderInfo">
+
+            <div class="roomNameSection">
+                <div ng-hide="name.isEditing" ng-dblclick="name.editName()" id="roomName">
+                    {{ room_id | mRoomName }}
+                </div>
+                <form ng-submit="name.updateName()" ng-show="name.isEditing" class="roomNameForm">
+                    <input ng-model="name.newNameText" ng-blur="name.cancelEdit()" class="roomNameInput" placeholder="Room name"/>
+                </form>
+            </div>
+
+            <div class="roomTopicSection">
+                <button ng-hide="room.current_room_state.state_events['m.room.topic'].content.topic || topic.isEditing"
+                    ng-click="topic.editTopic()" class="roomTopicSetNew">
+                    Set Topic
+                </button>
+                <div ng-show="room.current_room_state.state_events['m.room.topic'].content.topic || topic.isEditing">
+                    <div ng-hide="topic.isEditing" ng-dblclick="topic.editTopic()" id="roomTopic">
+                        {{ room.current_room_state.state_events['m.room.topic'].content.topic | limitTo: 200}}
+                    </div>
+                    <form ng-submit="topic.updateTopic()" ng-show="topic.isEditing" class="roomTopicForm">
+                        <input ng-model="topic.newTopicText" ng-blur="topic.cancelEdit()" class="roomTopicInput"  placeholder="Topic"/>
+                    </form>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div id="roomPage">
+    <div id="roomWrapper">
+        
+    <div id="roomRecentsTableWrapper">
+        <div ng-include="'recents/recents.html'"></div>
+    </div>
+
+    <div id="usersTableWrapper" ng-hide="state.permission_denied">
+        <table id="usersTable">
+            <tr ng-repeat="member in members | orderMembersList">
+                <td class="userAvatar mouse-pointer" ng-click="$parent.goToUserPage(member.id)" ng-class="member.membership == 'invite' ? 'invited' : ''">
+                    <img class="userAvatarImage" 
+                         ng-src="{{member.avatar_url || 'img/default-profile.png'}}" 
+                         alt="{{ member.displayname || member.id.substr(0, member.id.indexOf(':')) }}"
+                         title="{{ member.id }} - power: {{ member.powerLevel }}"
+                         width="80" height="80"/>
+                    <img class="userAvatarGradient" src="img/gradient.png" title="{{ member.id }}" width="80" height="24"/>
+                    <div class="userPowerLevel" ng-style="{'width': member.powerLevelNorm +'%'}"></div>
+                    <div class="userName">
+                        <div ng-show="member.displayname">
+                            {{ member.id | mUserDisplayName: room_id }}
+                        </div>
+                        <div ng-hide="member.displayname">
+                            {{ member.id.substr(0, member.id.indexOf(':')) }}<br/>
+                            {{ member.id.substr(member.id.indexOf(':')) }}
+                        </div>
+                    </div>
+                </td>
+                <td class="userPresence" ng-class="(member.presence === 'online' ? 'online' : (member.presence === 'unavailable' ? 'unavailable' : '')) + ' ' + (member.membership == 'invite' ? 'invited' : '')">
+                    <span ng-show="member.last_active_ago">{{ member.last_active_ago + (now - member.last_updated) | duration }}<br/>ago</span>
+                </td>
+        </table>
+    </div>
+    
+    <div id="messageTableWrapper" 
+         ng-hide="state.permission_denied" 
+         ng-style="{ 'visibility': state.messages_visibility }"
+         keep-scroll>
+        <table id="messageTable" infinite-scroll="paginateMore()">
+            <tr ng-repeat="msg in room.events"
+                ng-class="(room.events[$index + 1].user_id !== msg.user_id ? 'differentUser' : '') + (msg.user_id === state.user_id ? ' mine' : '')" scroll-item>
+                <td class="leftBlock">
+                    <div class="sender" ng-hide="room.events[$index - 1].user_id === msg.user_id"> {{ msg.__room_member.cnt.displayname || msg.user_id | mUserDisplayName: room_id }}</div>
+                    <div class="timestamp"
+                         ng-class="msg.echo_msg_state">
+                        {{ (msg.origin_server_ts) | date:'MMM d HH:mm' }}
+                    </div>
+                </td>
+                <td class="avatar">
+                    <!-- msg.__room_member.avatar_url is just backwards compat, and can be removed in the future. -->
+                    <img class="avatarImage" ng-src="{{ msg.__room_member.cnt.avatar_url || msg.__room_member.avatar_url || 'img/default-profile.png' }}" width="32" height="32" title="{{msg.user_id}}"
+                         ng-hide="room.events[$index - 1].user_id === msg.user_id || msg.user_id === state.user_id"/>
+                </td>
+                <td ng-class="(!msg.content.membership && ('m.room.topic' !== msg.type && 'm.room.name' !== msg.type))? (msg.content.msgtype === 'm.emote' ? 'emote text' : 'text') : 'membership text'">
+                    <div class="bubble" ng-dblclick="openJson(msg)">
+                        <span ng-if="'join' === msg.content.membership && msg.changedKey === 'membership'">
+                            {{ msg.content.displayname || members[msg.state_key].displayname || msg.state_key }} joined
+                        </span>
+                        <span ng-if="'leave' === msg.content.membership && msg.changedKey === 'membership'">
+                            <span ng-if="msg.user_id === msg.state_key">
+                                <!-- FIXME: This seems like a synapse bug that the 'leave' content doesn't give the displayname... -->
+                                {{ msg.__room_member.cnt.displayname || members[msg.state_key].displayname || msg.state_key }} left
+                            </span>
+                            <span ng-if="msg.user_id !== msg.state_key && msg.prev_content">
+                                {{ msg.content.displayname || members[msg.user_id].displayname || msg.user_id }}
+                                {{ {"invite": "kicked", "join": "kicked", "ban": "unbanned"}[msg.prev_content.membership] }}
+                                {{ msg.__target_room_member.content.displayname || msg.state_key }}
+                                <span ng-if="'join' === msg.prev_content.membership && msg.content.reason">
+                                    : {{ msg.content.reason }}
+                                </span>
+                            </span>
+                        </span>
+                        <span ng-if="'invite' === msg.content.membership && msg.changedKey === 'membership' || 
+                                     'ban' === msg.content.membership && msg.changedKey === 'membership'">
+                            {{ msg.__room_member.cnt.displayname || msg.user_id }}
+                            {{ {"invite": "invited", "ban": "banned"}[msg.content.membership] }}
+                            {{ msg.__target_room_member.cnt.displayname || msg.state_key }}
+                            <span ng-if="msg.prev_content && 'ban' === msg.prev_content.membership && msg.content.reason">
+                                : {{ msg.content.reason }}
+                            </span>
+                        </span>                        
+                        <span ng-if="msg.changedKey === 'displayname'">
+                            {{ msg.user_id }} changed their display name from {{ msg.prev_content.displayname }} to {{ msg.content.displayname }}
+                        </span>
+                        
+                        <span ng-show='msg.content.msgtype === "m.emote"'
+                              ng-class="msg.echo_msg_state"
+                              ng-bind-html="'* ' + (members[msg.user_id].displayname || msg.user_id) + ' ' + msg.content.body | linky:'_blank'"
+                              />
+                        
+                        <span ng-show='msg.content.msgtype === "m.text"' 
+                              class="message"
+                              ng-class="containsBingWord(msg.content.body) && msg.user_id != state.user_id ? msg.echo_msg_state + ' messageBing' : msg.echo_msg_state"
+                              ng-bind-html="(msg.content.msgtype === 'm.text' && msg.type === 'm.room.message' && msg.content.format === 'org.matrix.custom.html') ? 
+                                                                                        (msg.content.formatted_body | unsanitizedLinky) :
+                                             (msg.content.msgtype === 'm.text' && msg.type === 'm.room.message') ? (msg.content.body | linky:'_blank') : '' "/>
+
+                        <span ng-show='msg.type === "m.call.invite" && msg.user_id == state.user_id'>Outgoing Call{{ isWebRTCSupported() ? '' : ' (But your browser does not support VoIP)' }}</span>
+                        <span ng-show='msg.type === "m.call.invite" && msg.user_id != state.user_id'>Incoming Call{{ isWebRTCSupported() ? '' : ' (But your browser does not support VoIP)' }}</span>
+
+                        <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; $event.stopPropagation();"/>
+                            </div>
+                        </div>
+
+                        <span ng-if="'m.room.topic' === msg.type">
+                            {{ members[msg.user_id].displayname || msg.user_id }} changed the topic to: {{ msg.content.topic }}
+                        </span>
+
+                        <span ng-if="'m.room.name' === msg.type">
+                            {{ members[msg.user_id].displayname || msg.user_id }} changed the room name to: {{ msg.content.name }}
+                        </span>
+
+                    </div>
+                </td>
+                <td class="rightBlock">
+                    <img class="avatarImage" ng-src="{{ members[msg.user_id].avatar_url || 'img/default-profile.png' }}" width="32" height="32"
+                         ng-hide="room.events[$index - 1].user_id === msg.user_id || msg.user_id !== state.user_id"/>
+                </td>
+            </tr>
+        </table>
+    </div>
+        
+    <div ng-show="state.permission_denied">
+        {{ state.permission_denied }}
+    </div>
+    
+    </div>
+    </div>
+
+    <div id="controlPanel">
+        <div id="controls">
+            <table id="inputBarTable">
+                <tr>
+                    <td id="userIdCell" width="1px">
+                        {{ state.user_id }} 
+                    </td>
+                    <td width="*">
+                        <textarea id="mainInput" rows="1" ng-enter="send()"
+                                  ng-disabled="state.permission_denied"
+                                  ng-focus="true" autocomplete="off" tab-complete command-history/>
+                    </td>
+                    <td id="buttonsCell">
+                        <button ng-click="send()" ng-disabled="state.permission_denied">Send</button>
+                        <button m-file-input="imageFileToSend" class="extraControls" ng-disabled="state.permission_denied">Image</button>
+                    </td>
+                </tr>
+            </table>
+
+            <div class="extraControls">
+                <span>
+                   Invite a user: 
+                        <input ng-model="userIDToInvite" size="32" type="text" ng-enter="inviteUser()" ng-disabled="state.permission_denied" placeholder="User ID (ex:@user:homeserver)"/>     
+                        <button ng-click="inviteUser()" ng-disabled="state.permission_denied">Invite</button>
+                </span>
+                <button ng-click="leaveRoom()" ng-disabled="state.permission_denied">Leave</button>
+                <button ng-click="startVoiceCall()"
+                        ng-show="(currentCall == undefined || currentCall.state == 'ended')"
+                        ng-disabled="state.permission_denied || !isWebRTCSupported() || memberCount() != 2"
+                        title ="{{ !isWebRTCSupported() ? 'VoIP requires webRTC but your browser does not support it' : (memberCount() == 2 ? '' : 'VoIP calls can only be made in rooms with two participants') }}"
+                        >
+                    Voice Call
+                </button>
+                <button ng-click="startVideoCall()"
+                        ng-show="(currentCall == undefined || currentCall.state == 'ended')"
+                        ng-disabled="state.permission_denied || !isWebRTCSupported() || memberCount() != 2"
+                        title ="{{ !isWebRTCSupported() ? 'VoIP requires webRTC but your browser does not support it' : (memberCount() == 2 ? '' : 'VoIP calls can only be made in rooms with two participants') }}"
+                        >
+                    Video Call
+                </button>
+                <button ng-click="openRoomInfo()">
+                    Room Info
+                </button>
+            </div>
+        
+            {{ feedback }}
+            <div ng-show="state.stream_failure">
+                {{ state.stream_failure.data.error || "Connection failure" }}
+            </div>
+        </div>
+    </div>
+
+    <div id="room-fullscreen-image" ng-show="fullScreenImageURL" ng-click="fullScreenImageURL = undefined;">
+        <img ng-src="{{ fullScreenImageURL }}"/>
+    </div>
+
+ </div>