summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--CHANGES.rst20
-rw-r--r--VERSION2
-rw-r--r--docs/client-server/swagger_matrix/api-docs-rooms2
-rw-r--r--docs/turn-howto.rst92
-rw-r--r--synapse/__init__.py2
-rw-r--r--synapse/api/auth.py4
-rw-r--r--synapse/config/captcha.py7
-rw-r--r--synapse/config/homeserver.py3
-rw-r--r--synapse/config/voip.py41
-rw-r--r--synapse/handlers/federation.py10
-rw-r--r--synapse/handlers/message.py16
-rw-r--r--synapse/handlers/room.py11
-rw-r--r--synapse/rest/__init__.py3
-rw-r--r--synapse/rest/register.py53
-rw-r--r--synapse/rest/room.py13
-rw-r--r--synapse/rest/voip.py60
-rw-r--r--synapse/storage/__init__.py3
-rw-r--r--tests/handlers/test_federation.py2
-rw-r--r--webclient/app-controller.js10
-rw-r--r--webclient/app-filter.js45
-rw-r--r--webclient/components/matrix/event-handler-service.js123
-rw-r--r--webclient/components/matrix/matrix-call.js58
-rw-r--r--webclient/components/matrix/matrix-filter.js90
-rw-r--r--webclient/components/matrix/matrix-service.js15
-rw-r--r--webclient/home/home-controller.js59
-rw-r--r--webclient/home/home.html13
-rw-r--r--webclient/img/close.pngbin0 -> 397 bytes
-rw-r--r--webclient/index.html2
-rw-r--r--webclient/login/login.html2
-rw-r--r--webclient/recents/recents.html53
-rw-r--r--webclient/room/room-controller.js41
-rw-r--r--webclient/room/room-directive.js85
-rw-r--r--webclient/room/room.html25
-rw-r--r--webclient/test/README28
-rw-r--r--webclient/test/e2e/home.spec.js16
-rw-r--r--webclient/test/protractor.conf.js18
-rw-r--r--webclient/test/unit/user-controller.spec.js6
-rw-r--r--webclient/user/user-controller.js3
39 files changed, 824 insertions, 213 deletions
diff --git a/.gitignore b/.gitignore
index dfe8dfedbf..b91b52b615 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,5 +25,6 @@ graph/*.png
 graph/*.dot
 
 webclient/config.js
+webclient/test/environment-protractor.js
 
 uploads
diff --git a/CHANGES.rst b/CHANGES.rst
index dd188cf86a..400ded0f15 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,3 +1,23 @@
+Changes in synapse 0.3.3 (2014-09-22)
+=====================================
+
+Homeserver:
+ * Fix bug where you continued to get events for rooms you had left.
+
+Webclient:
+ * Add support for video calls with basic UI.
+ * Fix bug where one to one chats were named after your display name rather
+   than the other person's.
+ * Fix bug which caused lag when typing in the textarea.
+ * Refuse to run on browsers we know won't work.
+ * Trigger pagination when joining new rooms.
+ * Fix bug where we sometimes didn't display invitations in recents.
+ * Automatically join room when accepting a VoIP call.
+ * Disable outgoing and reject incoming calls on browsers we don't support
+   VoIP in.
+ * Don't display desktop notifications for messages in the room you are
+   non-idle and speaking in.
+
 Changes in synapse 0.3.2 (2014-09-18)
 =====================================
 
diff --git a/VERSION b/VERSION
index d15723fbe8..1c09c74e22 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.3.2
+0.3.3
diff --git a/docs/client-server/swagger_matrix/api-docs-rooms b/docs/client-server/swagger_matrix/api-docs-rooms
index 0e1fa452a2..b941e58139 100644
--- a/docs/client-server/swagger_matrix/api-docs-rooms
+++ b/docs/client-server/swagger_matrix/api-docs-rooms
@@ -639,7 +639,7 @@
         {
           "method": "GET",
           "summary": "Get a list of all the current state events for this room.",
-          "notes": "NOT YET IMPLEMENTED.",
+          "notes": "This is equivalent to the events returned under the 'state' key for this room in /initialSync.",
           "type": "array",
           "items": {
             "$ref": "Event"
diff --git a/docs/turn-howto.rst b/docs/turn-howto.rst
new file mode 100644
index 0000000000..4f097c391e
--- /dev/null
+++ b/docs/turn-howto.rst
@@ -0,0 +1,92 @@
+How to enable VoIP relaying on your Home Server with TURN
+
+Overview
+--------
+The synapse Matrix Home Server supports integration with TURN server via the
+TURN server REST API
+(http://tools.ietf.org/html/draft-uberti-behave-turn-rest-00). This allows
+the Home Server to generate credentials that are valid for use on the TURN
+server through the use of a secret shared between the Home Server and the
+TURN server.
+
+This document described how to install coturn
+(https://code.google.com/p/coturn/) which also supports the TURN REST API,
+and integrate it with synapse.
+
+coturn Setup
+============
+
+ 1. Check out coturn::
+      svn checkout http://coturn.googlecode.com/svn/trunk/ coturn
+      cd coturn
+
+ 2. Configure it::
+      ./configure
+
+    You may need to install libevent2: if so, you should do so
+    in the way recommended by your operating system.
+    You can ignore warnings about lack of database support: a
+    database is unnecessary for this purpose.
+
+ 3. Build and install it::
+      make
+      make install
+
+ 4. Make a config file in /etc/turnserver.conf. You can customise
+    a config file from turnserver.conf.default. The relevant
+    lines are::
+
+      lt-cred-mech
+      use-auth-secret
+      static-auth-secret=[your secret key here]
+
+    See turnserver.conf.default for explanations of the options.
+    One way to generate the static-auth-secret is with pwgen::
+
+       pwgen -s 64 1
+
+ 5. Ensure youe firewall allows traffic into the TURN server on
+    the ports you've configured it to listen on (remember to allow
+    both TCP and UDP if you've enabled both).
+
+ 6. If you've configured coturn to support TLS/DTLS, generate or
+    import your private key and certificate.
+
+ 7. Start the turn server::
+       bin/turnserver -o
+
+
+synapse Setup
+=============
+
+Your home server configuration file needs the following extra keys:
+
+ 1. "turn_uris": This needs to be a comma-separated
+    list of public-facing URIs for your TURN server to be given out 
+    to your clients. Add separate entries for each transport your
+    TURN server supports.
+
+ 2. "turn_shared_secret": This is the secret shared between your Home
+    server and your TURN server, so you should set it to the same
+    string you used in turnserver.conf.
+
+ 3. "turn_user_lifetime": This is the amount of time credentials
+    generated by your Home Server are valid for (in milliseconds).
+    Shorter times offer less potential for abuse at the expense
+    of increased traffic between web clients and your home server
+    to refresh credentials. The TURN REST API specification recommends
+    one day (86400000).
+
+As an example, here is the relevant section of the config file for
+matrix.org::
+
+    turn_uris: turn:turn.matrix.org:3478?transport=udp,turn:turn.matrix.org:3478?transport=tcp
+    turn_shared_secret: n0t4ctuAllymatr1Xd0TorgSshar3d5ecret4obvIousreAsons
+    turn_user_lifetime: 86400000
+
+Now, restart synapse::
+
+    cd /where/you/run/synapse
+    ./synctl restart
+
+...and your Home Server now supports VoIP relaying!
diff --git a/synapse/__init__.py b/synapse/__init__.py
index 0dbad9a0de..bba551b2c4 100644
--- a/synapse/__init__.py
+++ b/synapse/__init__.py
@@ -16,4 +16,4 @@
 """ This is a reference implementation of a synapse home server.
 """
 
-__version__ = "0.3.2"
+__version__ = "0.3.3"
diff --git a/synapse/api/auth.py b/synapse/api/auth.py
index 9c65d47fb4..9bfd25c86e 100644
--- a/synapse/api/auth.py
+++ b/synapse/api/auth.py
@@ -400,11 +400,11 @@ class Auth(object):
         }
 
         removed = set(old_people.keys()) - set(new_people.keys())
-        added = set(old_people.keys()) - set(new_people.keys())
+        added = set(new_people.keys()) - set(old_people.keys())
         same = set(old_people.keys()) & set(new_people.keys())
 
         for r in removed:
-            if int(old_list.content[r]) > user_level:
+            if int(old_list[r]) > user_level:
                 raise AuthError(
                     403,
                     "You don't have permission to remove user: %s" % (r, )
diff --git a/synapse/config/captcha.py b/synapse/config/captcha.py
index 8ebcfc3623..4ed9070b9e 100644
--- a/synapse/config/captcha.py
+++ b/synapse/config/captcha.py
@@ -24,6 +24,7 @@ class CaptchaConfig(Config):
         self.captcha_ip_origin_is_x_forwarded = (
             args.captcha_ip_origin_is_x_forwarded
         )
+        self.captcha_bypass_secret = args.captcha_bypass_secret
 
     @classmethod
     def add_arguments(cls, parser):
@@ -43,4 +44,8 @@ class CaptchaConfig(Config):
             "--captcha_ip_origin_is_x_forwarded", type=bool, default=False,
             help="When checking captchas, use the X-Forwarded-For (XFF) header"
             + " as the client IP and not the actual client IP."
-        )
\ No newline at end of file
+        )
+        group.add_argument(
+            "--captcha_bypass_secret", type=str,
+            help="A secret key used to bypass the captcha test entirely."
+        )
diff --git a/synapse/config/homeserver.py b/synapse/config/homeserver.py
index 4b810a2302..5a11fd6c76 100644
--- a/synapse/config/homeserver.py
+++ b/synapse/config/homeserver.py
@@ -21,11 +21,12 @@ from .ratelimiting import RatelimitConfig
 from .repository import ContentRepositoryConfig
 from .captcha import CaptchaConfig
 from .email import EmailConfig
+from .voip import VoipConfig
 
 
 class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig,
                        RatelimitConfig, ContentRepositoryConfig, CaptchaConfig,
-                       EmailConfig):
+                       EmailConfig, VoipConfig):
     pass
 
 
diff --git a/synapse/config/voip.py b/synapse/config/voip.py
new file mode 100644
index 0000000000..c5131d9bcd
--- /dev/null
+++ b/synapse/config/voip.py
@@ -0,0 +1,41 @@
+# 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.
+
+from ._base import Config
+
+
+class VoipConfig(Config):
+
+    def __init__(self, args):
+        super(VoipConfig, self).__init__(args)
+        self.turn_uris = args.turn_uris.split(",") if args.turn_uris else None
+        self.turn_shared_secret = args.turn_shared_secret
+        self.turn_user_lifetime = args.turn_user_lifetime
+
+    @classmethod
+    def add_arguments(cls, parser):
+        super(VoipConfig, cls).add_arguments(parser)
+        group = parser.add_argument_group("voip")
+        group.add_argument(
+            "--turn-uris", type=str, default=None,
+            help="The public URIs of the TURN server to give to clients"
+        )
+        group.add_argument(
+            "--turn-shared-secret", type=str, default=None,
+            help="The shared secret used to compute passwords for the TURN server"
+        )
+        group.add_argument(
+            "--turn-user-lifetime", type=int, default=(1000 * 60 * 60),
+            help="How long generated TURN credentials last, in ms"
+        )
diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py
index 001c6c110c..f52591d2a3 100644
--- a/synapse/handlers/federation.py
+++ b/synapse/handlers/federation.py
@@ -169,7 +169,15 @@ class FederationHandler(BaseHandler):
                     )
 
             if not backfilled:
-                yield self.notifier.on_new_room_event(event)
+                extra_users = []
+                if event.type == RoomMemberEvent.TYPE:
+                    target_user_id = event.state_key
+                    target_user = self.hs.parse_userid(target_user_id)
+                    extra_users.append(target_user)
+
+                yield self.notifier.on_new_room_event(
+                    event, extra_users=extra_users
+                )
 
         if event.type == RoomMemberEvent.TYPE:
             if event.membership == Membership.JOIN:
diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py
index 14fae689f2..317ef2c80c 100644
--- a/synapse/handlers/message.py
+++ b/synapse/handlers/message.py
@@ -233,6 +233,22 @@ class MessageHandler(BaseHandler):
         yield self._on_new_room_event(event, snapshot)
 
     @defer.inlineCallbacks
+    def get_state_events(self, user_id, room_id):
+        """Retrieve all state events for a given room.
+
+        Args:
+            user_id(str): The user requesting state events.
+            room_id(str): The room ID to get all state events from.
+        Returns:
+            A list of dicts representing state events. [{}, {}, {}]
+        """
+        yield self.auth.check_joined_room(room_id, user_id)
+
+        # TODO: This is duplicating logic from snapshot_all_rooms
+        current_state = yield self.store.get_current_state(room_id)
+        defer.returnValue([self.hs.serialize_event(c) for c in current_state])
+
+    @defer.inlineCallbacks
     def snapshot_all_rooms(self, user_id=None, pagin_config=None,
                            feedback=False):
         """Retrieve a snapshot of all rooms the user is invited or has joined.
diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py
index b7a9d77ca2..c0f9a7c807 100644
--- a/synapse/handlers/room.py
+++ b/synapse/handlers/room.py
@@ -146,17 +146,6 @@ class RoomCreationHandler(BaseHandler):
             )
 
             yield handle_event(name_event)
-        elif room_alias:
-            name = room_alias.to_string()
-            name_event = self.event_factory.create_event(
-                etype=RoomNameEvent.TYPE,
-                room_id=room_id,
-                user_id=user_id,
-                required_power_level=50,
-                content={"name": name},
-            )
-
-            yield handle_event(name_event)
 
         if "topic" in config:
             topic = config["topic"]
diff --git a/synapse/rest/__init__.py b/synapse/rest/__init__.py
index ed785cfbd5..3b9aa59733 100644
--- a/synapse/rest/__init__.py
+++ b/synapse/rest/__init__.py
@@ -15,7 +15,7 @@
 
 
 from . import (
-    room, events, register, login, profile, presence, initial_sync, directory
+    room, events, register, login, profile, presence, initial_sync, directory, voip
 )
 
 
@@ -42,3 +42,4 @@ class RestServletFactory(object):
         presence.register_servlets(hs, client_resource)
         initial_sync.register_servlets(hs, client_resource)
         directory.register_servlets(hs, client_resource)
+        voip.register_servlets(hs, client_resource)
diff --git a/synapse/rest/register.py b/synapse/rest/register.py
index af528a44f6..4935e323d9 100644
--- a/synapse/rest/register.py
+++ b/synapse/rest/register.py
@@ -21,6 +21,8 @@ from synapse.api.constants import LoginType
 from base import RestServlet, client_path_pattern
 import synapse.util.stringutils as stringutils
 
+from hashlib import sha1
+import hmac
 import json
 import logging
 import urllib
@@ -28,6 +30,16 @@ import urllib
 logger = logging.getLogger(__name__)
 
 
+# We ought to be using hmac.compare_digest() but on older pythons it doesn't
+# exist. It's a _really minor_ security flaw to use plain string comparison
+# because the timing attack is so obscured by all the other code here it's
+# unlikely to make much difference
+if hasattr(hmac, "compare_digest"):
+    compare_digest = hmac.compare_digest
+else:
+    compare_digest = lambda a, b: a == b
+
+
 class RegisterRestServlet(RestServlet):
     """Handles registration with the home server.
 
@@ -142,6 +154,38 @@ class RegisterRestServlet(RestServlet):
         if not self.hs.config.enable_registration_captcha:
             raise SynapseError(400, "Captcha not required.")
 
+        yield self._check_recaptcha(request, register_json, session)
+
+        session[LoginType.RECAPTCHA] = True  # mark captcha as done
+        self._save_session(session)
+        defer.returnValue({
+            "next": [LoginType.PASSWORD, LoginType.EMAIL_IDENTITY]
+        })
+
+    @defer.inlineCallbacks
+    def _check_recaptcha(self, request, register_json, session):
+        if ("captcha_bypass_hmac" in register_json and
+                self.hs.config.captcha_bypass_secret):
+            if "user" not in register_json:
+                raise SynapseError(400, "Captcha bypass needs 'user'")
+
+            want = hmac.new(
+                key=self.hs.config.captcha_bypass_secret,
+                msg=register_json["user"],
+                digestmod=sha1,
+            ).hexdigest()
+
+            # str() because otherwise hmac complains that 'unicode' does not
+            # have the buffer interface
+            got = str(register_json["captcha_bypass_hmac"])
+
+            if compare_digest(want, got):
+                session["user"] = register_json["user"]
+                defer.returnValue(None)
+            else:
+                raise SynapseError(400, "Captcha bypass HMAC incorrect",
+                    errcode=Codes.CAPTCHA_NEEDED)
+
         challenge = None
         user_response = None
         try:
@@ -166,11 +210,6 @@ class RegisterRestServlet(RestServlet):
             challenge,
             user_response
         )
-        session[LoginType.RECAPTCHA] = True  # mark captcha as done
-        self._save_session(session)
-        defer.returnValue({
-            "next": [LoginType.PASSWORD, LoginType.EMAIL_IDENTITY]
-        })
 
     @defer.inlineCallbacks
     def _do_email_identity(self, request, register_json, session):
@@ -195,6 +234,10 @@ class RegisterRestServlet(RestServlet):
             # captcha should've been done by this stage!
             raise SynapseError(400, "Captcha is required.")
 
+        if ("user" in session and "user" in register_json and
+                session["user"] != register_json["user"]):
+            raise SynapseError(400, "Cannot change user ID during registration")
+
         password = register_json["password"].encode("utf-8")
         desired_user_id = (register_json["user"].encode("utf-8") if "user"
                           in register_json else None)
diff --git a/synapse/rest/room.py b/synapse/rest/room.py
index 399084ae2a..a01dab1b8e 100644
--- a/synapse/rest/room.py
+++ b/synapse/rest/room.py
@@ -329,12 +329,13 @@ class RoomStateRestServlet(RestServlet):
     @defer.inlineCallbacks
     def on_GET(self, request, room_id):
         user = yield self.auth.get_user_by_req(request)
-        # TODO: Get all the current state for this room and return in the same
-        # format as initial sync, that is:
-        # [
-        #   { state event }, { state event }
-        # ]
-        defer.returnValue((200, []))
+        handler = self.handlers.message_handler
+        # Get all the current state for this room
+        events = yield handler.get_state_events(
+            room_id=urllib.unquote(room_id),
+            user_id=user.to_string(),
+        )
+        defer.returnValue((200, events))
 
 
 # TODO: Needs unit testing
diff --git a/synapse/rest/voip.py b/synapse/rest/voip.py
new file mode 100644
index 0000000000..2e4627606f
--- /dev/null
+++ b/synapse/rest/voip.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+# 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.
+
+from twisted.internet import defer
+
+from base import RestServlet, client_path_pattern
+
+
+import hmac
+import hashlib
+import base64
+
+
+class VoipRestServlet(RestServlet):
+    PATTERN = client_path_pattern("/voip/turnServer$")
+
+    @defer.inlineCallbacks
+    def on_GET(self, request):
+        auth_user = yield self.auth.get_user_by_req(request)
+
+        turnUris = self.hs.config.turn_uris
+        turnSecret = self.hs.config.turn_shared_secret
+        userLifetime = self.hs.config.turn_user_lifetime
+        if not turnUris or not turnSecret or not userLifetime:
+            defer.returnValue( (200, {}) )
+
+        expiry = self.hs.get_clock().time_msec() + userLifetime
+        username = "%d:%s" % (expiry, auth_user.to_string())
+         
+        mac = hmac.new(turnSecret, msg=username, digestmod=hashlib.sha1)
+        # We need to use standard base64 encoding here, *not* syutil's encode_base64
+        # because we need to add the standard padding to get the same result as the
+        # TURN server.
+        password = base64.b64encode(mac.digest())
+
+        defer.returnValue( (200, {
+            'username': username,
+            'password': password,
+            'ttl': userLifetime / 1000,
+            'uris': turnUris,
+        }) )
+
+    def on_OPTIONS(self, request):
+        return (200, {})
+
+
+def register_servlets(hs, http_server):
+    VoipRestServlet(hs).register(http_server)
diff --git a/synapse/storage/__init__.py b/synapse/storage/__init__.py
index 38f5ec0ac5..15919eb580 100644
--- a/synapse/storage/__init__.py
+++ b/synapse/storage/__init__.py
@@ -221,7 +221,8 @@ class DataStore(RoomMemberStore, RoomStore,
             )
             raise _RollbackButIsFineException("_persist_event")
 
-        if is_new_state and hasattr(event, "state_key"):
+        is_state = hasattr(event, "state_key") and event.state_key is not None
+        if is_new_state and is_state:
             vals = {
                 "event_id": event.event_id,
                 "room_id": event.room_id,
diff --git a/tests/handlers/test_federation.py b/tests/handlers/test_federation.py
index eb6b7c22ef..7208afdb3b 100644
--- a/tests/handlers/test_federation.py
+++ b/tests/handlers/test_federation.py
@@ -77,7 +77,7 @@ class FederationTestCase(unittest.TestCase):
         self.datastore.persist_event.assert_called_once_with(
             ANY, False, is_new_state=False
         )
-        self.notifier.on_new_room_event.assert_called_once_with(ANY)
+        self.notifier.on_new_room_event.assert_called_once_with(ANY, extra_users=[])
 
     @defer.inlineCallbacks
     def test_invite_join_target_this(self):
diff --git a/webclient/app-controller.js b/webclient/app-controller.js
index 0e823b43e7..7d61207554 100644
--- a/webclient/app-controller.js
+++ b/webclient/app-controller.js
@@ -67,6 +67,16 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
         }
     };
     
+    $scope.leave = function(room_id) {
+        matrixService.leave(room_id).then(
+            function(response) {
+                console.log("Left room " + room_id);
+            },
+            function(error) {
+                console.log("Failed to leave room " + room_id + ": " + error.data.error);
+            });
+    };
+    
     // Logs the user out 
     $scope.logout = function() {
         
diff --git a/webclient/app-filter.js b/webclient/app-filter.js
index ee9374668b..fc16492ef3 100644
--- a/webclient/app-filter.js
+++ b/webclient/app-filter.js
@@ -45,33 +45,34 @@ angular.module('matrixWebClient')
         angular.forEach(members, function(value, key) {
             value["id"] = key;
             filtered.push( value );
-            if (value["displayname"]) {
-                if (!displayNames[value["displayname"]]) {
-                    displayNames[value["displayname"]] = [];
-                }
-                displayNames[value["displayname"]].push(key);
-            }
         });
 
-        // FIXME: we shouldn't disambiguate displayNames on every orderMembersList
-        // invocation but keep track of duplicates incrementally somewhere
-        angular.forEach(displayNames, function(value, key) {
-            if (value.length > 1) {
-                // console.log(key + ": " + value);
-                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]);
+        filtered.sort(function (a, b) {
+            // Sort members on their last_active absolute time
+            var aLastActiveTS = 0, bLastActiveTS = 0;
+            if (undefined !== a.last_active_ago) {
+                aLastActiveTS = a.last_updated - a.last_active_ago;
+            }
+            if (undefined !== b.last_active_ago) {
+                bLastActiveTS = b.last_updated - b.last_active_ago;
+            }
+            if (aLastActiveTS || bLastActiveTS) {
+                return bLastActiveTS - aLastActiveTS;
+            }
+            else {
+                // If they do not have last_active_ago, sort them according to their presence state
+                // Online users go first amongs members who do not have last_active_ago
+                var presenceLevels = {
+                    offline: 1,
+                    unavailable: 2,
+                    online: 4,
+                    free_for_chat: 3
                 };
+                var aPresence = (a.presence in presenceLevels) ? presenceLevels[a.presence] : 0;
+                var bPresence = (b.presence in presenceLevels) ? presenceLevels[b.presence] : 0;
+                return bPresence - aPresence;
             }
         });
-
-        filtered.sort(function (a, b) {
-            return ((a["last_active_ago"] || 10e10) > (b["last_active_ago"] || 10e10) ? 1 : -1);
-        });
         return filtered;
     };
 })
diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js
index 98003e97bf..e990d42d05 100644
--- a/webclient/components/matrix/event-handler-service.js
+++ b/webclient/components/matrix/event-handler-service.js
@@ -101,7 +101,7 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
 
     var initRoom = function(room_id, room) {
         if (!(room_id in $rootScope.events.rooms)) {
-            console.log("Creating new handler entry for " + room_id);
+            console.log("Creating new rooms entry for " + room_id);
             $rootScope.events.rooms[room_id] = {
                 room_id: room_id,
                 messages: [],
@@ -113,10 +113,12 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
             };
         }
 
-        if (room) {
+        if (room) { // we got an existing room object from initialsync, seemingly.
             // Report all other metadata of the room object (membership, inviter, visibility, ...)
             for (var field in room) {
-                if (-1 === ["room_id", "messages", "state"].indexOf(field)) {
+                if (!room.hasOwnProperty(field)) continue;
+
+                if (-1 === ["room_id", "messages", "state"].indexOf(field)) { // why indexOf - why not ===? --Matthew
                     $rootScope.events.rooms[room_id][field] = room[field];
                 }
             }
@@ -211,11 +213,8 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
                 
                 if (shouldBing && isIdle) {
                     console.log("Displaying notification for "+JSON.stringify(event));
-                    var member = $rootScope.events.rooms[event.room_id].members[event.user_id];
-                    var displayname = undefined;
-                    if (member) {
-                        displayname = member.displayname;
-                    }
+                    var member = getMember(event.room_id, event.user_id);
+                    var displayname = getUserDisplayName(event.room_id, event.user_id);
 
                     var message = event.content.body;
                     if (event.content.msgtype === "m.emote") {
@@ -223,7 +222,7 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
                     }
 
                     var notification = new window.Notification(
-                        (displayname || event.user_id) +
+                        displayname +
                         " (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here
                     {
                         "body": message,
@@ -253,12 +252,29 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
         // Exception: Do not do this if the event is a room state event because such events already come
         // as room messages events. Moreover, when they come as room messages events, they are relatively ordered
         // with other other room messages
-        if (event.content.prev !== event.content.membership && !isStateEvent) {
-            if (isLiveEvent) {
-                $rootScope.events.rooms[event.room_id].messages.push(event);
+        if (!isStateEvent) {
+            // could be a membership change, display name change, etc.
+            // Find out which one.
+            var memberChanges = undefined;
+            if (event.content.prev !== event.content.membership) {
+                memberChanges = "membership";
             }
-            else {
-                $rootScope.events.rooms[event.room_id].messages.unshift(event);
+            else if (event.prev_content && (event.prev_content.displayname !== event.content.displayname)) {
+                memberChanges = "displayname";
+            }
+
+            // mark the key which changed
+            event.changedKey = memberChanges;
+
+            // If there was a change we want to display, dump it in the message
+            // list.
+            if (memberChanges) {
+                if (isLiveEvent) {
+                    $rootScope.events.rooms[event.room_id].messages.push(event);
+                }
+                else {
+                    $rootScope.events.rooms[event.room_id].messages.unshift(event);
+                }
             }
         }
         
@@ -328,6 +344,65 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
         return index;
     };
     
+    /**
+     * Get the member object of a room member
+     * @param {String} room_id the room id
+     * @param {String} user_id the id of the user
+     * @returns {undefined | Object} the member object of this user in this room if he is part of the room
+     */
+    var getMember = function(room_id, user_id) {
+        var member;
+
+        var room = $rootScope.events.rooms[room_id];
+        if (room) {
+            member = room.members[user_id];
+        }
+        return member;
+    };
+
+    /**
+     * Return the display name of an user acccording to data already downloaded
+     * @param {String} room_id the room id
+     * @param {String} user_id the id of the user
+     * @returns {String} the user displayname or user_id if not available
+     */
+    var getUserDisplayName = function(room_id, user_id) {
+        var displayName;
+
+        // Get the user display name from the member list of the room
+        var member = getMember(room_id, user_id);
+        if (member && member.content.displayname) { // Do not consider null displayname
+            displayName = member.content.displayname;
+
+            // Disambiguate users who have the same displayname in the room
+            if (user_id !== matrixService.config().user_id) {
+                var room = $rootScope.events.rooms[room_id];
+
+                for (var member_id in room.members) {
+                    if (room.members.hasOwnProperty(member_id) && member_id !== user_id) {
+                        var member2 = room.members[member_id];
+                        if (member2.content.displayname && member2.content.displayname === displayName) {
+                            displayName = displayName + " (" + user_id + ")";
+                            break;
+                        }
+                    }
+                }
+            }
+        }
+
+        // The user may not have joined the room yet. So try to resolve display name from presence data
+        // Note: This data may not be available
+        if (undefined === displayName && user_id in $rootScope.presence) {
+            displayName = $rootScope.presence[user_id].content.displayname;
+        }
+
+        if (undefined === displayName) {
+            // By default, use the user ID
+            displayName = user_id;
+        }
+        return displayName;
+    };
+
     return {
         ROOM_CREATE_EVENT: ROOM_CREATE_EVENT,
         MSG_EVENT: MSG_EVENT,
@@ -499,6 +574,8 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
                 memberCount = 0;
 
                 for (var i in room.members) {
+                    if (!room.members.hasOwnProperty(i)) continue;
+
                     var member = room.members[i];
 
                     if ("join" === member.membership) {
@@ -517,15 +594,19 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
          * @returns {undefined | Object} the member object of this user in this room if he is part of the room
          */
         getMember: function(room_id, user_id) {
-            var member;
-            
-            var room = $rootScope.events.rooms[room_id];
-            if (room) {
-                member = room.members[user_id];
-            }
-            return member;
+            return getMember(room_id, user_id);
         },
         
+        /**
+         * Return the display name of an user acccording to data already downloaded
+         * @param {String} room_id the room id
+         * @param {String} user_id the id of the user
+         * @returns {String} the user displayname or user_id if not available
+         */
+        getUserDisplayName: function(room_id, user_id) {
+            return getUserDisplayName(room_id, user_id);
+        },
+
         setRoomVisibility: function(room_id, visible) {
             if (!visible) {
                 return;
diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js
index 7b5d9cffef..3e8811e5fc 100644
--- a/webclient/components/matrix/matrix-call.js
+++ b/webclient/components/matrix/matrix-call.js
@@ -66,15 +66,67 @@ angular.module('MatrixCall', [])
 
     }
 
+    MatrixCall.getTurnServer = function() {
+        matrixService.getTurnServer().then(function(response) {
+            if (response.data.uris) {
+                console.log("Got TURN URIs: "+response.data.uris);
+                MatrixCall.turnServer = response.data;
+                $rootScope.haveTurn = true;
+                // re-fetch when we're about to reach the TTL
+                $timeout(MatrixCall.getTurnServer, MatrixCall.turnServer.ttl * 1000 * 0.9);
+            } else {
+                console.log("Got no TURN URIs from HS");
+                $rootScope.haveTurn = false;
+            }
+        }, function(error) {
+            console.log("Failed to get TURN URIs");
+            MatrixCall.turnServer = {};
+            $timeout(MatrixCall.getTurnServer, 60000);
+        });
+    }
+
+    // FIXME: we should prevent any class from being placed or accepted before this has finished
+    MatrixCall.getTurnServer();
+
     MatrixCall.CALL_TIMEOUT = 60000;
+    MatrixCall.FALLBACK_STUN_SERVER = 'stun:stun.l.google.com:19302';
 
     MatrixCall.prototype.createPeerConnection = function() {
-        var stunServer = 'stun:stun.l.google.com:19302';
         var pc;
         if (window.mozRTCPeerConnection) {
-            pc = new window.mozRTCPeerConnection({'url': stunServer});
+            var iceServers = [];
+            if (MatrixCall.turnServer) {
+                if (MatrixCall.turnServer.uris) {
+                    for (var i = 0; i < MatrixCall.turnServer.uris.length; i++) {
+                        iceServers.push({
+                            'url': MatrixCall.turnServer.uris[i],
+                            'username': MatrixCall.turnServer.username,
+                            'credential': MatrixCall.turnServer.password,
+                        });
+                    }
+                } else {
+                    console.log("No TURN server: using fallback STUN server");
+                    iceServers.push({ 'url' : MatrixCall.FALLBACK_STUN_SERVER });
+                }
+            }
+          
+            pc = new window.mozRTCPeerConnection({"iceServers":iceServers});
         } else {
-            pc = new window.RTCPeerConnection({"iceServers":[{"urls":"stun:stun.l.google.com:19302"}]});
+            var iceServers = [];
+            if (MatrixCall.turnServer) {
+                if (MatrixCall.turnServer.uris) {
+                    iceServers.push({
+                        'urls': MatrixCall.turnServer.uris,
+                        'username': MatrixCall.turnServer.username,
+                        'credential': MatrixCall.turnServer.password,
+                    });
+                } else {
+                    console.log("No TURN server: using fallback STUN server");
+                    iceServers.push({ 'urls' : MatrixCall.FALLBACK_STUN_SERVER });
+                }
+            }
+          
+            pc = new window.RTCPeerConnection({"iceServers":iceServers});
         }
         var self = this;
         pc.oniceconnectionstatechange = function() { self.onIceConnectionStateChanged(); };
diff --git a/webclient/components/matrix/matrix-filter.js b/webclient/components/matrix/matrix-filter.js
index 328e3a7086..e6f2acc5fd 100644
--- a/webclient/components/matrix/matrix-filter.js
+++ b/webclient/components/matrix/matrix-filter.js
@@ -19,7 +19,7 @@
 angular.module('matrixFilter', [])
 
 // Compute the room name according to information we have
-.filter('mRoomName', ['$rootScope', 'matrixService', function($rootScope, matrixService) {
+.filter('mRoomName', ['$rootScope', 'matrixService', 'eventHandlerService', function($rootScope, matrixService, eventHandlerService) {
     return function(room_id) {
         var roomName;
 
@@ -31,49 +31,57 @@ angular.module('matrixFilter', [])
         if (room) {
             // Get name from room state date
             var room_name_event = room["m.room.name"];
+
+            // Determine if it is a public room
+            var isPublicRoom = false;
+            if (room["m.room.join_rules"] && room["m.room.join_rules"].content) {
+                isPublicRoom = ("public" === room["m.room.join_rules"].content.join_rule);
+            }
+
             if (room_name_event) {
                 roomName = room_name_event.content.name;
             }
             else if (alias) {
                 roomName = alias;
             }
-            else if (room.members) {
-
+            else if (room.members && !isPublicRoom) {    // Do not rename public room
+            
                 var user_id = matrixService.config().user_id;
 
                 // Else, build the name from its users
                 // Limit the room renaming to 1:1 room
                 if (2 === Object.keys(room.members).length) {
                     for (var i in room.members) {
+                        if (!room.members.hasOwnProperty(i)) continue;
+
                         var member = room.members[i];
                         if (member.state_key !== user_id) {
-
-                            if (member.state_key in $rootScope.presence) {
-                                // If the user is available in presence, use the displayname there
-                                // as it is the most uptodate
-                                roomName = $rootScope.presence[member.state_key].content.displayname;
-                            }
-                            else if (member.content.displayname) {
-                                roomName = member.content.displayname;
-                            }
-                            else {
-                                roomName = member.state_key;
-                            }
+                            roomName = eventHandlerService.getUserDisplayName(room_id, member.state_key);
+                            break;
                         }
                     }
                 }
-                else if (1 === Object.keys(room.members).length) {
+                else if (Object.keys(room.members).length <= 1) {
+                    
                     var otherUserId;
 
-                    if (Object.keys(room.members)[0] !== user_id) {
+                    if (Object.keys(room.members)[0] && Object.keys(room.members)[0] !== user_id) {
                         otherUserId = Object.keys(room.members)[0];
                     }
                     else {
+                        // it's got to be an invite, or failing that a self-chat;
+                        otherUserId = room.inviter || user_id;
+/*                        
+                        // XXX: This should all be unnecessary now thanks to using the /rooms/<room>/roomid API
+
                         // The other member may be in the invite list, get all invited users
                         var invitedUserIDs = [];
+                        
+                        // XXX: *SURELY* we shouldn't have to trawl through the whole messages list to
+                        // find invite - surely the other user should be in room.members with state invited? :/ --Matthew
                         for (var i in room.messages) {
                             var message = room.messages[i];
-                            if ("m.room.member" === message.type && "invite" === message.membership) {
+                            if ("m.room.member" === message.type && "invite" === message.content.membership) {
                                 // Filter out the current user
                                 var member_id = message.state_key;
                                 if (member_id === user_id) {
@@ -92,15 +100,11 @@ angular.module('matrixFilter', [])
                         if (1 === invitedUserIDs.length) {
                             otherUserId = invitedUserIDs[0];
                         }
+*/                        
                     }
-
-                    // Try to resolve his displayname in presence global data
-                    if (otherUserId in $rootScope.presence) {
-                        roomName = $rootScope.presence[otherUserId].content.displayname;
-                    }
-                    else {
-                        roomName = otherUserId;
-                    }
+                    
+                    // Get the user display name
+                    roomName = eventHandlerService.getUserDisplayName(room_id, otherUserId);
                 }
             }
         }
@@ -127,37 +131,9 @@ angular.module('matrixFilter', [])
     };
 }])
 
-// Compute the user display name in a room according to the data already downloaded
-.filter('mUserDisplayName', ['$rootScope', function($rootScope) {
+// Return the user display name
+.filter('mUserDisplayName', ['eventHandlerService', function(eventHandlerService) {
     return function(user_id, room_id) {
-        var displayName;
-    
-        // Try to find the user name among presence data
-        // Warning: that means we have received before a presence event for this
-        // user which cannot be guaranted.
-        // However, if we get the info by this way, we are sure this is the latest user display name
-        // See FIXME comment below
-        if (user_id in $rootScope.presence) {
-            displayName = $rootScope.presence[user_id].content.displayname;
-        }
-            
-        // FIXME: Would like to use the display name as defined in room members of the room.
-        // But this information is the display name of the user when he has joined the room.
-        // It does not take into account user display name update
-        if (room_id) {
-            var room = $rootScope.events.rooms[room_id];
-            if (room && (user_id in room.members)) {
-                var member = room.members[user_id];
-                if (member.content.displayname) {
-                    displayName = member.content.displayname;
-                }
-            }
-        }
-        
-        if (undefined === displayName) {
-            // By default, use the user ID
-            displayName = user_id;
-        }
-        return displayName;
+        return eventHandlerService.getUserDisplayName(room_id, user_id);
     };
 }]);
diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js
index 069e02e939..a4f0568bce 100644
--- a/webclient/components/matrix/matrix-service.js
+++ b/webclient/components/matrix/matrix-service.js
@@ -264,7 +264,13 @@ angular.module('matrixService', [])
 
             return doRequest("GET", path, params);
         },
-
+        
+        // get room state for a specific room
+        roomState: function(room_id) {
+            var path = "/rooms/" + room_id + "/state";
+            return doRequest("GET", path);
+        },
+        
         // Joins a room
         join: function(room_id) {
             return this.membershipChange(room_id, undefined, "join");
@@ -697,11 +703,10 @@ angular.module('matrixService', [])
         createRoomIdToAliasMapping: function(roomId, alias) {
             roomIdToAlias[roomId] = alias;
             aliasToRoomId[alias] = roomId;
-            // localStorage.setItem(MAPPING_PREFIX+roomId, alias);
         },
         
         getRoomIdToAliasMapping: function(roomId) {
-            var alias = roomIdToAlias[roomId]; // was localStorage.getItem(MAPPING_PREFIX+roomId)
+            var alias = roomIdToAlias[roomId];
             //console.log("looking for alias for " + roomId + "; found: " + alias);
             return alias;
         },
@@ -762,6 +767,10 @@ angular.module('matrixService', [])
             var deferred = $q.defer();
             deferred.reject({data:{error: "Invalid room: " + room_id}});
             return deferred.promise;
+        },
+
+        getTurnServer: function() {
+            return doRequest("GET", "/voip/turnServer");
         }
 
     };
diff --git a/webclient/home/home-controller.js b/webclient/home/home-controller.js
index e35219bebb..f1295560ef 100644
--- a/webclient/home/home-controller.js
+++ b/webclient/home/home-controller.js
@@ -42,6 +42,10 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
         displayName: "",
         avatarUrl: ""
     };
+    
+    $scope.newChat = {
+        user: ""
+    };
 
     var refresh = function() {
         
@@ -82,18 +86,24 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
     
     // 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) {
+                var final_room_id = room_id;
                 if (response.data.hasOwnProperty("room_id")) {
-                    if (response.data.room_id != room_id) {
-                        $location.url("room/" + response.data.room_id);
-                        return;
-                     }
+                    final_room_id = response.data.room_id;
                 }
 
-                $location.url("room/" + room_id);
+                // TODO: factor out the common housekeeping whenever we try to join a room or alias
+                matrixService.roomState(final_room_id).then(
+                    function(response) {
+                        eventHandlerService.handleEvents(response.data, false, true);
+                    },
+                    function(error) {
+                        $scope.feedback = "Failed to get room state for: " + final_room_id;
+                    }
+                );                                        
+
+                $location.url("room/" + final_room_id);
             },
             function(error) {
                 $scope.feedback = "Can't join room: " + JSON.stringify(error.data);
@@ -104,6 +114,15 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
     $scope.joinAlias = function(room_alias) {
         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;
+                    }
+                );                                        
                 // Go to this room
                 $location.url("room/" + room_alias);
             },
@@ -112,6 +131,32 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
             }
         );
     };
+    
+    // FIXME: factor this out between user-controller and home-controller etc.
+    $scope.messageUser = function() {    
+        
+        // FIXME: create a new room every time, for now
+        
+        matrixService.create(null, 'private').then(
+            function(response) { 
+                // This room has been created. Refresh the rooms list
+                var room_id = response.data.room_id;
+                console.log("Created room with id: "+ room_id);
+                
+                matrixService.invite(room_id, $scope.newChat.user).then(
+                    function() {
+                        $scope.feedback = "Invite sent successfully";
+                        $scope.$parent.goToPage("/room/" + room_id);
+                    },
+                    function(reason) {
+                        $scope.feedback = "Failure: " + JSON.stringify(reason);
+                    });
+            },
+            function(error) {
+                $scope.feedback = "Failure: " + JSON.stringify(error.data);
+            });                
+    };
+    
  
     $scope.onInit = function() {
         // Load profile data
diff --git a/webclient/home/home.html b/webclient/home/home.html
index 5a1e18e1de..0af382916e 100644
--- a/webclient/home/home.html
+++ b/webclient/home/home.html
@@ -17,7 +17,7 @@
             <div>{{ config.user_id }}</div>
         </div>
     </div>
-    
+        
     <h3>Recent conversations</h3>
         <div ng-include="'recents/recents.html'"></div>
     <br/>
@@ -52,17 +52,24 @@
     
     <div>
         <form>
-            <input size="40" ng-model="newRoom.room_alias" ng-enter="createNewRoom(newRoom.room_alias, newRoom.private)" placeholder="(e.g. foo_channel)"/>
+            <input size="40" ng-model="newRoom.room_alias" ng-enter="createNewRoom(newRoom.room_alias, newRoom.private)" placeholder="(e.g. foo)"/>
             <input type="checkbox" ng-model="newRoom.private">private
             <button ng-disabled="!newRoom.room_alias" ng-click="createNewRoom(newRoom.room_alias, 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)"/>
+            <input size="40" ng-model="joinAlias.room_alias" ng-enter="joinAlias(joinAlias.room_alias)" placeholder="(e.g. #foo:example.org)"/>
             <button ng-disabled="!joinAlias.room_alias" ng-click="joinAlias(joinAlias.room_alias)">Join room</button>    
         </form>
     </div>
+    <div>
+        <form>
+            <input size="40" ng-model="newChat.user" ng-enter="messageUser()" placeholder="e.g. @user:domain.com"/>
+            <button ng-disabled="!newChat.user" ng-click="messageUser()">Message user</button>
+        </form>
+    </div>
+    
     <br/>
     
     {{ feedback }}
diff --git a/webclient/img/close.png b/webclient/img/close.png
new file mode 100644
index 0000000000..fbcdb51e6b
--- /dev/null
+++ b/webclient/img/close.png
Binary files differdiff --git a/webclient/index.html b/webclient/index.html
index 411c2762d3..f233919e3d 100644
--- a/webclient/index.html
+++ b/webclient/index.html
@@ -69,7 +69,7 @@
                         <span ng-show="currentCall.state == 'ringing' && currentCall && currentCall.type == 'voice'">Incoming Voice Call</span>
                         <span ng-show="currentCall.state == 'connecting'">Call Connecting...</span>
                         <span ng-show="currentCall.state == 'connected'">Call Connected</span>
-                        <span ng-show="currentCall.state == 'ended' && currentCall.hangupReason == 'ice_failed'">Media Connection Failed</span>
+                        <span ng-show="currentCall.state == 'ended' && currentCall.hangupReason == 'ice_failed'">Media Connection Failed{{ haveTurn ? "" : " (VoIP relaying unsupported by Home Server)" }}</span>
                         <span ng-show="currentCall.state == 'ended' && !currentCall.hangupReason && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'remote'">Call Rejected</span>
                         <span ng-show="currentCall.state == 'ended' && !currentCall.hangupReason && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local'">Call Canceled</span>
                         <span ng-show="currentCall.state == 'ended' && currentCall.hangupReason == 'invite_timeout' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local'">User Not Responding</span>
diff --git a/webclient/login/login.html b/webclient/login/login.html
index 6297ec4d42..6b321f8fc5 100644
--- a/webclient/login/login.html
+++ b/webclient/login/login.html
@@ -23,7 +23,7 @@
                     <br/>
                     <input id="password" size="32" type="password" ng-model="account.password" placeholder="Password"/>
                     <br/><br/>
-                    <button ng-click="login()" ng-disabled="!account.user_id || !account.password || !account.homeserver">Login</button>
+                    <button id="login" ng-click="login()" ng-disabled="!account.user_id || !account.password || !account.homeserver">Login</button>
                     <br/><br/>
                 </div>
 
diff --git a/webclient/recents/recents.html b/webclient/recents/recents.html
index edfc1677eb..9cbdcd357a 100644
--- a/webclient/recents/recents.html
+++ b/webclient/recents/recents.html
@@ -2,7 +2,7 @@
     <table class="recentsTable">
         <tbody ng-repeat="(index, room) in events.rooms | orderRecents" 
                ng-click="goToPage('room/' + (room.room_alias ? room.room_alias : room.room_id) )" 
-               class ="recentsRoom" 
+               class="recentsRoom" 
                ng-class="{'recentsRoomSelected': (room.room_id === recentsSelectedRoomID)}">                                           
             <tr>
                 <td ng-class="room['m.room.join_rules'].content.join_rule == 'public' ? 'recentsRoomName recentsPublicRoom' : 'recentsRoomName'">
@@ -19,6 +19,8 @@
                     {{ lastMsg = eventHandlerService.getLastMessage(room.room_id, true);"" }}
 
                     {{ (lastMsg.ts) | date:'MMM d HH:mm' }}
+                    
+                    <img ng-click="leave(room.room_id); $event.stopPropagation();" src="img/close.png" width="10" height="10" style="margin-bottom: -1px; margin-left: 2px;" alt="close"/>
                 </td>
             </tr>
 
@@ -31,28 +33,35 @@
                     
                     <div ng-hide="room.membership === 'invite'" ng-switch="lastMsg.type">
                         <div ng-switch-when="m.room.member">
-                            <span ng-if="'join' === lastMsg.content.membership">
-                                {{ lastMsg.state_key | mUserDisplayName: room.room_id}} joined
-                            </span>
-                            <span ng-if="'leave' === lastMsg.content.membership">
-                                <span ng-if="lastMsg.user_id === lastMsg.state_key">
-                                    {{lastMsg.state_key | mUserDisplayName: room.room_id }} left
-                                </span>
-                                <span ng-if="lastMsg.user_id !== lastMsg.state_key">
-                                    {{ lastMsg.user_id | mUserDisplayName: room.room_id }}
-                                    {{ {"join": "kicked", "ban": "unbanned"}[lastMsg.content.prev] }}
-                                    {{ lastMsg.state_key | mUserDisplayName: room.room_id }}
-                                </span>
-                                <span ng-if="'join' === lastMsg.content.prev && lastMsg.content.reason">
-                                    : {{ lastMsg.content.reason }}
+                            <span ng-switch="lastMsg.changedKey">
+                                <span ng-switch-when="membership">
+                                    <span ng-if="'join' === lastMsg.content.membership">
+                                        {{ lastMsg.state_key | mUserDisplayName: room.room_id }} joined
+                                    </span>
+                                    <span ng-if="'leave' === lastMsg.content.membership">
+                                        <span ng-if="lastMsg.user_id === lastMsg.state_key">
+                                            {{lastMsg.state_key | mUserDisplayName: room.room_id }} left
+                                        </span>
+                                        <span ng-if="lastMsg.user_id !== lastMsg.state_key">
+                                            {{ lastMsg.user_id | mUserDisplayName: room.room_id }}
+                                            {{ {"join": "kicked", "ban": "unbanned"}[lastMsg.content.prev] }}
+                                            {{ lastMsg.state_key | mUserDisplayName: room.room_id }}
+                                        </span>
+                                        <span ng-if="'join' === lastMsg.content.prev && lastMsg.content.reason">
+                                            : {{ lastMsg.content.reason }}
+                                        </span>
+                                    </span>
+                                    <span ng-if="'invite' === lastMsg.content.membership || 'ban' === lastMsg.content.membership">
+                                        {{ lastMsg.user_id | mUserDisplayName: room.room_id }}
+                                        {{ {"invite": "invited", "ban": "banned"}[lastMsg.content.membership] }}
+                                        {{ lastMsg.state_key | mUserDisplayName: room.room_id }}
+                                        <span ng-if="'ban' === lastMsg.content.prev && lastMsg.content.reason">
+                                            : {{ lastMsg.content.reason }}
+                                        </span>
+                                    </span>         
                                 </span>
-                            </span>
-                            <span ng-if="'invite' === lastMsg.content.membership || 'ban' === lastMsg.content.membership">
-                                {{ lastMsg.user_id | mUserDisplayName: room.room_id }}
-                                {{ {"invite": "invited", "ban": "banned"}[lastMsg.content.membership] }}
-                                {{ lastMsg.state_key | mUserDisplayName: room.room_id }}
-                                <span ng-if="'ban' === lastMsg.content.prev && lastMsg.content.reason">
-                                    : {{ lastMsg.content.reason }}
+                                <span ng-switch-when="displayname">
+                                    {{ lastMsg.user_id }} changed their display name from {{ lastMsg.prev_content.displayname }} to {{ lastMsg.content.displayname }}
                                 </span>
                             </span>
                         </div>
diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js
index c8104e39e6..d8c62c231e 100644
--- a/webclient/room/room-controller.js
+++ b/webclient/room/room-controller.js
@@ -400,6 +400,8 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         // 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);
@@ -409,6 +411,8 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         // 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;
             }
@@ -479,6 +483,15 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
                             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) {
@@ -702,19 +715,24 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
                 // The room members is available in the data fetched by initialSync
                 if ($rootScope.events.rooms[$scope.room_id]) {
 
-                    // There is no need to do a 1st pagination (initialSync provided enough to fill a page)
-                    if ($rootScope.events.rooms[$scope.room_id].messages.length) {
-                        $scope.state.first_pagination = false;
+                    var messages = $rootScope.events.rooms[$scope.room_id].messages;
+
+                    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 {
-                        // except 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;
+                        // There is no need to do a 1st pagination (initialSync provided enough to fill a page)
+                        $scope.state.first_pagination = false;
                     }
 
                     var members = $rootScope.events.rooms[$scope.room_id].members;
 
                     // Update the member list
                     for (var i in members) {
+                        if (!members.hasOwnProperty(i)) continue;
+
                         var member = members[i];
                         updateMemberList(member);
                     }
@@ -732,6 +750,16 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
                     $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
@@ -740,6 +768,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
                         },
                         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";
                         });
                 }
@@ -809,7 +838,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         
         matrixService.leave($scope.room_id).then(
             function(response) {
-                console.log("Left room ");
+                console.log("Left room " + $scope.room_id);
                 $location.url("home");
             },
             function(error) {
diff --git a/webclient/room/room-directive.js b/webclient/room/room-directive.js
index e033b003e1..05382cfcd3 100644
--- a/webclient/room/room-directive.js
+++ b/webclient/room/room-directive.js
@@ -21,39 +21,62 @@ angular.module('RoomController')
     return function (scope, element, attrs) {
         element.bind("keydown keypress", function (event) {
             // console.log("event: " + event.which);
-            if (event.which === 9) {
+            var TAB = 9;
+            var SHIFT = 16;
+            var keypressCode = event.which;
+            if (keypressCode === TAB) {
                 if (!scope.tabCompleting) { // cache our starting text
-                    // console.log("caching " + element[0].value);
                     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) {
-                        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);
+                // console.log("targetIndex: " + targetIndex + ", 
+                // text=" + text);
                                     
-                // FIXME: use the correct regexp to recognise userIDs
+                // 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) {
-                    element[0].value = text;
-                    
-                    // Force angular to wake up and update the input ng-model by firing up input event
+
+                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);
+                    // console.log("search found: " + search+" from "+text);
                     var expansion;
                     
                     // FIXME: could do better than linear search here
@@ -68,6 +91,7 @@ angular.module('RoomController')
                     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++;
@@ -76,18 +100,22 @@ angular.module('RoomController')
                         });
                     }
                     
-                    if (searchIndex === targetIndex) {
-                        // xchat-style tab complete
+                    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 += " : ";
+                            expansion += ": ";
                         else
                             expansion += " ";
                         element[0].value = text.replace(/@?([a-zA-Z0-9_\-:\.]+)$/, expansion);
                         // cancel blink
                         element[0].className = "";     
-                        
-                        // Force angular to wake up and update the input ng-model by firing up input event
-                        angular.element(element[0]).triggerHandler('input');
+                        if (targetIndex === Number.MAX_VALUE) {
+                            // wrap the index around to the last index found
+                            scope.tabCompleteIndex = searchIndex;
+                            targetIndex = searchIndex;
+                        }
                     }
                     else {
                         // console.log("wrapped!");
@@ -97,23 +125,40 @@ angular.module('RoomController')
                         }, 150);
                         element[0].value = text;
                         scope.tabCompleteIndex = 0;
-                        
-                        // Force angular to wake up and update the input ng-model by firing up input event
-                        angular.element(element[0]).triggerHandler('input');
                     }
+
+                    // 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 (event.which !== 16 && scope.tabCompleting) {
+            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.
diff --git a/webclient/room/room.html b/webclient/room/room.html
index db3aa193c5..b99413cbbf 100644
--- a/webclient/room/room.html
+++ b/webclient/room/room.html
@@ -48,7 +48,15 @@
                          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">{{ member.displayname || member.id.substr(0, member.id.indexOf(':')) }}<br/>{{ member.displayname ? "" : member.id.substr(member.id.indexOf(':')) }}</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>
@@ -65,7 +73,7 @@
             <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>
                 <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="sender" ng-hide="events.rooms[room_id].messages[$index - 1].user_id === msg.user_id"> {{ msg.user_id | mUserDisplayName: room_id }}</div>
                     <div class="timestamp"
                          ng-class="msg.echo_msg_state">
                         {{ (msg.content.hsob_ts || msg.ts) | date:'MMM d HH:mm' }}
@@ -77,10 +85,10 @@
                 </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">
-                        <span ng-if="'join' === msg.content.membership">
+                        <span ng-if="'join' === msg.content.membership && msg.changedKey === 'membership'">
                             {{ members[msg.state_key].displayname || msg.state_key }} joined
                         </span>
-                        <span ng-if="'leave' === msg.content.membership">
+                        <span ng-if="'leave' === msg.content.membership && msg.changedKey === 'membership'">
                             <span ng-if="msg.user_id === msg.state_key">
                                 {{ members[msg.state_key].displayname || msg.state_key }} left
                             </span>
@@ -93,7 +101,8 @@
                                 </span>
                             </span>
                         </span>
-                        <span ng-if="'invite' === msg.content.membership || 'ban' === msg.content.membership">
+                        <span ng-if="'invite' === msg.content.membership && msg.changedKey === 'membership' || 
+                                     'ban' === msg.content.membership && msg.changedKey === 'membership'">
                             {{ members[msg.user_id].displayname || msg.user_id }}
                             {{ {"invite": "invited", "ban": "banned"}[msg.content.membership] }}
                             {{ members[msg.state_key].displayname || msg.state_key }}
@@ -101,6 +110,9 @@
                                 : {{ 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"
@@ -159,8 +171,7 @@
                     <td width="*">
                         <textarea id="mainInput" rows="1" ng-enter="send()"
                                   ng-disabled="state.permission_denied"
-                                  ng-keydown="(38 === $event.which) ? history.goUp($event) : ((40 === $event.which) ? history.goDown($event) : 0)"
-                                  ng-focus="true" autocomplete="off" tab-complete/>
+                                  ng-focus="true" autocomplete="off" tab-complete command-history/>
                     </td>
                     <td id="buttonsCell">
                         <button ng-click="send()" ng-disabled="state.permission_denied">Send</button>
diff --git a/webclient/test/README b/webclient/test/README
index b1e0d7adea..1a7bc832c7 100644
--- a/webclient/test/README
+++ b/webclient/test/README
@@ -1,9 +1,33 @@
 Requires:
- - npm
+ - nodejs/npm
  - npm install karma
  - npm install jasmine
+ - npm install protractor (e2e testing)
 
-Setting up continuous integration / run the tests:
+Setting up continuous integration / run the unit tests (make sure you're in
+this directory so it can find the config file):
   karma start
 
 
+Setting up e2e tests (only if you don't have a selenium server to run the tests
+on. If you do, edit the config to point to that url):
+
+  webdriver-manager update
+  webdriver-manager start
+
+  Create a file "environment-protractor.js" in this directory and type:
+    module.exports = {
+        seleniumAddress: 'http://localhost:4444/wd/hub',
+        baseUrl: "http://localhost:8008",
+        username: "YOUR_TEST_USERNAME",
+        password: "YOUR_TEST_PASSWORD"
+    }
+
+Running e2e tests:
+  protractor protractor.conf.js
+
+NOTE: This will create a public room on the target home server.
+
+
+
+
diff --git a/webclient/test/e2e/home.spec.js b/webclient/test/e2e/home.spec.js
new file mode 100644
index 0000000000..470237d557
--- /dev/null
+++ b/webclient/test/e2e/home.spec.js
@@ -0,0 +1,16 @@
+var env = require("../environment-protractor.js");
+
+describe("home page", function() {
+
+    beforeEach(function() {
+        ptor = protractor.getInstance();
+        // FIXME we use longpoll on the event stream, and I can't get $interval
+        // playing nicely with it. Patches welcome to fix this.
+        ptor.ignoreSynchronization = true;
+    }); 
+
+    it("should have a title", function() {
+        browser.get(env.baseUrl);
+        expect(browser.getTitle()).toEqual("[matrix]");
+    });
+});
diff --git a/webclient/test/protractor.conf.js b/webclient/test/protractor.conf.js
new file mode 100644
index 0000000000..76ae7b712b
--- /dev/null
+++ b/webclient/test/protractor.conf.js
@@ -0,0 +1,18 @@
+var env = require("./environment-protractor.js");
+exports.config = {
+    seleniumAddress: env.seleniumAddress,
+    specs: ['e2e/*.spec.js'],
+    onPrepare: function() {
+        browser.driver.get(env.baseUrl);
+        browser.driver.findElement(by.id("user_id")).sendKeys(env.username);
+        browser.driver.findElement(by.id("password")).sendKeys(env.password);
+        browser.driver.findElement(by.id("login")).click();
+
+        // wait till the login is done, detect via url change
+        browser.driver.wait(function() {
+            return browser.driver.getCurrentUrl().then(function(url) {
+                return !(/login/.test(url))
+            });
+        });
+    }
+}
diff --git a/webclient/test/unit/user-controller.spec.js b/webclient/test/unit/user-controller.spec.js
index 217559114b..798cc4de48 100644
--- a/webclient/test/unit/user-controller.spec.js
+++ b/webclient/test/unit/user-controller.spec.js
@@ -21,13 +21,12 @@ describe("UserCtrl", function() {
 
                 getDisplayName: function(uid) {
                     var d = $q.defer();
-                    // FIXME: everything goes into fire here
                     d.resolve({
                         data: {
                             displayname: displayName
                         }
                     });
-                    return d;
+                    return d.promise;
                 },
 
                 getProfilePictureUrl: function(uid) {
@@ -37,7 +36,7 @@ describe("UserCtrl", function() {
                             avatar_url: avatarUrl
                         }
                     });
-                    return d;
+                    return d.promise;
                 }
             };
             scope = $rootScope.$new();
@@ -49,7 +48,6 @@ describe("UserCtrl", function() {
                 '$routeParams': routeParams, 
                 'matrixService': matrixService
             });
-            console.log("end inject");
         });
     });
 
diff --git a/webclient/user/user-controller.js b/webclient/user/user-controller.js
index 3940db6683..0dbfa325d0 100644
--- a/webclient/user/user-controller.js
+++ b/webclient/user/user-controller.js
@@ -38,7 +38,8 @@ angular.module('UserController', ['matrixService'])
             $scope.user.avatar_url = response.data.avatar_url;
         }
     );
-    
+
+    // FIXME: factor this out between user-controller and home-controller etc.
     $scope.messageUser = function() {    
         
         // FIXME: create a new room every time, for now