summary refs log tree commit diff
diff options
context:
space:
mode:
authorMark Haines <mark.haines@matrix.org>2014-09-01 13:29:17 +0100
committerMark Haines <mark.haines@matrix.org>2014-09-01 13:29:17 +0100
commita9512d0994bba12cd3684d37030ce68ebe9330b3 (patch)
tree6a80e8f1fa89fccf0cd90ea251edb7c1fb28224c
parentMerge branch 'develop' into server2server_tls (diff)
parentadd another public wishlist item (diff)
downloadsynapse-a9512d0994bba12cd3684d37030ce68ebe9330b3.tar.xz
Merge branch 'develop' into server2server_tls
-rw-r--r--WISHLIST.rst7
-rwxr-xr-xcmdclient/console.py12
-rw-r--r--docs/client-server/howto.rst22
-rw-r--r--docs/client-server/specification.rst4
-rw-r--r--docs/client-server/swagger_matrix/directory2
-rw-r--r--docs/client-server/swagger_matrix/events2
-rw-r--r--docs/client-server/swagger_matrix/login2
-rw-r--r--docs/client-server/swagger_matrix/presence2
-rw-r--r--docs/client-server/swagger_matrix/profile2
-rw-r--r--docs/client-server/swagger_matrix/registration2
-rw-r--r--docs/client-server/swagger_matrix/rooms2
-rw-r--r--docs/server-server/specification.rst2
-rw-r--r--docs/specification.rst162
-rw-r--r--graph/graph.py2
-rw-r--r--jsfiddles/create_room_send_msg/demo.js8
-rw-r--r--jsfiddles/event_stream/demo.js8
-rw-r--r--jsfiddles/example_app/demo.js18
-rw-r--r--jsfiddles/register_login/demo.js8
-rw-r--r--jsfiddles/room_memberships/demo.js10
-rw-r--r--synapse/api/urls.py8
-rw-r--r--synapse/handlers/message.py6
-rw-r--r--synapse/handlers/presence.py89
-rw-r--r--synapse/handlers/room.py46
-rw-r--r--synapse/handlers/typing.py14
-rw-r--r--synapse/http/server.py2
-rw-r--r--synapse/notifier.py30
-rw-r--r--synapse/streams/events.py164
-rw-r--r--synapse/types.py15
-rw-r--r--tests/federation/test_federation.py18
-rw-r--r--tests/handlers/test_presence.py152
-rw-r--r--tests/handlers/test_typing.py6
-rw-r--r--tests/rest/test_events.py2
-rw-r--r--tests/rest/test_presence.py24
-rw-r--r--tests/rest/test_profile.py2
-rw-r--r--tests/rest/test_rooms.py2
-rw-r--r--webclient/app-controller.js19
-rw-r--r--webclient/app-directive.js7
-rwxr-xr-x[-rw-r--r--]webclient/app.css319
-rw-r--r--webclient/app.js10
-rw-r--r--webclient/components/fileUpload/file-upload-service.js2
-rw-r--r--webclient/components/matrix/matrix-call.js3
-rw-r--r--webclient/components/matrix/matrix-service.js26
-rw-r--r--webclient/home/home-controller.js29
-rw-r--r--webclient/home/home.html40
-rw-r--r--webclient/img/default-profile.jpgbin2311 -> 0 bytes
-rw-r--r--webclient/img/default-profile.pngbin0 -> 1722 bytes
-rw-r--r--webclient/img/logo-small.pngbin0 -> 910 bytes
-rw-r--r--webclient/img/logo.pngbin0 -> 4060 bytes
-rw-r--r--webclient/index.html19
-rw-r--r--webclient/login/login-controller.js78
-rw-r--r--webclient/login/login.html88
-rw-r--r--webclient/login/register-controller.js102
-rw-r--r--webclient/login/register.html48
-rw-r--r--webclient/mobile.css92
-rw-r--r--webclient/room/room-controller.js61
-rw-r--r--webclient/room/room.html36
-rw-r--r--webclient/settings/settings-controller.js67
-rw-r--r--webclient/settings/settings.html71
-rw-r--r--webclient/user/user-controller.js28
-rw-r--r--webclient/user/user.html36
60 files changed, 1291 insertions, 747 deletions
diff --git a/WISHLIST.rst b/WISHLIST.rst
new file mode 100644
index 0000000000..68324ad9d4
--- /dev/null
+++ b/WISHLIST.rst
@@ -0,0 +1,7 @@
+Broad-sweeping stuff which would be nice to have
+================================================
+
+ - Additional SQL backends beyond sqlite
+ - homeserver implementation in go
+ - homeserver implementation in node.js
+ - client SDKs
diff --git a/cmdclient/console.py b/cmdclient/console.py
index 7bda4000fc..7678b5e352 100755
--- a/cmdclient/console.py
+++ b/cmdclient/console.py
@@ -60,7 +60,7 @@ class SynapseCmd(cmd.Cmd):
             "complete_usernames": "on",
             "send_delivery_receipts": "on"
         }
-        self.path_prefix = "/matrix/client/api/v1"
+        self.path_prefix = "/_matrix/client/api/v1"
         self.event_stream_token = "END"
         self.prompt = ">>> "
 
@@ -252,7 +252,7 @@ class SynapseCmd(cmd.Cmd):
 
     @defer.inlineCallbacks
     def _do_emailrequest(self, args):
-        url = self._identityServerUrl()+"/matrix/identity/api/v1/validate/email/requestToken"
+        url = self._identityServerUrl()+"/_matrix/identity/api/v1/validate/email/requestToken"
 
         json_res = yield self.http_client.do_request("POST", url, data=urllib.urlencode(args), jsonreq=False,
                                                      headers={'Content-Type': ['application/x-www-form-urlencoded']})
@@ -274,7 +274,7 @@ class SynapseCmd(cmd.Cmd):
 
     @defer.inlineCallbacks
     def _do_emailvalidate(self, args):
-        url = self._identityServerUrl()+"/matrix/identity/api/v1/validate/email/submitToken"
+        url = self._identityServerUrl()+"/_matrix/identity/api/v1/validate/email/submitToken"
 
         json_res = yield self.http_client.do_request("POST", url, data=urllib.urlencode(args), jsonreq=False,
                                                      headers={'Content-Type': ['application/x-www-form-urlencoded']})
@@ -294,7 +294,7 @@ class SynapseCmd(cmd.Cmd):
 
     @defer.inlineCallbacks
     def _do_3pidbind(self, args):
-        url = self._identityServerUrl()+"/matrix/identity/api/v1/3pid/bind"
+        url = self._identityServerUrl()+"/_matrix/identity/api/v1/3pid/bind"
 
         json_res = yield self.http_client.do_request("POST", url, data=urllib.urlencode(args), jsonreq=False,
                                                      headers={'Content-Type': ['application/x-www-form-urlencoded']})
@@ -360,14 +360,14 @@ class SynapseCmd(cmd.Cmd):
     def _do_invite(self, roomid, userstring):
         if (not userstring.startswith('@') and
                     self._is_on("complete_usernames")):
-            url = self._identityServerUrl()+"/matrix/identity/api/v1/lookup"
+            url = self._identityServerUrl()+"/_matrix/identity/api/v1/lookup"
 
             json_res = yield self.http_client.do_request("GET", url, qparams={'medium':'email','address':userstring})
 
             mxid = None
 
             if 'mxid' in json_res and 'signatures' in json_res:
-                url = self._identityServerUrl()+"/matrix/identity/api/v1/pubkey/ed25519"
+                url = self._identityServerUrl()+"/_matrix/identity/api/v1/pubkey/ed25519"
 
                 pubKey = None
                 pubKeyObj = yield self.http_client.do_request("GET", url)
diff --git a/docs/client-server/howto.rst b/docs/client-server/howto.rst
index 9ef4cb5f78..3660c73d36 100644
--- a/docs/client-server/howto.rst
+++ b/docs/client-server/howto.rst
@@ -30,7 +30,7 @@ Registration
 The aim of registration is to get a user ID and access token which you will need
 when accessing other APIs::
 
-    curl -XPOST -d '{"user_id":"example", "password":"wordpass"}' "http://localhost:8080/matrix/client/api/v1/register"
+    curl -XPOST -d '{"user_id":"example", "password":"wordpass"}' "http://localhost:8080/_matrix/client/api/v1/register"
 
     {
         "access_token": "QGV4YW1wbGU6bG9jYWxob3N0.AqdSzFmFYrLrTmteXc", 
@@ -51,13 +51,13 @@ Login
 -----
 The aim when logging in is to get an access token for your existing user ID::
 
-    curl -XGET "http://localhost:8080/matrix/client/api/v1/login"
+    curl -XGET "http://localhost:8080/_matrix/client/api/v1/login"
 
     {
         "type": "m.login.password"
     }
 
-    curl -XPOST -d '{"type":"m.login.password", "user":"example", "password":"wordpass"}' "http://localhost:8080/matrix/client/api/v1/login"
+    curl -XPOST -d '{"type":"m.login.password", "user":"example", "password":"wordpass"}' "http://localhost:8080/_matrix/client/api/v1/login"
 
     {
         "access_token": "QGV4YW1wbGU6bG9jYWxob3N0.vRDLTgxefmKWQEtgGd", 
@@ -87,7 +87,7 @@ Creating a room
 If you want to send a message to someone, you have to be in a room with them. To
 create a room::
 
-    curl -XPOST -d '{"room_alias_name":"tutorial"}' "http://localhost:8080/matrix/client/api/v1/rooms?access_token=QGV4YW1wbGU6bG9jYWxob3N0.vRDLTgxefmKWQEtgGd"
+    curl -XPOST -d '{"room_alias_name":"tutorial"}' "http://localhost:8080/_matrix/client/api/v1/rooms?access_token=QGV4YW1wbGU6bG9jYWxob3N0.vRDLTgxefmKWQEtgGd"
 
     {
         "room_alias": "#tutorial:localhost", 
@@ -105,7 +105,7 @@ Sending messages
 ----------------
 You can now send messages to this room::
 
-    curl -XPUT -d '{"msgtype":"m.text", "body":"hello"}' "http://localhost:8080/matrix/client/api/v1/rooms/%21CvcvRuDYDzTOzfKKgh:localhost/messages/%40example%3Alocalhost/msgid1?access_token=QGV4YW1wbGU6bG9jYWxob3N0.vRDLTgxefmKWQEtgGd"
+    curl -XPUT -d '{"msgtype":"m.text", "body":"hello"}' "http://localhost:8080/_matrix/client/api/v1/rooms/%21CvcvRuDYDzTOzfKKgh:localhost/messages/%40example%3Alocalhost/msgid1?access_token=QGV4YW1wbGU6bG9jYWxob3N0.vRDLTgxefmKWQEtgGd"
     
 NB: There are no limitations to the types of messages which can be exchanged.
 The only requirement is that ``"msgtype"`` is specified.
@@ -127,7 +127,7 @@ Inviting a user to a room
 -------------------------
 You can directly invite a user to a room like so::
 
-    curl -XPUT -d '{"membership":"invite"}' "http://localhost:8080/matrix/client/api/v1/rooms/%21CvcvRuDYDzTOzfKKgh:localhost/members/%40myfriend%3Alocalhost/state?access_token=QGV4YW1wbGU6bG9jYWxob3N0.vRDLTgxefmKWQEtgGd"
+    curl -XPUT -d '{"membership":"invite"}' "http://localhost:8080/_matrix/client/api/v1/rooms/%21CvcvRuDYDzTOzfKKgh:localhost/members/%40myfriend%3Alocalhost/state?access_token=QGV4YW1wbGU6bG9jYWxob3N0.vRDLTgxefmKWQEtgGd"
     
 This informs ``@myfriend:localhost`` of the room ID 
 ``!CvcvRuDYDzTOzfKKgh:localhost`` and allows them to join the room.
@@ -137,7 +137,7 @@ Joining a room via an invite
 If you receive an invite, you can join the room by changing the membership to
 join::
 
-    curl -XPUT -d '{"membership":"join"}' "http://localhost:8080/matrix/client/api/v1/rooms/%21CvcvRuDYDzTOzfKKgh:localhost/members/%40myfriend%3Alocalhost/state?access_token=QG15ZnJpZW5kOmxvY2FsaG9zdA...XKuGdVsovHmwMyDDvK"
+    curl -XPUT -d '{"membership":"join"}' "http://localhost:8080/_matrix/client/api/v1/rooms/%21CvcvRuDYDzTOzfKKgh:localhost/members/%40myfriend%3Alocalhost/state?access_token=QG15ZnJpZW5kOmxvY2FsaG9zdA...XKuGdVsovHmwMyDDvK"
     
 NB: Only the person invited (``@myfriend:localhost``) can change the membership
 state to ``"join"``.
@@ -147,7 +147,7 @@ Joining a room via an alias
 Alternatively, if you know the room alias for this room and the room config 
 allows it, you can directly join a room via the alias::
 
-    curl -XPUT -d '{}' "http://localhost:8080/matrix/client/api/v1/join/%23tutorial%3Alocalhost?access_token=QG15ZnJpZW5kOmxvY2FsaG9zdA...XKuGdVsovHmwMyDDvK"
+    curl -XPUT -d '{}' "http://localhost:8080/_matrix/client/api/v1/join/%23tutorial%3Alocalhost?access_token=QG15ZnJpZW5kOmxvY2FsaG9zdA...XKuGdVsovHmwMyDDvK"
     
     {
         "room_id": "!CvcvRuDYDzTOzfKKgh:localhost"
@@ -173,7 +173,7 @@ Getting all state
 If the client doesn't know any information on the rooms the user is 
 invited/joined on, they can get all the user's state for all rooms::
 
-    curl -XGET "http://localhost:8080/matrix/client/api/v1/im/sync?access_token=QG15ZnJpZW5kOmxvY2FsaG9zdA...XKuGdVsovHmwMyDDvK"
+    curl -XGET "http://localhost:8080/_matrix/client/api/v1/im/sync?access_token=QG15ZnJpZW5kOmxvY2FsaG9zdA...XKuGdVsovHmwMyDDvK"
     
     [
         {
@@ -236,7 +236,7 @@ all of the messages and feedback for these rooms. This can be a LOT of data. You
 may just want the most recent message for each room. This can be achieved by 
 applying pagination stream parameters to this request::
 
-    curl -XGET "http://localhost:8080/matrix/client/api/v1/im/sync?access_token=QG15ZnJpZW5kOmxvY2FsaG9zdA...XKuGdVsovHmwMyDDvK&from=END&to=START&limit=1"
+    curl -XGET "http://localhost:8080/_matrix/client/api/v1/im/sync?access_token=QG15ZnJpZW5kOmxvY2FsaG9zdA...XKuGdVsovHmwMyDDvK&from=END&to=START&limit=1"
     
     [
         {
@@ -271,7 +271,7 @@ Getting live state
 Once you know which rooms the client has previously interacted with, you need to
 listen for incoming events. This can be done like so::
 
-    curl -XGET "http://localhost:8080/matrix/client/api/v1/events?access_token=QG15ZnJpZW5kOmxvY2FsaG9zdA...XKuGdVsovHmwMyDDvK&from=END"
+    curl -XGET "http://localhost:8080/_matrix/client/api/v1/events?access_token=QG15ZnJpZW5kOmxvY2FsaG9zdA...XKuGdVsovHmwMyDDvK&from=END"
     
     {
         "chunk": [], 
diff --git a/docs/client-server/specification.rst b/docs/client-server/specification.rst
index 4c1a3d4456..ee8bb5c420 100644
--- a/docs/client-server/specification.rst
+++ b/docs/client-server/specification.rst
@@ -306,11 +306,11 @@ POST requests MUST be submitted as application/json.
 All paths MUST be namespaced by the version of the API being used. This should
 be:
 
-/matrix/client/api/v1
+/_matrix/client/api/v1
 
 All REST paths in this section MUST be prefixed with this. E.g.
   REST Path: /rooms/$room_id
-  Absolute Path: /matrix/client/api/v1/rooms/$room_id
+  Absolute Path: /_matrix/client/api/v1/rooms/$room_id
 
 Registration
 ============
diff --git a/docs/client-server/swagger_matrix/directory b/docs/client-server/swagger_matrix/directory
index 3f3bef9c11..98109a0fbc 100644
--- a/docs/client-server/swagger_matrix/directory
+++ b/docs/client-server/swagger_matrix/directory
@@ -1,7 +1,7 @@
 {
   "apiVersion": "1.0.0",
   "swaggerVersion": "1.2",
-  "basePath": "http://localhost:8080/matrix/client/api/v1",
+  "basePath": "http://localhost:8080/_matrix/client/api/v1",
   "resourcePath": "/directory",
   "produces": [
     "application/json"
diff --git a/docs/client-server/swagger_matrix/events b/docs/client-server/swagger_matrix/events
index ca69d34db5..d22c68cc78 100644
--- a/docs/client-server/swagger_matrix/events
+++ b/docs/client-server/swagger_matrix/events
@@ -1,7 +1,7 @@
 {
   "apiVersion": "1.0.0",
   "swaggerVersion": "1.2",
-  "basePath": "http://localhost:8080/matrix/client/api/v1",
+  "basePath": "http://localhost:8080/_matrix/client/api/v1",
   "resourcePath": "/events",
   "produces": [
     "application/json"
diff --git a/docs/client-server/swagger_matrix/login b/docs/client-server/swagger_matrix/login
index 4410d3c887..8cc598b3c1 100644
--- a/docs/client-server/swagger_matrix/login
+++ b/docs/client-server/swagger_matrix/login
@@ -40,7 +40,7 @@
       "path": "/login"
     }
   ], 
-  "basePath": "http://localhost:8080/matrix/client/api/v1", 
+  "basePath": "http://localhost:8080/_matrix/client/api/v1", 
   "consumes": [
     "application/json"
   ], 
diff --git a/docs/client-server/swagger_matrix/presence b/docs/client-server/swagger_matrix/presence
index 1b4c7323aa..d52ce2164a 100644
--- a/docs/client-server/swagger_matrix/presence
+++ b/docs/client-server/swagger_matrix/presence
@@ -1,7 +1,7 @@
 {
   "apiVersion": "1.0.0",
   "swaggerVersion": "1.2",
-  "basePath": "http://localhost:8080/matrix/client/api/v1",
+  "basePath": "http://localhost:8080/_matrix/client/api/v1",
   "resourcePath": "/presence",
   "produces": [
     "application/json"
diff --git a/docs/client-server/swagger_matrix/profile b/docs/client-server/swagger_matrix/profile
index 1ebde62e20..188259fa3d 100644
--- a/docs/client-server/swagger_matrix/profile
+++ b/docs/client-server/swagger_matrix/profile
@@ -1,7 +1,7 @@
 {
   "apiVersion": "1.0.0",
   "swaggerVersion": "1.2",
-  "basePath": "http://localhost:8080/matrix/client/api/v1",
+  "basePath": "http://localhost:8080/_matrix/client/api/v1",
   "resourcePath": "/profile",
   "produces": [
     "application/json"
diff --git a/docs/client-server/swagger_matrix/registration b/docs/client-server/swagger_matrix/registration
index ccd542d11e..2048aec1d2 100644
--- a/docs/client-server/swagger_matrix/registration
+++ b/docs/client-server/swagger_matrix/registration
@@ -37,7 +37,7 @@
       "path": "/register"
     }
   ], 
-  "basePath": "http://localhost:8080/matrix/client/api/v1", 
+  "basePath": "http://localhost:8080/_matrix/client/api/v1", 
   "consumes": [
     "application/json"
   ], 
diff --git a/docs/client-server/swagger_matrix/rooms b/docs/client-server/swagger_matrix/rooms
index 1c32d135aa..7d3341f097 100644
--- a/docs/client-server/swagger_matrix/rooms
+++ b/docs/client-server/swagger_matrix/rooms
@@ -1,7 +1,7 @@
 {
   "apiVersion": "1.0.0",
   "swaggerVersion": "1.2",
-  "basePath": "http://localhost:8080/matrix/client/api/v1", 
+  "basePath": "http://localhost:8080/_matrix/client/api/v1", 
   "resourcePath": "/rooms",
   "produces": [
     "application/json"
diff --git a/docs/server-server/specification.rst b/docs/server-server/specification.rst
index a9ab9bff66..17cffafdd4 100644
--- a/docs/server-server/specification.rst
+++ b/docs/server-server/specification.rst
@@ -155,7 +155,7 @@ Protocol URLs
 
 All these URLs are namespaced within a prefix of 
 
-  /matrix/federation/v1/...
+  /_matrix/federation/v1/...
 
 For active pushing of messages representing live activity "as it happens":
 
diff --git a/docs/specification.rst b/docs/specification.rst
index d4a01a3fc2..3288aac019 100644
--- a/docs/specification.rst
+++ b/docs/specification.rst
@@ -35,8 +35,8 @@ namespaced to the home server which allocated the account and looks like::
 
   @localpart:domain
 
-The ``localpart`` of a user ID may be a user name, or an opaque ID identifying this user.
-
+The ``localpart`` of a user ID may be a user name, or an opaque ID identifying this user. They are
+case-insensitive.
 
 A "Home Server" is a server which provides C-S APIs and has the ability to federate with other HSes.
 It is typically responsible for multiple clients. "Federation" is the term used to describe the
@@ -60,7 +60,8 @@ identified via a "Room ID", which look like::
 
 There is exactly one room ID for each room. Whilst the room ID does contain a
 domain, it is simply for namespacing room IDs. The room does NOT reside on the
-domain specified. Room IDs are not meant to be human readable.
+domain specified. Room IDs are not meant to be human readable. They ARE
+case-sensitive.
 
 The following diagram shows an ``m.room.message`` event being sent in the room 
 ``!qporfwt:matrix.org``::
@@ -102,10 +103,10 @@ Each room can also have multiple "Room Aliases", which looks like::
 
 A room alias "points" to a room ID. The room ID the alias is pointing to can be obtained
 by visiting the domain specified. Room aliases are designed to be human readable strings
-which can be used to publicise rooms. Note that the mapping from a room alias to a 
-room ID is not fixed, and may change over time to point to a different room ID. For this
-reason, Clients SHOULD resolve the room alias to a room ID once and then use that ID on
-subsequent requests.
+which can be used to publicise rooms. They are case-insensitive. Note that the mapping 
+from a room alias to a room ID is not fixed, and may change over time to point to a 
+different room ID. For this reason, Clients SHOULD resolve the room alias to a room ID 
+once and then use that ID on subsequent requests.
 
 ::
 
@@ -214,24 +215,150 @@ In contrast, these are invalid requests::
       "key": "This is a put but it is missing a txnId."
     }
 
+
+
+- TODO: All strings everywhere are UTF-8
+
+
+
 Receiving live updates on a client
 ----------------------------------
-- C-S longpoll event stream
-- Concept of start/end tokens.
-- Mention /initialSync to get token.
+Clients can receive new events by long-polling the home server. This will hold open the
+HTTP connection for a short period of time waiting for new events, returning early if an
+event occurs. This is called the "Event Stream". All events which the client is authorised 
+to view will appear in the event stream. When the stream is closed, an ``end`` token is 
+returned. This token can be used in the next request to continue where the client left off.
 
+When the client first logs in, they will need to initially synchronise with their home
+server. This is achieved via the ``/initialSync`` API. This API also returns an ``end``
+token which can be used with the event stream.
 
 Rooms
 =====
-- How are they created? PDU anchor point: "root of the tree".
+
+Creation
+--------
+To create a room, a client has to use the ``/createRoom`` API. There are various options
+which can be set when creating a room:
+
+``visibility``
+  Type: 
+    String
+  Optional: 
+    Yes
+  Value:
+    Either ``public`` or ``private``.
+  Description:
+    A ``public`` visibility indicates that the room will be shown in the public room list. A
+    ``private`` visibility will hide the room from the public room list. Rooms default to
+    ``public`` visibility if this key is not included.
+
+``room_alias_name``
+  Type: 
+    String
+  Optional: 
+    Yes
+  Value:
+    The room alias localpart.
+  Description:
+    If this is included, a room alias will be created and mapped to the newly created room.
+    The alias will belong on the same home server which created the room, e.g.
+    ``!qadnasoi:domain.com >>> #room_alias_name:domain.com``
+
+Example::
+
+  {
+    "visibility": "public", 
+    "room_alias_name": "the pub"
+  }
+
+- TODO: This creates a room creation event which serves as the root of the PDU graph for this room.
+
+Modifying aliases
+-----------------
 - Adding / removing aliases.
-- Invite/join dance
-- State and non-state data (+extensibility)
 
-TODO : Room permissions / config / power levels.
+Permissions
+-----------
+- TODO : Room permissions / config / power levels. What they are. How do they work. Examples.
+
+Joining rooms
+-------------
+- What is joining? What permissions / access does it give you? How does this affect /initialSync?
+- API to hit (``/join/$alias or id``). Explain how alias joining works (auto-resolving).  See "Room events" for more info.
+- What does the home server have to do?
+- Rooms that DON'T need an invite to join. This follows through onto inviting users section.
+- Outline invite join dance?
 
-Messages
-========
+
+Inviting users
+--------------
+- Can invite users to a room if the room config key TODO is set to TODO. Must have required power level.
+- Outline invite join dance. What is it? Why is it required? How does it work?
+- What does the home server have to do?
+
+The purpose of inviting users to a room is to notify them that the room exists 
+so they can choose to become a member of that room. Some rooms require that all 
+users who join a room are previously invited to it (an "invite-only" room). 
+Whether a given room is an "invite-only" room is determined by the room config 
+key ``TODO``. It can have one of the following values:
+
+ - TODO Room config invite only value explanation
+ - TODO Room config free-to-join value explanation
+
+Only users who have a membership state of ``join`` in a room can invite new 
+users to said room. The person being invited must not be in the ``join`` state 
+in the room. The fully-qualified user ID must be specified when inviting a user, 
+as the user may reside on a different home server. To invite a user, send the 
+following request to ``/rooms/<room id>/invite``, which will manage the 
+entire invitation process::
+
+  {
+    "user_id": "<user id to invite>"
+  }
+
+Alternatively, the membership state for this user in this room can be modified 
+directly by sending the following request to 
+``/rooms/<room id>/state/m.room.member/<url encoded user id>``::
+
+  {
+    "membership": "invite"
+  }
+
+See the "Room events" section for more information on ``m.room.member``.
+
+- TODO: In what circumstances will this NOT be equivalent to ``/invite``?
+
+Leaving rooms
+-------------
+- API to hit (``$roomid/leave``). See "Room events" for more info.
+- Must be joined to leave. How does this affect /initialSync?
+- Not ever being in a room is NOT equivalent to have left it (due to membership: leave).
+- Need to be re-invited if invite-only room.
+- If no more HSes in room, can delete room?
+- Is there a dance?
+
+Events in a room
+----------------
+- Split into state and non-state data
+- Explain what they are, semantics, give examples of clobbering / not, use cases (msgs vs room names).
+  Not too much detail on the actual event contents.
+- API to hit.
+- Extensibility provided by the API for custom events. Examples.
+- How this hooks into ``initialSync``.
+- See the "Room Events" section for actual spec on each type.
+
+Syncing a room
+--------------
+- Single room initial sync. API to hit. Why it might be used (lazy loading)
+
+Getting grouped state events
+----------------------------
+- ``/members`` and ``/messages`` and the events they return.
+- ``/state`` and it returns ALL THE THINGS.
+
+Room Events
+===========
 
 This specification outlines several standard event types, all of which are
 prefixed with ``m.``
@@ -244,7 +371,8 @@ State messages
 - m.room.config
 - m.room.invite_join
 
-What are they, when are they used, what do they contain, how should they be used
+What are they, when are they used, what do they contain, how should they be used.
+Link back to explanatory sections (e.g. invite/join/leave sections for m.room.member)
 
 Non-state messages
 ------------------
diff --git a/graph/graph.py b/graph/graph.py
index 220f5eb1d5..ac06d979e1 100644
--- a/graph/graph.py
+++ b/graph/graph.py
@@ -120,7 +120,7 @@ def make_graph(pdus, room, filename_prefix):
 def get_pdus(host, room):
     transaction = json.loads(
         urllib2.urlopen(
-            "http://%s/matrix/federation/v1/context/%s/" % (host, room)
+            "http://%s/_matrix/federation/v1/context/%s/" % (host, room)
         ).read()
     )
 
diff --git a/jsfiddles/create_room_send_msg/demo.js b/jsfiddles/create_room_send_msg/demo.js
index db2ae2d606..61044da743 100644
--- a/jsfiddles/create_room_send_msg/demo.js
+++ b/jsfiddles/create_room_send_msg/demo.js
@@ -10,7 +10,7 @@ $('.login').live('click', function() {
     var user = $("#userLogin").val();
     var password = $("#passwordLogin").val();
     $.ajax({
-        url: "http://localhost:8080/matrix/client/api/v1/login",
+        url: "http://localhost:8080/_matrix/client/api/v1/login",
         type: "POST",
         contentType: "application/json; charset=utf-8",
         data: JSON.stringify({ user: user, password: password, type: "m.login.password" }),
@@ -25,7 +25,7 @@ $('.login').live('click', function() {
 });
 
 var getCurrentRoomList = function() {
-    var url = "http://localhost:8080/matrix/client/api/v1/initialSync?access_token=" + accountInfo.access_token + "&limit=1";
+    var url = "http://localhost:8080/_matrix/client/api/v1/initialSync?access_token=" + accountInfo.access_token + "&limit=1";
     $.getJSON(url, function(data) {
         var rooms = data.rooms;
         for (var i=0; i<rooms.length; ++i) {
@@ -44,7 +44,7 @@ $('.createRoom').live('click', function() {
         data.room_alias_name = roomAlias;   
     }
     $.ajax({
-        url: "http://localhost:8080/matrix/client/api/v1/createRoom?access_token="+accountInfo.access_token,
+        url: "http://localhost:8080/_matrix/client/api/v1/createRoom?access_token="+accountInfo.access_token,
         type: "POST",
         contentType: "application/json; charset=utf-8",
         data: JSON.stringify(data),
@@ -79,7 +79,7 @@ $('.sendMessage').live('click', function() {
         return;
     }
     
-    var url = "http://localhost:8080/matrix/client/api/v1/rooms/$roomid/send/m.room.message?access_token=$token";
+    var url = "http://localhost:8080/_matrix/client/api/v1/rooms/$roomid/send/m.room.message?access_token=$token";
     url = url.replace("$token", accountInfo.access_token);
     url = url.replace("$roomid", encodeURIComponent(roomId));
     
diff --git a/jsfiddles/event_stream/demo.js b/jsfiddles/event_stream/demo.js
index 3f132cd98e..997d1a2240 100644
--- a/jsfiddles/event_stream/demo.js
+++ b/jsfiddles/event_stream/demo.js
@@ -7,7 +7,7 @@ var eventStreamInfo = {
 var roomInfo = [];
 
 var longpollEventStream = function() {
-    var url = "http://localhost:8080/matrix/client/api/v1/events?access_token=$token&from=$from";
+    var url = "http://localhost:8080/_matrix/client/api/v1/events?access_token=$token&from=$from";
     url = url.replace("$token", accountInfo.access_token);
     url = url.replace("$from", eventStreamInfo.from);
     
@@ -48,7 +48,7 @@ $('.login').live('click', function() {
     var user = $("#userLogin").val();
     var password = $("#passwordLogin").val();
     $.ajax({
-        url: "http://localhost:8080/matrix/client/api/v1/login",
+        url: "http://localhost:8080/_matrix/client/api/v1/login",
         type: "POST",
         contentType: "application/json; charset=utf-8",
         data: JSON.stringify({ user: user, password: password, type: "m.login.password" }),
@@ -65,7 +65,7 @@ $('.login').live('click', function() {
 
 var getCurrentRoomList = function() {
     $("#roomId").val("");
-    var url = "http://localhost:8080/matrix/client/api/v1/initialSync?access_token=" + accountInfo.access_token + "&limit=1";
+    var url = "http://localhost:8080/_matrix/client/api/v1/initialSync?access_token=" + accountInfo.access_token + "&limit=1";
     $.getJSON(url, function(data) {
         var rooms = data.rooms;
         for (var i=0; i<rooms.length; ++i) {
@@ -98,7 +98,7 @@ var sendMessage = function(roomId) {
         return;
     }
     
-    var url = "http://localhost:8080/matrix/client/api/v1/rooms/$roomid/send/m.room.message?access_token=$token";
+    var url = "http://localhost:8080/_matrix/client/api/v1/rooms/$roomid/send/m.room.message?access_token=$token";
     url = url.replace("$token", accountInfo.access_token);
     url = url.replace("$roomid", encodeURIComponent(roomId));
     
diff --git a/jsfiddles/example_app/demo.js b/jsfiddles/example_app/demo.js
index f5e08b326a..958232047f 100644
--- a/jsfiddles/example_app/demo.js
+++ b/jsfiddles/example_app/demo.js
@@ -10,7 +10,7 @@ var viewingRoomId;
 
 // ************** Event Streaming **************
 var longpollEventStream = function() {
-    var url = "http://localhost:8080/matrix/client/api/v1/events?access_token=$token&from=$from";
+    var url = "http://localhost:8080/_matrix/client/api/v1/events?access_token=$token&from=$from";
     url = url.replace("$token", accountInfo.access_token);
     url = url.replace("$from", eventStreamInfo.from);
 
@@ -89,7 +89,7 @@ $('.login').live('click', function() {
     var user = $("#userLogin").val();
     var password = $("#passwordLogin").val();
     $.ajax({
-        url: "http://localhost:8080/matrix/client/api/v1/login",
+        url: "http://localhost:8080/_matrix/client/api/v1/login",
         type: "POST",
         contentType: "application/json; charset=utf-8",
         data: JSON.stringify({ user: user, password: password, type: "m.login.password" }),
@@ -107,7 +107,7 @@ $('.register').live('click', function() {
     var user = $("#userReg").val();
     var password = $("#passwordReg").val();
     $.ajax({
-        url: "http://localhost:8080/matrix/client/api/v1/register",
+        url: "http://localhost:8080/_matrix/client/api/v1/register",
         type: "POST",
         contentType: "application/json; charset=utf-8",
         data: JSON.stringify({ user_id: user, password: password }),
@@ -134,7 +134,7 @@ $('.createRoom').live('click', function() {
         data.room_alias_name = roomAlias;   
     }
     $.ajax({
-        url: "http://localhost:8080/matrix/client/api/v1/createRoom?access_token="+accountInfo.access_token,
+        url: "http://localhost:8080/_matrix/client/api/v1/createRoom?access_token="+accountInfo.access_token,
         type: "POST",
         contentType: "application/json; charset=utf-8",
         data: JSON.stringify(data),
@@ -155,7 +155,7 @@ $('.createRoom').live('click', function() {
 
 // ************** Getting current state **************
 var getCurrentRoomList = function() {
-    var url = "http://localhost:8080/matrix/client/api/v1/initialSync?access_token=" + accountInfo.access_token + "&limit=1";
+    var url = "http://localhost:8080/_matrix/client/api/v1/initialSync?access_token=" + accountInfo.access_token + "&limit=1";
     $.getJSON(url, function(data) {
         var rooms = data.rooms;
         for (var i=0; i<rooms.length; ++i) {
@@ -181,7 +181,7 @@ var loadRoomContent = function(roomId) {
 
 var getMessages = function(roomId) {
     $("#messages").empty();
-    var url = "http://localhost:8080/matrix/client/api/v1/rooms/" + 
+    var url = "http://localhost:8080/_matrix/client/api/v1/rooms/" + 
               encodeURIComponent(roomId) + "/messages?access_token=" + accountInfo.access_token + "&from=END&dir=b&limit=10";
     $.getJSON(url, function(data) {
         for (var i=data.chunk.length-1; i>=0; --i) {
@@ -193,7 +193,7 @@ var getMessages = function(roomId) {
 var getMemberList = function(roomId) {
     $("#members").empty();
     memberInfo = [];
-    var url = "http://localhost:8080/matrix/client/api/v1/rooms/" + 
+    var url = "http://localhost:8080/_matrix/client/api/v1/rooms/" + 
               encodeURIComponent(roomId) + "/members?access_token=" + accountInfo.access_token;
     $.getJSON(url, function(data) {
         for (var i=0; i<data.chunk.length; ++i) {
@@ -216,7 +216,7 @@ $('.sendMessage').live('click', function() {
 var sendMessage = function(roomId, body) {
     var msgId = $.now();
     
-    var url = "http://localhost:8080/matrix/client/api/v1/rooms/$roomid/send/m.room.message?access_token=$token";
+    var url = "http://localhost:8080/_matrix/client/api/v1/rooms/$roomid/send/m.room.message?access_token=$token";
     url = url.replace("$token", accountInfo.access_token);
     url = url.replace("$roomid", encodeURIComponent(roomId));
     
@@ -262,7 +262,7 @@ var setRooms = function(roomList) {
         var membership = $(this).find('td:eq(1)').text();
         if (membership !== "join") {
             console.log("Joining room " + roomId); 
-            var url = "http://localhost:8080/matrix/client/api/v1/rooms/$roomid/join?access_token=$token";
+            var url = "http://localhost:8080/_matrix/client/api/v1/rooms/$roomid/join?access_token=$token";
             url = url.replace("$token", accountInfo.access_token);
             url = url.replace("$roomid", encodeURIComponent(roomId));
             $.ajax({
diff --git a/jsfiddles/register_login/demo.js b/jsfiddles/register_login/demo.js
index 270a96786c..1e68cb91bd 100644
--- a/jsfiddles/register_login/demo.js
+++ b/jsfiddles/register_login/demo.js
@@ -11,7 +11,7 @@ $('.register').live('click', function() {
     var user = $("#user").val();
     var password = $("#password").val();
     $.ajax({
-        url: "http://localhost:8080/matrix/client/api/v1/register",
+        url: "http://localhost:8080/_matrix/client/api/v1/register",
         type: "POST",
         contentType: "application/json; charset=utf-8",
         data: JSON.stringify({ user_id: user, password: password }),
@@ -27,7 +27,7 @@ $('.register').live('click', function() {
 
 var login = function(user, password) {
     $.ajax({
-        url: "http://localhost:8080/matrix/client/api/v1/login",
+        url: "http://localhost:8080/_matrix/client/api/v1/login",
         type: "POST",
         contentType: "application/json; charset=utf-8",
         data: JSON.stringify({ user: user, password: password, type: "m.login.password" }),
@@ -44,7 +44,7 @@ var login = function(user, password) {
 $('.login').live('click', function() {
     var user = $("#userLogin").val();
     var password = $("#passwordLogin").val();
-    $.getJSON("http://localhost:8080/matrix/client/api/v1/login", function(data) {
+    $.getJSON("http://localhost:8080/_matrix/client/api/v1/login", function(data) {
         if (data.flows[0].type !== "m.login.password") {
             alert("I don't know how to login with this type: " + data.type);
             return;
@@ -60,7 +60,7 @@ $('.logout').live('click', function() {
 });
 
 $('.testToken').live('click', function() {
-    var url = "http://localhost:8080/matrix/client/api/v1/initialSync?access_token=" + accountInfo.access_token + "&limit=1";
+    var url = "http://localhost:8080/_matrix/client/api/v1/initialSync?access_token=" + accountInfo.access_token + "&limit=1";
     $.getJSON(url, function(data) {
          $("#imSyncText").text(JSON.stringify(data, undefined, 2));
     }).fail(function(err) {
diff --git a/jsfiddles/room_memberships/demo.js b/jsfiddles/room_memberships/demo.js
index 91cc96ab6f..7e499049ab 100644
--- a/jsfiddles/room_memberships/demo.js
+++ b/jsfiddles/room_memberships/demo.js
@@ -18,7 +18,7 @@ $('.login').live('click', function() {
     var user = $("#userLogin").val();
     var password = $("#passwordLogin").val();
     $.ajax({
-        url: "http://localhost:8080/matrix/client/api/v1/login",
+        url: "http://localhost:8080/_matrix/client/api/v1/login",
         type: "POST",
         contentType: "application/json; charset=utf-8",
         data: JSON.stringify({ user: user, password: password, type: "m.login.password" }),
@@ -39,7 +39,7 @@ var getCurrentRoomList = function() {
     // solution but that is out of scope of this fiddle.
     $("#rooms").find("tr:gt(0)").remove();
     
-    var url = "http://localhost:8080/matrix/client/api/v1/initialSync?access_token=" + accountInfo.access_token + "&limit=1";
+    var url = "http://localhost:8080/_matrix/client/api/v1/initialSync?access_token=" + accountInfo.access_token + "&limit=1";
     $.getJSON(url, function(data) {
         var rooms = data.rooms;
         for (var i=0; i<rooms.length; ++i) {
@@ -53,7 +53,7 @@ var getCurrentRoomList = function() {
 $('.createRoom').live('click', function() {
     var data = {};
     $.ajax({
-        url: "http://localhost:8080/matrix/client/api/v1/createRoom?access_token="+accountInfo.access_token,
+        url: "http://localhost:8080/_matrix/client/api/v1/createRoom?access_token="+accountInfo.access_token,
         type: "POST",
         contentType: "application/json; charset=utf-8",
         data: JSON.stringify(data),
@@ -87,7 +87,7 @@ $('.changeMembership').live('click', function() {
         return;
     }
     
-    var url = "http://localhost:8080/matrix/client/api/v1/rooms/$roomid/$membership?access_token=$token";
+    var url = "http://localhost:8080/_matrix/client/api/v1/rooms/$roomid/$membership?access_token=$token";
     url = url.replace("$token", accountInfo.access_token);
     url = url.replace("$roomid", encodeURIComponent(roomId));
     url = url.replace("$membership", membership);
@@ -117,7 +117,7 @@ $('.changeMembership').live('click', function() {
 
 $('.joinAlias').live('click', function() {
     var roomAlias = $("#roomAlias").val();
-    var url = "http://localhost:8080/matrix/client/api/v1/join/$roomalias?access_token=$token";
+    var url = "http://localhost:8080/_matrix/client/api/v1/join/$roomalias?access_token=$token";
     url = url.replace("$token", accountInfo.access_token);
     url = url.replace("$roomalias", encodeURIComponent(roomAlias));
     $.ajax({
diff --git a/synapse/api/urls.py b/synapse/api/urls.py
index 05ca000787..3d0b5de965 100644
--- a/synapse/api/urls.py
+++ b/synapse/api/urls.py
@@ -15,7 +15,7 @@
 
 """Contains the URL paths to prefix various aspects of the server with. """
 
-CLIENT_PREFIX = "/matrix/client/api/v1"
-FEDERATION_PREFIX = "/matrix/federation/v1"
-WEB_CLIENT_PREFIX = "/matrix/client"
-CONTENT_REPO_PREFIX = "/matrix/content"
\ No newline at end of file
+CLIENT_PREFIX = "/_matrix/client/api/v1"
+FEDERATION_PREFIX = "/_matrix/federation/v1"
+WEB_CLIENT_PREFIX = "/_matrix/client"
+CONTENT_REPO_PREFIX = "/_matrix/content"
\ No newline at end of file
diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py
index 3d7f97bcff..c8ff34e5f5 100644
--- a/synapse/handlers/message.py
+++ b/synapse/handlers/message.py
@@ -274,11 +274,11 @@ class MessageHandler(BaseRoomHandler):
                 messages, token = yield self.store.get_recent_events_for_room(
                     event.room_id,
                     limit=limit,
-                    end_token=now_token.events_key,
+                    end_token=now_token.room_key,
                 )
 
-                start_token = now_token.copy_and_replace("events_key", token[0])
-                end_token = now_token.copy_and_replace("events_key", token[1])
+                start_token = now_token.copy_and_replace("room_key", token[0])
+                end_token = now_token.copy_and_replace("room_key", token[1])
 
                 d["messages"] = {
                     "chunk": [m.get_dict() for m in messages],
diff --git a/synapse/handlers/presence.py b/synapse/handlers/presence.py
index 7731de85c0..93bd07b196 100644
--- a/synapse/handlers/presence.py
+++ b/synapse/handlers/presence.py
@@ -260,19 +260,18 @@ class PresenceHandler(BaseHandler):
 
     @defer.inlineCallbacks
     def user_joined_room(self, user, room_id):
-
         if user.is_mine:
-            self.push_update_to_local_and_remote(
-                observed_user=user,
-                room_ids=[room_id],
-                statuscache=self._get_or_offline_usercache(user),
-            )
+            statuscache = self._get_or_make_usercache(user)
 
-        else:
-            self.push_update_to_clients(
+            # No actual update but we need to bump the serial anyway for the
+            # event source
+            self._user_cachemap_latest_serial += 1
+            statuscache.update({}, serial=self._user_cachemap_latest_serial)
+
+            self.push_update_to_local_and_remote(
                 observed_user=user,
                 room_ids=[room_id],
-                statuscache=self._get_or_offline_usercache(user),
+                statuscache=statuscache,
             )
 
         # We also want to tell them about current presence of people.
@@ -722,6 +721,78 @@ class PresenceHandler(BaseHandler):
         )
 
 
+class PresenceEventSource(object):
+    def __init__(self, hs):
+        self.hs = hs
+        self.clock = hs.get_clock()
+
+    def get_new_events_for_user(self, user, from_key, limit):
+        from_key = int(from_key)
+
+        presence = self.hs.get_handlers().presence_handler
+        cachemap = presence._user_cachemap
+
+        # TODO(paul): limit, and filter by visibility
+        updates = [(k, cachemap[k]) for k in cachemap
+                   if from_key < cachemap[k].serial]
+
+        if updates:
+            clock = self.clock
+
+            latest_serial = max([x[1].serial for x in updates])
+            data = [x[1].make_event(user=x[0], clock=clock) for x in updates]
+
+            return ((data, latest_serial))
+        else:
+            return (([], presence._user_cachemap_latest_serial))
+
+    def get_current_key(self):
+        presence = self.hs.get_handlers().presence_handler
+        return presence._user_cachemap_latest_serial
+
+    def get_pagination_rows(self, user, pagination_config, key):
+        # TODO (erikj): Does this make sense? Ordering?
+
+        from_token = pagination_config.from_token
+        to_token = pagination_config.to_token
+
+        from_key = int(from_token.presence_key)
+
+        if to_token:
+            to_key = int(to_token.presence_key)
+        else:
+            to_key = -1
+
+        presence = self.hs.get_handlers().presence_handler
+        cachemap = presence._user_cachemap
+
+        # TODO(paul): limit, and filter by visibility
+        updates = [(k, cachemap[k]) for k in cachemap
+                   if to_key < cachemap[k].serial < from_key]
+
+        if updates:
+            clock = self.clock
+
+            earliest_serial = max([x[1].serial for x in updates])
+            data = [x[1].make_event(user=x[0], clock=clock) for x in updates]
+
+            if to_token:
+                next_token = to_token
+            else:
+                next_token = from_token
+
+            next_token = next_token.copy_and_replace(
+                "presence_key", earliest_serial
+            )
+            return ((data, next_token))
+        else:
+            if not to_token:
+                to_token = from_token.copy_and_replace(
+                    "presence_key", 0
+                )
+            return (([], to_token))
+
+
 class UserPresenceCache(object):
     """Store an observed user's state and status message.
 
diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py
index 3e41d7a46b..c54e0f963b 100644
--- a/synapse/handlers/room.py
+++ b/synapse/handlers/room.py
@@ -462,3 +462,49 @@ class RoomListHandler(BaseRoomHandler):
         chunk = yield self.store.get_rooms(is_public=True)
         # FIXME (erikj): START is no longer a valid value
         defer.returnValue({"start": "START", "end": "END", "chunk": chunk})
+
+
+class RoomEventSource(object):
+    def __init__(self, hs):
+        self.store = hs.get_datastore()
+
+    @defer.inlineCallbacks
+    def get_new_events_for_user(self, user, from_key, limit):
+        # We just ignore the key for now.
+
+        to_key = yield self.get_current_key()
+
+        events, end_key = yield self.store.get_room_events_stream(
+            user_id=user.to_string(),
+            from_key=from_key,
+            to_key=to_key,
+            room_id=None,
+            limit=limit,
+        )
+
+        defer.returnValue((events, end_key))
+
+    def get_current_key(self):
+        return self.store.get_room_events_max_id()
+
+    @defer.inlineCallbacks
+    def get_pagination_rows(self, user, pagination_config, key):
+        from_token = pagination_config.from_token
+        to_token = pagination_config.to_token
+        limit = pagination_config.limit
+        direction = pagination_config.direction
+
+        to_key = to_token.room_key if to_token else None
+
+        events, next_key = yield self.store.paginate_room_events(
+            room_id=key,
+            from_key=from_token.room_key,
+            to_key=to_key,
+            direction=direction,
+            limit=limit,
+            with_feedback=True
+        )
+
+        next_token = from_token.copy_and_replace("room_key", next_key)
+
+        defer.returnValue((events, next_token))
diff --git a/synapse/handlers/typing.py b/synapse/handlers/typing.py
index 9fab0ff37c..3268427ecd 100644
--- a/synapse/handlers/typing.py
+++ b/synapse/handlers/typing.py
@@ -145,3 +145,17 @@ class TypingNotificationHandler(BaseHandler):
             typing):
         # TODO(paul) steal this from presence.py
         pass
+
+
+class TypingNotificationEventSource(object):
+    def __init__(self, hs):
+        self.hs = hs
+
+    def get_new_events_for_user(self, user, from_key, limit):
+        return ([], from_key)
+
+    def get_current_key(self):
+        return 0
+
+    def get_pagination_rows(self, user, pagination_config, key):
+        return ([], pagination_config.from_token)
diff --git a/synapse/http/server.py b/synapse/http/server.py
index 66f966fcaa..243b71744d 100644
--- a/synapse/http/server.py
+++ b/synapse/http/server.py
@@ -325,7 +325,7 @@ class ContentRepoResource(resource.Resource):
 
             # FIXME (erikj): These should use constants.
             file_name = os.path.basename(fname)
-            url = "http://%s/matrix/content/%s" % (
+            url = "http://%s/_matrix/content/%s" % (
                 self.hs.domain_with_port, file_name
             )
 
diff --git a/synapse/notifier.py b/synapse/notifier.py
index b6d5ec4820..cb544e9886 100644
--- a/synapse/notifier.py
+++ b/synapse/notifier.py
@@ -95,7 +95,7 @@ class Notifier(object):
         """
         room_id = event.room_id
 
-        source = self.event_sources.sources["room"]
+        room_source = self.event_sources.sources["room"]
 
         listeners = self.rooms_to_listeners.get(room_id, set()).copy()
 
@@ -107,13 +107,17 @@ class Notifier(object):
         # TODO (erikj): Can we make this more efficient by hitting the
         # db once?
         for listener in listeners:
-            events, end_token = yield source.get_new_events_for_user(
+            events, end_key = yield room_source.get_new_events_for_user(
                 listener.user,
-                listener.from_token,
+                listener.from_token.room_key,
                 listener.limit,
             )
 
             if events:
+                end_token = listener.from_token.copy_and_replace(
+                    "room_key", end_key
+                )
+
                 listener.notify(
                     self, events, listener.from_token, end_token
                 )
@@ -126,7 +130,7 @@ class Notifier(object):
 
         Will wake up all listeners for the given users and rooms.
         """
-        source = self.event_sources.sources["presence"]
+        presence_source = self.event_sources.sources["presence"]
 
         listeners = set()
 
@@ -137,13 +141,17 @@ class Notifier(object):
             listeners |= self.rooms_to_listeners.get(room, set()).copy()
 
         for listener in listeners:
-            events, end_token = yield source.get_new_events_for_user(
+            events, end_key = yield presence_source.get_new_events_for_user(
                 listener.user,
-                listener.from_token,
+                listener.from_token.presence_key,
                 listener.limit,
             )
 
             if events:
+                end_token = listener.from_token.copy_and_replace(
+                    "presence_key", end_key
+                )
+
                 listener.notify(
                     self, events, listener.from_token, end_token
                 )
@@ -216,16 +224,18 @@ class Notifier(object):
         limit = listener.limit
 
         # TODO (erikj): DeferredList?
-        for source in self.event_sources.sources.values():
-            stuff, new_token = yield source.get_new_events_for_user(
+        for name, source in self.event_sources.sources.items():
+            keyname = "%s_key" % name
+
+            stuff, new_key = yield source.get_new_events_for_user(
                 listener.user,
-                from_token,
+                getattr(from_token, keyname),
                 limit,
             )
 
             events.extend(stuff)
 
-            from_token = new_token
+            from_token = from_token.copy_and_replace(keyname, new_key)
 
         end_token = from_token
 
diff --git a/synapse/streams/events.py b/synapse/streams/events.py
index c68cf1a59c..08d6e6f733 100644
--- a/synapse/streams/events.py
+++ b/synapse/streams/events.py
@@ -17,6 +17,10 @@ from twisted.internet import defer
 
 from synapse.types import StreamToken
 
+from synapse.handlers.presence import PresenceEventSource
+from synapse.handlers.room import RoomEventSource
+from synapse.handlers.typing import TypingNotificationEventSource
+
 
 class NullSource(object):
     """This event source never yields any events and its token remains at
@@ -24,146 +28,21 @@ class NullSource(object):
     def __init__(self, hs):
         pass
 
-    def get_new_events_for_user(self, user, from_token, limit):
-        return defer.succeed(([], from_token))
+    def get_new_events_for_user(self, user, from_key, limit):
+        return defer.succeed(([], from_key))
 
-    def get_current_token_part(self):
+    def get_current_key(self):
         return defer.succeed(0)
 
     def get_pagination_rows(self, user, pagination_config, key):
         return defer.succeed(([], pagination_config.from_token))
 
 
-class RoomEventSource(object):
-    def __init__(self, hs):
-        self.store = hs.get_datastore()
-
-    @defer.inlineCallbacks
-    def get_new_events_for_user(self, user, from_token, limit):
-        # We just ignore the key for now.
-
-        to_key = yield self.get_current_token_part()
-
-        events, end_key = yield self.store.get_room_events_stream(
-            user_id=user.to_string(),
-            from_key=from_token.events_key,
-            to_key=to_key,
-            room_id=None,
-            limit=limit,
-        )
-
-        end_token = from_token.copy_and_replace("events_key", end_key)
-
-        defer.returnValue((events, end_token))
-
-    def get_current_token_part(self):
-        return self.store.get_room_events_max_id()
-
-    @defer.inlineCallbacks
-    def get_pagination_rows(self, user, pagination_config, key):
-        from_token = pagination_config.from_token
-        to_token = pagination_config.to_token
-        limit = pagination_config.limit
-        direction = pagination_config.direction
-
-        to_key = to_token.events_key if to_token else None
-
-        events, next_key = yield self.store.paginate_room_events(
-            room_id=key,
-            from_key=from_token.events_key,
-            to_key=to_key,
-            direction=direction,
-            limit=limit,
-            with_feedback=True
-        )
-
-        next_token = from_token.copy_and_replace("events_key", next_key)
-
-        defer.returnValue((events, next_token))
-
-
-class PresenceSource(object):
-    def __init__(self, hs):
-        self.hs = hs
-        self.clock = hs.get_clock()
-
-    def get_new_events_for_user(self, user, from_token, limit):
-        from_key = int(from_token.presence_key)
-
-        presence = self.hs.get_handlers().presence_handler
-        cachemap = presence._user_cachemap
-
-        # TODO(paul): limit, and filter by visibility
-        updates = [(k, cachemap[k]) for k in cachemap
-                   if from_key < cachemap[k].serial]
-
-        if updates:
-            clock = self.clock
-
-            latest_serial = max([x[1].serial for x in updates])
-            data = [x[1].make_event(user=x[0], clock=clock) for x in updates]
-
-            end_token = from_token.copy_and_replace(
-                "presence_key", latest_serial
-            )
-            return ((data, end_token))
-        else:
-            end_token = from_token.copy_and_replace(
-                "presence_key", presence._user_cachemap_latest_serial
-            )
-            return (([], end_token))
-
-    def get_current_token_part(self):
-        presence = self.hs.get_handlers().presence_handler
-        return presence._user_cachemap_latest_serial
-
-    def get_pagination_rows(self, user, pagination_config, key):
-        # TODO (erikj): Does this make sense? Ordering?
-
-        from_token = pagination_config.from_token
-        to_token = pagination_config.to_token
-
-        from_key = int(from_token.presence_key)
-
-        if to_token:
-            to_key = int(to_token.presence_key)
-        else:
-            to_key = -1
-
-        presence = self.hs.get_handlers().presence_handler
-        cachemap = presence._user_cachemap
-
-        # TODO(paul): limit, and filter by visibility
-        updates = [(k, cachemap[k]) for k in cachemap
-                   if to_key < cachemap[k].serial < from_key]
-
-        if updates:
-            clock = self.clock
-
-            earliest_serial = max([x[1].serial for x in updates])
-            data = [x[1].make_event(user=x[0], clock=clock) for x in updates]
-
-            if to_token:
-                next_token = to_token
-            else:
-                next_token = from_token
-
-            next_token = next_token.copy_and_replace(
-                "presence_key", earliest_serial
-            )
-            return ((data, next_token))
-        else:
-            if not to_token:
-                to_token = from_token.copy_and_replace(
-                    "presence_key", 0
-                )
-            return (([], to_token))
-
-
 class EventSources(object):
     SOURCE_TYPES = {
         "room": RoomEventSource,
-        "presence": PresenceSource,
+        "presence": PresenceEventSource,
+        "typing": TypingNotificationEventSource,
     }
 
     def __init__(self, hs):
@@ -172,24 +51,29 @@ class EventSources(object):
             for name, cls in EventSources.SOURCE_TYPES.items()
         }
 
-    @staticmethod
-    def create_token(events_key, presence_key):
-        return StreamToken(events_key=events_key, presence_key=presence_key)
-
     @defer.inlineCallbacks
     def get_current_token(self):
-        events_key = yield self.sources["room"].get_current_token_part()
-        presence_key = yield self.sources["presence"].get_current_token_part()
-        token = EventSources.create_token(events_key, presence_key)
+        token = StreamToken(
+            room_key=(
+                yield self.sources["room"].get_current_key()
+            ),
+            presence_key=(
+                yield self.sources["presence"].get_current_key()
+            ),
+            typing_key=(
+                yield self.sources["typing"].get_current_key()
+            )
+        )
         defer.returnValue(token)
 
 
 class StreamSource(object):
-    def get_new_events_for_user(self, user, from_token, limit):
+    def get_new_events_for_user(self, user, from_key, limit):
+        """from_key is the key within this event source."""
         raise NotImplementedError("get_new_events_for_user")
 
-    def get_current_token_part(self):
-        raise NotImplementedError("get_current_token_part")
+    def get_current_key(self):
+        raise NotImplementedError("get_current_key")
 
     def get_pagination_rows(self, user, pagination_config, key):
         raise NotImplementedError("get_rows")
diff --git a/synapse/types.py b/synapse/types.py
index 63154855dd..1a9dceabf5 100644
--- a/synapse/types.py
+++ b/synapse/types.py
@@ -97,7 +97,7 @@ class RoomID(DomainSpecificString):
 class StreamToken(
     namedtuple(
         "Token",
-        ("events_key", "presence_key")
+        ("room_key", "presence_key", "typing_key")
     )
 ):
     _SEPARATOR = "_"
@@ -105,21 +105,14 @@ class StreamToken(
     @classmethod
     def from_string(cls, string):
         try:
-            events_key, presence_key = string.split(cls._SEPARATOR)
+            keys = string.split(cls._SEPARATOR)
 
-            return cls(
-                events_key=events_key,
-                presence_key=presence_key,
-            )
+            return cls(*keys)
         except:
             raise SynapseError(400, "Invalid Token")
 
     def to_string(self):
-        return "".join([
-            str(self.events_key),
-            self._SEPARATOR,
-            str(self.presence_key),
-        ])
+        return self._SEPARATOR.join([str(k) for k in self])
 
     def copy_and_replace(self, key, new_value):
         d = self._asdict()
diff --git a/tests/federation/test_federation.py b/tests/federation/test_federation.py
index 938b57bec9..51308ca358 100644
--- a/tests/federation/test_federation.py
+++ b/tests/federation/test_federation.py
@@ -87,7 +87,7 @@ class FederationTestCase(unittest.TestCase):
 
         # Empty context initially
         (code, response) = yield self.mock_resource.trigger("GET",
-                "/matrix/federation/v1/state/my-context/", None)
+                "/_matrix/federation/v1/state/my-context/", None)
         self.assertEquals(200, code)
         self.assertFalse(response["pdus"])
 
@@ -112,7 +112,7 @@ class FederationTestCase(unittest.TestCase):
         )
 
         (code, response) = yield self.mock_resource.trigger("GET",
-                "/matrix/federation/v1/state/my-context/", None)
+                "/_matrix/federation/v1/state/my-context/", None)
         self.assertEquals(200, code)
         self.assertEquals(1, len(response["pdus"]))
 
@@ -123,7 +123,7 @@ class FederationTestCase(unittest.TestCase):
         )
 
         (code, response) = yield self.mock_resource.trigger("GET",
-                "/matrix/federation/v1/pdu/red/abc123def456/", None)
+                "/_matrix/federation/v1/pdu/red/abc123def456/", None)
         self.assertEquals(404, code)
 
         # Now insert such a PDU
@@ -142,7 +142,7 @@ class FederationTestCase(unittest.TestCase):
         )
 
         (code, response) = yield self.mock_resource.trigger("GET",
-                "/matrix/federation/v1/pdu/red/abc123def456/", None)
+                "/_matrix/federation/v1/pdu/red/abc123def456/", None)
         self.assertEquals(200, code)
         self.assertEquals(1, len(response["pdus"]))
         self.assertEquals("m.text", response["pdus"][0]["pdu_type"])
@@ -168,7 +168,7 @@ class FederationTestCase(unittest.TestCase):
 
         self.mock_http_client.put_json.assert_called_with(
                 "remote",
-                path="/matrix/federation/v1/send/1000000/",
+                path="/_matrix/federation/v1/send/1000000/",
                 data={
                     "ts": 1000000,
                     "origin": "test",
@@ -203,7 +203,7 @@ class FederationTestCase(unittest.TestCase):
         # MockClock ensures we can guess these timestamps
         self.mock_http_client.put_json.assert_called_with(
                 "remote",
-                path="/matrix/federation/v1/send/1000000/",
+                path="/_matrix/federation/v1/send/1000000/",
                 data={
                     "origin": "test",
                     "ts": 1000000,
@@ -226,7 +226,7 @@ class FederationTestCase(unittest.TestCase):
         self.federation.register_edu_handler("m.test", recv_observer)
 
         yield self.mock_resource.trigger("PUT",
-                "/matrix/federation/v1/send/1001000/",
+                "/_matrix/federation/v1/send/1001000/",
                 """{
                     "origin": "remote",
                     "ts": 1001000,
@@ -261,7 +261,7 @@ class FederationTestCase(unittest.TestCase):
 
         self.mock_http_client.get_json.assert_called_with(
             destination="remote",
-            path="/matrix/federation/v1/query/a-question",
+            path="/_matrix/federation/v1/query/a-question",
             args={"one": "1", "two": "2"}
         )
 
@@ -273,7 +273,7 @@ class FederationTestCase(unittest.TestCase):
         self.federation.register_query_handler("a-question", recv_handler)
 
         code, response = yield self.mock_resource.trigger("GET",
-            "/matrix/federation/v1/query/a-question?three=3&four=4", None)
+            "/_matrix/federation/v1/query/a-question?three=3&four=4", None)
 
         self.assertEquals(200, code)
         self.assertEquals({"another": "response"}, response)
diff --git a/tests/handlers/test_presence.py b/tests/handlers/test_presence.py
index fcd7a784cd..451e1eaa22 100644
--- a/tests/handlers/test_presence.py
+++ b/tests/handlers/test_presence.py
@@ -314,7 +314,7 @@ class PresenceInvitesTestCase(unittest.TestCase):
         put_json = self.mock_http_client.put_json
         put_json.expect_call_and_return(
             call("elsewhere",
-                path="/matrix/federation/v1/send/1000000/",
+                path="/_matrix/federation/v1/send/1000000/",
                 data=_expect_edu("elsewhere", "m.presence_invite",
                     content={
                         "observer_user": "@apple:test",
@@ -340,7 +340,7 @@ class PresenceInvitesTestCase(unittest.TestCase):
         put_json = self.mock_http_client.put_json
         put_json.expect_call_and_return(
             call("elsewhere",
-                path="/matrix/federation/v1/send/1000000/",
+                path="/_matrix/federation/v1/send/1000000/",
                 data=_expect_edu("elsewhere", "m.presence_accept",
                     content={
                         "observer_user": "@cabbage:elsewhere",
@@ -352,7 +352,7 @@ class PresenceInvitesTestCase(unittest.TestCase):
         )
 
         yield self.mock_federation_resource.trigger("PUT",
-            "/matrix/federation/v1/send/1000000/",
+            "/_matrix/federation/v1/send/1000000/",
             _make_edu_json("elsewhere", "m.presence_invite",
                 content={
                     "observer_user": "@cabbage:elsewhere",
@@ -371,7 +371,7 @@ class PresenceInvitesTestCase(unittest.TestCase):
         put_json = self.mock_http_client.put_json
         put_json.expect_call_and_return(
             call("elsewhere",
-                path="/matrix/federation/v1/send/1000000/",
+                path="/_matrix/federation/v1/send/1000000/",
                 data=_expect_edu("elsewhere", "m.presence_deny",
                     content={
                         "observer_user": "@cabbage:elsewhere",
@@ -383,7 +383,7 @@ class PresenceInvitesTestCase(unittest.TestCase):
         )
 
         yield self.mock_federation_resource.trigger("PUT",
-            "/matrix/federation/v1/send/1000000/",
+            "/_matrix/federation/v1/send/1000000/",
             _make_edu_json("elsewhere", "m.presence_invite",
                 content={
                     "observer_user": "@cabbage:elsewhere",
@@ -397,7 +397,7 @@ class PresenceInvitesTestCase(unittest.TestCase):
     @defer.inlineCallbacks
     def test_accepted_remote(self):
         yield self.mock_federation_resource.trigger("PUT",
-            "/matrix/federation/v1/send/1000000/",
+            "/_matrix/federation/v1/send/1000000/",
             _make_edu_json("elsewhere", "m.presence_accept",
                 content={
                     "observer_user": "@apple:test",
@@ -415,7 +415,7 @@ class PresenceInvitesTestCase(unittest.TestCase):
     @defer.inlineCallbacks
     def test_denied_remote(self):
         yield self.mock_federation_resource.trigger("PUT",
-            "/matrix/federation/v1/send/1000000/",
+            "/_matrix/federation/v1/send/1000000/",
             _make_edu_json("elsewhere", "m.presence_deny",
                 content={
                     "observer_user": "@apple:test",
@@ -514,13 +514,6 @@ class PresencePushTestCase(unittest.TestCase):
             )
         hs.handlers = JustPresenceHandlers(hs)
 
-        def update(*args,**kwargs):
-            # print "mock_update_client: Args=%s, kwargs=%s" %(args, kwargs,)
-            return defer.succeed(None)
-
-        self.mock_update_client = Mock()
-        self.mock_update_client.side_effect = update
-
         self.datastore = hs.get_datastore()
 
         def get_received_txn_response(*args):
@@ -528,7 +521,7 @@ class PresencePushTestCase(unittest.TestCase):
         self.datastore.get_received_txn_response = get_received_txn_response
 
         self.handler = hs.get_handlers().presence_handler
-        self.handler.push_update_to_clients = self.mock_update_client
+        self.event_source = hs.get_event_sources().sources["presence"]
 
         # Mock the RoomMemberHandler
         hs.handlers.room_member_handler = Mock(spec=[
@@ -622,16 +615,23 @@ class PresencePushTestCase(unittest.TestCase):
         apple_set.add(self.u_banana)
         apple_set.add(self.u_clementine)
 
+        self.assertEquals(self.event_source.get_current_key(), 0)
+
         yield self.handler.set_state(self.u_apple, self.u_apple,
                 {"state": ONLINE})
 
-        self.mock_update_client.assert_has_calls([
-                call(users_to_push=set([self.u_apple, self.u_banana, self.u_clementine]),
-                    room_ids=["a-room"],
-                    observed_user=self.u_apple,
-                    statuscache=ANY), # self-reflection
-        ], any_order=True)
-        self.mock_update_client.reset_mock()
+        self.assertEquals(self.event_source.get_current_key(), 1)
+        self.assertEquals(
+            self.event_source.get_new_events_for_user(self.u_apple, 0, None)[0],
+            [
+                {"type": "m.presence",
+                 "content": {
+                    "user_id": "@apple:test",
+                    "state": ONLINE,
+                    "mtime_age": 0,
+                }},
+            ],
+        )
 
         presence = yield self.handler.get_presence_list(
                 observer_user=self.u_apple, accepted=True)
@@ -657,31 +657,24 @@ class PresencePushTestCase(unittest.TestCase):
                  "state": OFFLINE},
         ], presence)
 
-        self.mock_update_client.assert_has_calls([
-                call(users_to_push=set([self.u_banana]),
-                    room_ids=[],
-                    observed_user=self.u_banana,
-                    statuscache=ANY), # self-reflection
-        ]) # and no others...
+        self.assertEquals(self.event_source.get_current_key(), 2)
+        self.assertEquals(
+            self.event_source.get_new_events_for_user(
+                self.u_banana, 1, None
+            )[0],
+            [
+                {"type": "m.presence",
+                 "content": {
+                     "user_id": "@banana:test",
+                     "state": ONLINE,
+                     "mtime_age": 2000
+                }},
+            ]
+        )
 
     @defer.inlineCallbacks
     def test_push_remote(self):
         put_json = self.mock_http_client.put_json
-#        put_json.expect_call_and_return(
-#            call("remote",
-#                path=ANY,  # Can't guarantee which txn ID will be which
-#                data=_expect_edu("remote", "m.presence",
-#                    content={
-#                        "push": [
-#                            {"user_id": "@apple:test",
-#                             "state": "online",
-#                             "mtime_age": 0},
-#                        ],
-#                    }
-#                )
-#            ),
-#            defer.succeed((200, "OK"))
-#        )
         put_json.expect_call_and_return(
             call("farm",
                 path=ANY,  # Can't guarantee which txn ID will be which
@@ -724,8 +717,10 @@ class PresencePushTestCase(unittest.TestCase):
 
         self.room_members = [self.u_banana, self.u_potato]
 
+        self.assertEquals(self.event_source.get_current_key(), 0)
+
         yield self.mock_federation_resource.trigger("PUT",
-            "/matrix/federation/v1/send/1000000/",
+            "/_matrix/federation/v1/send/1000000/",
             _make_edu_json("elsewhere", "m.presence",
                 content={
                     "push": [
@@ -737,12 +732,20 @@ class PresencePushTestCase(unittest.TestCase):
             )
         )
 
-        self.mock_update_client.assert_has_calls([
-                call(users_to_push=set([self.u_apple]),
-                    room_ids=["a-room"],
-                    observed_user=self.u_potato,
-                    statuscache=ANY),
-        ], any_order=True)
+        self.assertEquals(self.event_source.get_current_key(), 1)
+        self.assertEquals(
+            self.event_source.get_new_events_for_user(
+                self.u_apple, 0, None
+            )[0],
+            [
+                {"type": "m.presence",
+                 "content": {
+                     "user_id": "@potato:remote",
+                     "state": ONLINE,
+                     "mtime_age": 1000,
+                }}
+            ]
+        )
 
         self.clock.advance_time(2)
 
@@ -754,24 +757,35 @@ class PresencePushTestCase(unittest.TestCase):
     def test_join_room_local(self):
         self.room_members = [self.u_apple, self.u_banana]
 
-        yield self.distributor.fire("user_joined_room", self.u_elderberry,
+        self.assertEquals(self.event_source.get_current_key(), 0)
+
+        # TODO(paul): Gut-wrenching
+        self.handler._user_cachemap[self.u_clementine] = UserPresenceCache()
+        self.handler._user_cachemap[self.u_clementine].update(
+            {
+                "state": PresenceState.ONLINE,
+                "mtime": self.clock.time_msec(),
+            }, self.u_clementine
+        )
+
+        yield self.distributor.fire("user_joined_room", self.u_clementine,
             "a-room"
         )
 
-        self.mock_update_client.assert_has_calls([
-            call(room_ids=["a-room"],
-                observed_user=self.u_elderberry,
-                users_to_push=set(),
-                statuscache=ANY),
-            call(users_to_push=set([self.u_elderberry]),
-                observed_user=self.u_apple,
-                room_ids=[],
-                statuscache=ANY),
-            call(users_to_push=set([self.u_elderberry]),
-                observed_user=self.u_banana,
-                room_ids=[],
-                statuscache=ANY),
-        ], any_order=True)
+        self.assertEquals(self.event_source.get_current_key(), 1)
+        self.assertEquals(
+            self.event_source.get_new_events_for_user(
+                self.u_apple, 0, None
+            )[0],
+            [
+                {"type": "m.presence",
+                 "content": {
+                     "user_id": "@clementine:test",
+                     "state": ONLINE,
+                     "mtime_age": 0,
+                }}
+            ]
+        )
 
     @defer.inlineCallbacks
     def test_join_room_remote(self):
@@ -822,7 +836,7 @@ class PresencePushTestCase(unittest.TestCase):
 
         put_json.expect_call_and_return(
             call("remote",
-                path="/matrix/federation/v1/send/1000002/",
+                path="/_matrix/federation/v1/send/1000002/",
                 data=_expect_edu("remote", "m.presence",
                     content={
                         "push": [
@@ -1116,7 +1130,7 @@ class PresencePollingTestCase(unittest.TestCase):
         put_json = self.mock_http_client.put_json
         put_json.expect_call_and_return(
             call("remote",
-                path="/matrix/federation/v1/send/1000000/",
+                path="/_matrix/federation/v1/send/1000000/",
                 data=_expect_edu("remote", "m.presence",
                     content={
                         "push": [
@@ -1131,7 +1145,7 @@ class PresencePollingTestCase(unittest.TestCase):
         )
 
         yield self.mock_federation_resource.trigger("PUT",
-            "/matrix/federation/v1/send/1000000/",
+            "/_matrix/federation/v1/send/1000000/",
             _make_edu_json("remote", "m.presence",
                 content={
                     "poll": [ "@banana:test" ],
@@ -1145,7 +1159,7 @@ class PresencePollingTestCase(unittest.TestCase):
         self.assertTrue(self.u_banana in self.handler._remote_sendmap)
 
         yield self.mock_federation_resource.trigger("PUT",
-            "/matrix/federation/v1/send/1000001/",
+            "/_matrix/federation/v1/send/1000001/",
             _make_edu_json("remote", "m.presence",
                 content={
                     "unpoll": [ "@banana:test" ],
diff --git a/tests/handlers/test_typing.py b/tests/handlers/test_typing.py
index 300a6e340a..c3c98074cc 100644
--- a/tests/handlers/test_typing.py
+++ b/tests/handlers/test_typing.py
@@ -166,7 +166,7 @@ class TypingNotificationsTestCase(unittest.TestCase):
         put_json = self.mock_http_client.put_json
         put_json.expect_call_and_return(
             call("farm",
-                path="/matrix/federation/v1/send/1000000/",
+                path="/_matrix/federation/v1/send/1000000/",
                 data=_expect_edu("farm", "m.typing",
                     content={
                         "room_id": self.room_id,
@@ -192,7 +192,7 @@ class TypingNotificationsTestCase(unittest.TestCase):
         self.room_members = [self.u_apple, self.u_onion]
 
         yield self.mock_federation_resource.trigger("PUT",
-            "/matrix/federation/v1/send/1000000/",
+            "/_matrix/federation/v1/send/1000000/",
             _make_edu_json("farm", "m.typing",
                 content={
                     "room_id": self.room_id,
@@ -216,7 +216,7 @@ class TypingNotificationsTestCase(unittest.TestCase):
         put_json = self.mock_http_client.put_json
         put_json.expect_call_and_return(
             call("farm",
-                path="/matrix/federation/v1/send/1000000/",
+                path="/_matrix/federation/v1/send/1000000/",
                 data=_expect_edu("farm", "m.typing",
                     content={
                         "room_id": self.room_id,
diff --git a/tests/rest/test_events.py b/tests/rest/test_events.py
index 3099a24e8c..1d1336d12d 100644
--- a/tests/rest/test_events.py
+++ b/tests/rest/test_events.py
@@ -36,7 +36,7 @@ from mock import Mock
 
 logging.getLogger().addHandler(logging.NullHandler())
 
-PATH_PREFIX = "/matrix/client/api/v1"
+PATH_PREFIX = "/_matrix/client/api/v1"
 
 
 class EventStreamPaginationApiTestCase(unittest.TestCase):
diff --git a/tests/rest/test_presence.py b/tests/rest/test_presence.py
index 7f7347dcf9..61692f02c2 100644
--- a/tests/rest/test_presence.py
+++ b/tests/rest/test_presence.py
@@ -37,7 +37,7 @@ ONLINE = PresenceState.ONLINE
 
 
 myid = "@apple:test"
-PATH_PREFIX = "/matrix/client/api/v1"
+PATH_PREFIX = "/_matrix/client/api/v1"
 
 
 class JustPresenceHandlers(object):
@@ -229,7 +229,7 @@ class PresenceEventStreamTestCase(unittest.TestCase):
         # HIDEOUS HACKERY
         # TODO(paul): This should be injected in via the HomeServer DI system
         from synapse.streams.events import (
-            PresenceSource, NullSource, EventSources
+            PresenceEventSource, NullSource, EventSources
         )
 
         old_SOURCE_TYPES = EventSources.SOURCE_TYPES
@@ -240,7 +240,7 @@ class PresenceEventStreamTestCase(unittest.TestCase):
         EventSources.SOURCE_TYPES = {
             k: NullSource for k in old_SOURCE_TYPES.keys()
         }
-        EventSources.SOURCE_TYPES["presence"] = PresenceSource
+        EventSources.SOURCE_TYPES["presence"] = PresenceEventSource
 
         hs = HomeServer("test",
             db_pool=None,
@@ -274,6 +274,15 @@ class PresenceEventStreamTestCase(unittest.TestCase):
                 lambda u: defer.succeed([]))
 
         self.mock_datastore = hs.get_datastore()
+
+        def get_profile_displayname(user_id):
+            return defer.succeed("Frank")
+        self.mock_datastore.get_profile_displayname = get_profile_displayname
+
+        def get_profile_avatar_url(user_id):
+            return defer.succeed(None)
+        self.mock_datastore.get_profile_avatar_url = get_profile_avatar_url
+
         self.presence = hs.get_handlers().presence_handler
 
         self.u_apple = hs.parse_userid("@apple:test")
@@ -295,7 +304,9 @@ class PresenceEventStreamTestCase(unittest.TestCase):
         # all be ours
 
         # I'll already get my own presence state change
-        self.assertEquals({"start": "0_1", "end": "0_1", "chunk": []}, response)
+        self.assertEquals({"start": "0_1_0", "end": "0_1_0", "chunk": []},
+            response
+        )
 
         self.mock_datastore.set_presence_state.return_value = defer.succeed(
                 {"state": ONLINE})
@@ -306,14 +317,15 @@ class PresenceEventStreamTestCase(unittest.TestCase):
                 state={"state": ONLINE})
 
         (code, response) = yield self.mock_resource.trigger("GET",
-                "/events?from=0_1&timeout=0", None)
+                "/events?from=0_1_0&timeout=0", None)
 
         self.assertEquals(200, code)
-        self.assertEquals({"start": "0_1", "end": "0_2", "chunk": [
+        self.assertEquals({"start": "0_1_0", "end": "0_2_0", "chunk": [
             {"type": "m.presence",
              "content": {
                  "user_id": "@banana:test",
                  "state": ONLINE,
+                 "displayname": "Frank",
                  "mtime_age": 0,
             }},
         ]}, response)
diff --git a/tests/rest/test_profile.py b/tests/rest/test_profile.py
index 9bd8dc9783..24456769c7 100644
--- a/tests/rest/test_profile.py
+++ b/tests/rest/test_profile.py
@@ -26,7 +26,7 @@ from synapse.api.errors import SynapseError, AuthError
 from synapse.server import HomeServer
 
 myid = "@1234ABCD:test"
-PATH_PREFIX = "/matrix/client/api/v1"
+PATH_PREFIX = "/_matrix/client/api/v1"
 
 class ProfileTestCase(unittest.TestCase):
     """ Tests profile management. """
diff --git a/tests/rest/test_rooms.py b/tests/rest/test_rooms.py
index 914dc28f53..b432cf254e 100644
--- a/tests/rest/test_rooms.py
+++ b/tests/rest/test_rooms.py
@@ -32,7 +32,7 @@ from .utils import RestTestCase
 
 from mock import Mock
 
-PATH_PREFIX = "/matrix/client/api/v1"
+PATH_PREFIX = "/_matrix/client/api/v1"
 
 
 class RoomPermissionsTestCase(RestTestCase):
diff --git a/webclient/app-controller.js b/webclient/app-controller.js
index 80474bb8df..172770f82f 100644
--- a/webclient/app-controller.js
+++ b/webclient/app-controller.js
@@ -37,6 +37,8 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
         mPresence.start();
     }
     
+    $scope.user_id = matrixService.config().user_id;
+    
     /**
      * Open a given page.
      * @param {String} url url of the page
@@ -45,6 +47,16 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
         $location.url(url);
     };
     
+    // Open the given user profile page
+    $scope.goToUserPage = function(user_id) {
+        if (user_id === $scope.user_id) {
+            $location.url("/settings");
+        }
+        else {
+            $location.url("/user/" + user_id);
+        }
+    };
+    
     // Logs the user out 
     $scope.logout = function() {
         
@@ -69,11 +81,8 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
         $scope.logout();
     });
     
-    $scope.requestNotifications = function() {
-        if (window.Notification) {
-            console.log("Notification.permission: " + window.Notification.permission);
-            window.Notification.requestPermission(function(){});
-        }
+    $scope.updateHeader = function() {
+        $scope.user_id = matrixService.config().user_id;
     };
     
 }]);
diff --git a/webclient/app-directive.js b/webclient/app-directive.js
index 01f60fdadf..eee0d3842f 100644
--- a/webclient/app-directive.js
+++ b/webclient/app-directive.js
@@ -32,7 +32,12 @@ angular.module('matrixWebClient')
 .directive('ngFocus', ['$timeout', function($timeout) {
     return {
         link: function(scope, element, attr) {
-            $timeout(function() { element[0].focus(); }, 0);
+            // XXX: slightly evil hack to disable autofocus on iOS, as in general
+            // it causes more problems than it fixes, by bouncing the page
+            // around
+            if (!/(iPad|iPhone|iPod)/g.test(navigator.userAgent)) {
+                $timeout(function() { element[0].focus(); }, 0);
+            }            
         }
     };
 }]);
\ No newline at end of file
diff --git a/webclient/app.css b/webclient/app.css
index 16f9dd72b7..cd1820e155 100644..100755
--- a/webclient/app.css
+++ b/webclient/app.css
@@ -1,121 +1,194 @@
-/*** Mobile voodoo ***/
-@media all and (max-device-width: 640px) {
-            
-    #messageTableWrapper {
-        margin-right: 0px ! important;
-    }
-    
-    .leftBlock {
-        width: 8em ! important;
-        font-size: 8px ! important;
-    }
-    
-    .rightBlock {
-        width: 0px ! important;
-        display: none ! important;
-    }
-    
-    .avatar {
-        width: 36px ! important;
-    }
-    
-    #header,
-    #messageTable,
-    #wrapper,
-    #roomName,
-    #controls {
-        max-width: 640px ! important;
-    }    
-    
-    #userIdCell,
-    #usersTableWrapper,
-    #extraControls {
-        display: none;
-    }
-    
-    #buttonsCell {
-        width: 60px ! important;
-        padding-left: 20px ! important;
-    }
-    
-    #roomLogo {
-        display: none;
-    }
-    
-    #roomName {
-        text-align: left ! important;
-        top: -35px ! important;
-    }
-    
-    .bubble {
-        font-size: 12px ! important;
-        min-height: 20px ! important;
-    }
-    
-    #page {
-        top: 35px ! important;
-        bottom: 70px ! important;
-    }
-    
-    #header,
-    #page {
-        margin: 5px ! important;
-    }
-    
-    #header {
-        padding: 5px ! important;
-    }
-        
-    /* stop zoom on select */
-    select:focus,
-    textarea,
-    input
-    {
-        font-size: 16px ! important;
-    }
-    
+/** Common layout **/
+
+html {
+    height: 100%;
 }
 
 body {
+    height: 100%;
     font-family: "Myriad Pro", "Myriad", Helvetica, Arial, sans-serif;
     font-size: 12pt;
     margin: 0px;
 }
 
 h1 {
-    font-family: Helvetica, Arial, sans-serif;
+    font-size: 20pt;
 }
 
-/*** Overall page layout ***/
+a:link    { color: #666; }
+a:visited { color: #666; }
+a:hover   { color: #000; }
+a:active  { color: #000; }
 
 #page {
-    position: absolute;
-    top: 80px;
-    bottom: 100px;
-    left: 0px;
-    right: 0px;
-    margin: 20px;
+    min-height: 100%;
+    margin-bottom: -32px; /* to make room for the footer */
 }
 
 #wrapper {
     margin: auto;
     max-width: 1280px;
-    height: 100%;
+    padding-top: 40px;
+    padding-bottom: 40px;
+    padding-left: 20px;
+    padding-right: 20px;
 }
 
-#roomName {
+#header
+{
+    position: absolute;
+    top: 0px;
+    width: 100%;
+    background-color: #333;
+    height: 32px;
+}
+
+#headerContent {
+    color: #ccc;
     max-width: 1280px;
+    margin: auto;
+    text-align: right;
+    height: 32px;
+    line-height: 32px;
+}
+
+#headerContent a:link,
+#headerContent a:visited,
+#headerContent a:hover,
+#headerContent a:active {
+    color: #fff;
+}
+
+#footer
+{
     width: 100%;
+    border-top: #666 1px solid;
+    background-color: #aaa;
+    height: 32px;
+}
+
+#footerContent
+{
+    font-size: 8pt;
+    color: #fff;
+    max-width: 1280px;
+    margin: auto;
+    text-align: center;
+    height: 32px;
+    line-height: 32px;
+}
+
+#genericHeading
+{
+    margin-top: 13px;
+}
+
+#feedback {
+    color: #800;
+}
+
+.mouse-pointer {
+    cursor: pointer;
+}
+
+.invited {
+    opacity: 0.2;
+}
+
+/*** Login Pages ***/
+
+.loginWrapper {
+    text-align: center;
+}
+
+#loginForm {
+    text-align: left;
+    padding: 1em;
+    margin-bottom: 40px;
+    display: inline-block;
+    
+    -webkit-border-radius: 10px;
+    -moz-border-radius: 10px;
+    border-radius: 10px;
+    
+    -webkit-box-shadow: 0px 0px 20px 0px rgba(0,0,0,0.15);
+    -moz-box-shadow: 0px 0px 20px 0px rgba(0,0,0,0.15);
+    box-shadow: 0px 0px 20px 0px rgba(0,0,0,0.15);
+    
+    background-color: #f8f8f8;
+    border: 1px #ccc solid;
+}
+
+#loginForm input[type='radio'] {
+    margin-right: 1em;
+}
+
+#serverConfig {
+    text-align: center;
+}
+
+#serverConfig,
+#serverConfig input,
+#serverConfig button
+{
+    font-size: 10pt ! important;
+}
+
+.smallPrint {
+    color: #888;
+    font-size: 9pt ! important;
+    font-style: italic ! important;
+}
+
+#serverConfig label {
+    display: inline-block;
     text-align: right;
-    top: -40px;
+    margin-right: 0.5em;
+    width: 7em;   
+}
+
+#loginForm,
+#loginForm input,
+#loginForm button,
+#loginForm select {
+    font-size: 18px;
+}
+
+/*** Room page ***/
+
+#roomPage {
     position: absolute;
+    top: 120px;
+    bottom: 120px;
+    left: 20px;
+    right: 20px;
+}
+
+#roomWrapper {
+    margin: auto;
+    max-width: 1280px;
+    height: 100%;
+}
+
+#roomName {
+    float: right;
     font-size: 16px;
+    margin-top: 15px;
+}
+
+#roomHeader {
+    margin: auto;
+    padding-left: 20px;
+    padding-right: 20px;
+    padding-top: 53px;
+    max-width: 1280px;
 }
 
 #controlPanel {
     position: absolute;
     bottom: 0px;
     width: 100%;
+    height: 100px;
     background-color: #f8f8f8;
     border-top: #aaa 1px solid;
 }
@@ -146,10 +219,6 @@ h1 {
     background-color: #faa;
 }
 
-.mouse-pointer {
-    cursor: pointer;
-}
-
 /*** Participant list ***/
 
 #usersTableWrapper {
@@ -300,7 +369,7 @@ h1 {
     display: inline-block;
     margin-bottom: -1px;
     max-width: 90%;
-    font-size: 16px;
+    font-size: 14px;
     word-wrap: break-word;
     padding-top: 7px;
     padding-bottom: 5px;
@@ -310,6 +379,11 @@ h1 {
     -webkit-text-size-adjust:100%
 }
 
+.bubble img {
+    max-width: 100%;
+    max-height: auto;
+}
+
 .differentUser td {
     padding-bottom: 5px ! important;
 }
@@ -341,8 +415,8 @@ h1 {
 }
 
 #room-fullscreen-image img {
-    max-width: 100%;
-    max-height: 100%;
+    max-width: 90%;
+    max-height: 90%;
     bottom: 0;
     left: 0;
     margin: auto;
@@ -350,9 +424,14 @@ h1 {
     position: fixed;
     right: 0;
     top: 0;
+
+    -webkit-box-shadow: 0px 0px 20px 0px rgba(0,0,0,0.75);
+    -moz-box-shadow: 0px 0px 20px 0px rgba(0,0,0,0.75);
+    box-shadow: 0px 0px 20px 0px rgba(0,0,0,0.75);    
 }
 
 /*** Recents ***/
+
 .recentsTable {
     max-width: 480px;
     width: 100%;
@@ -402,11 +481,14 @@ h1 {
 }
 
 /*** Recents in the room page ***/
+
 #roomRecentsTableWrapper {
     float: left;
     max-width: 320px;
-    margin-right: 20px;
+    padding-right: 10px;
+    margin-right: 10px;
     height: 100%;
+    border-right: 1px solid #ddd;
     overflow-y: auto;
 }
 
@@ -421,55 +503,14 @@ h1 {
 }
 
 .profile-avatar img {
-    max-width: 100%;
-    max-height: 100%;
+    width: 100%;
+    height: 100%;
+    object-fit: cover;    
 }
 
 /*** User profile page ***/
-#user-ids {
-    padding-left: 1em;
-}
 
 #user-displayname {
     font-size: 24px;
 }
-/******************************/
 
-#header
-{
-    padding: 20px;
-    max-width: 1280px;
-    margin: auto;
-}
-
-#logo,
-#roomLogo {
-    max-width: 1280px;
-    margin: auto;
-}
-
-#header-buttons {
-    float: right;
-}
-
-.text_entry_section {
-    position: fixed;
-    bottom: 0;
-    z-index: 100;
-    left: 0;
-    right: 10em;
-    width: 100%;
-    background: #e0e0e0;
-}
-
-.member_invited {
-    color: blue;
-}
-
-.member_joined {
-
-}
-
-.member_left {
-    color: gray;
-}
diff --git a/webclient/app.js b/webclient/app.js
index 02695c3ae6..9663ddf967 100644
--- a/webclient/app.js
+++ b/webclient/app.js
@@ -18,6 +18,7 @@ var matrixWebClient = angular.module('matrixWebClient', [
     'ngRoute',
     'MatrixWebClientController',
     'LoginController',
+    'RegisterController',
     'RoomController',
     'HomeController',
     'RecentsController',
@@ -38,6 +39,10 @@ matrixWebClient.config(['$routeProvider', '$provide', '$httpProvider',
                 templateUrl: 'login/login.html',
                 controller: 'LoginController'
             }).
+            when('/register', {
+                templateUrl: 'login/register.html',
+                controller: 'RegisterController'
+            }).
             when('/room/:room_id_or_alias', {
                 templateUrl: 'room/room.html',
                 controller: 'RoomController'
@@ -84,7 +89,10 @@ matrixWebClient.config(['$routeProvider', '$provide', '$httpProvider',
 matrixWebClient.run(['$location', 'matrixService', function($location, matrixService) {
 
     // If user auth details are not in cache, go to the login page
-    if (!matrixService.isUserLoggedIn()) {
+    if (!matrixService.isUserLoggedIn() &&
+        $location.path() !== "/login" &&
+        $location.path() !== "/register")
+    {
         $location.path("login");
     }
 
diff --git a/webclient/components/fileUpload/file-upload-service.js b/webclient/components/fileUpload/file-upload-service.js
index 5f01478fd1..699a3cbffc 100644
--- a/webclient/components/fileUpload/file-upload-service.js
+++ b/webclient/components/fileUpload/file-upload-service.js
@@ -30,7 +30,7 @@ angular.module('mFileUpload', ['matrixService', 'mUtilities'])
      */
     this.uploadFile = function(file) {
         var deferred = $q.defer();
-        console.log("Uploading " + file.name + "... to /matrix/content");
+        console.log("Uploading " + file.name + "... to /_matrix/content");
         matrixService.uploadContent(file).then(
             function(response) {
                 var content_url = response.data.content_token;
diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js
index c0a7735a7c..47b63d7f2f 100644
--- a/webclient/components/matrix/matrix-call.js
+++ b/webclient/components/matrix/matrix-call.js
@@ -36,7 +36,7 @@ var forAllTracksOnStream = function(s, f) {
 }
 
 angular.module('MatrixCall', [])
-.factory('MatrixCall', ['matrixService', 'matrixPhoneService', function MatrixCallFactory(matrixService, matrixPhoneService) {
+.factory('MatrixCall', ['matrixService', 'matrixPhoneService', '$rootScope', function MatrixCallFactory(matrixService, matrixPhoneService, $rootScope) {
     var MatrixCall = function(room_id) {
         this.room_id = room_id;
         this.call_id = "c" + new Date().getTime();
@@ -208,6 +208,7 @@ angular.module('MatrixCall', [])
         // ideally we'd consider the call to be connected when we get media but chrome doesn't implement nay of the 'onstarted' events yet
         if (this.peerConn.iceConnectionState == 'completed' || this.peerConn.iceConnectionState == 'connected') {
             this.state = 'connected';
+            $rootScope.$apply();
         }
     };
 
diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js
index 8543491dca..d509c20b0f 100644
--- a/webclient/components/matrix/matrix-service.js
+++ b/webclient/components/matrix/matrix-service.js
@@ -38,7 +38,7 @@ angular.module('matrixService', [])
     
     // Current version of permanent storage
     var configVersion = 0;
-    var prefixPath = "/matrix/client/api/v1";
+    var prefixPath = "/_matrix/client/api/v1";
     var MAPPING_PREFIX = "alias_for_";
 
     var doRequest = function(method, path, params, data, $httpParams) {
@@ -95,14 +95,18 @@ angular.module('matrixService', [])
         },
 
         // Create a room
-        create: function(room_id, visibility) {
+        create: function(room_alias, visibility) {
             // The REST path spec
             var path = "/createRoom";
 
-            return doRequest("POST", path, undefined, {
-                visibility: visibility,
-                room_alias_name: room_id
-            });
+            var req = {
+                "visibility": visibility
+            };
+            if (room_alias) {
+                req.room_alias_name = room_alias;
+            }
+            
+            return doRequest("POST", path, undefined, req);
         },
 
         // List all rooms joined or been invited to
@@ -164,7 +168,7 @@ angular.module('matrixService', [])
 
         // Retrieves the room ID corresponding to a room alias
         resolveRoomAlias:function(room_alias) {
-            var path = "/matrix/client/api/v1/directory/room/$room_alias";
+            var path = "/_matrix/client/api/v1/directory/room/$room_alias";
             room_alias = encodeURIComponent(room_alias);
 
             path = path.replace("$room_alias", room_alias);
@@ -304,7 +308,7 @@ angular.module('matrixService', [])
 
         // hit the Identity Server for a 3PID request.
         linkEmail: function(email, clientSecret, sendAttempt) {
-            var path = "/matrix/identity/api/v1/validate/email/requestToken"
+            var path = "/_matrix/identity/api/v1/validate/email/requestToken"
             var data = "clientSecret="+clientSecret+"&email=" + encodeURIComponent(email)+"&sendAttempt="+sendAttempt;
             var headers = {};
             headers["Content-Type"] = "application/x-www-form-urlencoded";
@@ -312,7 +316,7 @@ angular.module('matrixService', [])
         },
 
         authEmail: function(clientSecret, tokenId, code) {
-            var path = "/matrix/identity/api/v1/validate/email/submitToken";
+            var path = "/_matrix/identity/api/v1/validate/email/submitToken";
             var data = "token="+code+"&sid="+tokenId+"&clientSecret="+clientSecret;
             var headers = {};
             headers["Content-Type"] = "application/x-www-form-urlencoded";
@@ -320,7 +324,7 @@ angular.module('matrixService', [])
         },
 
         bindEmail: function(userId, tokenId, clientSecret) {
-            var path = "/matrix/identity/api/v1/3pid/bind";
+            var path = "/_matrix/identity/api/v1/3pid/bind";
             var data = "mxid="+encodeURIComponent(userId)+"&sid="+tokenId+"&clientSecret="+clientSecret;
             var headers = {};
             headers["Content-Type"] = "application/x-www-form-urlencoded";
@@ -328,7 +332,7 @@ angular.module('matrixService', [])
         },
         
         uploadContent: function(file) {
-            var path = "/matrix/content";
+            var path = "/_matrix/content";
             var headers = {
                 "Content-Type": undefined // undefined means angular will figure it out
             };
diff --git a/webclient/home/home-controller.js b/webclient/home/home-controller.js
index 547a5c5603..847918d5dc 100644
--- a/webclient/home/home-controller.js
+++ b/webclient/home/home-controller.js
@@ -37,6 +37,11 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
     $scope.joinAlias = {
         room_alias: ""
     };
+    
+    $scope.profile = {
+        displayName: "",
+        avatarUrl: ""
+    };
 
     var refresh = function() {
         
@@ -53,14 +58,14 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
         );
     };
     
-    $scope.createNewRoom = function(room_id, isPrivate) {
+    $scope.createNewRoom = function(room_alias, isPrivate) {
         
         var visibility = "public";
         if (isPrivate) {
             visibility = "private";
         }
         
-        matrixService.create(room_id, visibility).then(
+        matrixService.create(room_alias, visibility).then(
             function(response) { 
                 // This room has been created. Refresh the rooms list
                 console.log("Created room " + response.data.room_alias + " with id: "+
@@ -108,6 +113,26 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
     };
  
     $scope.onInit = function() {
+        // Load profile data
+        // Display name
+        matrixService.getDisplayName($scope.config.user_id).then(
+            function(response) {
+                $scope.profile.displayName = response.data.displayname;
+            },
+            function(error) {
+                $scope.feedback = "Can't load display name";
+            } 
+        );
+        // Avatar
+        matrixService.getProfilePictureUrl($scope.config.user_id).then(
+            function(response) {
+                $scope.profile.avatarUrl = response.data.avatar_url;
+            },
+            function(error) {
+                $scope.feedback = "Can't load avatar URL";
+            } 
+        );
+
         refresh();
     };
 }]);
diff --git a/webclient/home/home.html b/webclient/home/home.html
index d38b843d83..c1f9643839 100644
--- a/webclient/home/home.html
+++ b/webclient/home/home.html
@@ -1,29 +1,24 @@
 <div ng-controller="HomeController" data-ng-init="onInit()">
 
-    <div id="page">
     <div id="wrapper">
-        
+    
+    <div id="genericHeading">
+        <a href ng-click="goToPage('/')"><img src="img/logo-small.png" width="100" height="43" alt="[matrix]"/></a>
+    </div>
+    
+    <h1>Welcome to homeserver {{ config.homeserver }}</h1>
+    
     <div>
-        <form>
-            <table>
-                <tr>
-                    <td>
-                        <div class="profile-avatar">
-                            <img ng-src="{{ config.avatarUrl || 'img/default-profile.jpg' }}"/>
-                        </div>
-                    </td>
-                    <td>
-                        <div id="user-ids">
-                            <div id="user-displayname">{{ config.displayName }}</div>
-                            <div>{{ config.user_id }}</div>                        
-                        </div>
-                    </td>
-                </tr>
-            </table>
-        </form>
+        <div class="profile-avatar">
+            <img ng-src="{{ (null !== profile.avatarUrl) ? profile.avatarUrl : 'img/default-profile.png' }}"/>
+        </div>
+        <div id="user-ids">
+            <div id="user-displayname">{{ profile.displayName }}</div>
+            <div>{{ config.user_id }}</div>
+        </div>
     </div>
     
-    <h3>Recents</h3>
+    <h3>Recent conversations</h3>
         <div ng-include="'recents/recents.html'"></div>
     <br/>
 
@@ -38,9 +33,9 @@
     
     <div>
         <form>
-            <input size="40" ng-model="newRoom.room_id" ng-enter="createNewRoom(newRoom.room_id, 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_channel)"/>
             <input type="checkbox" ng-model="newRoom.private">private
-            <button ng-disabled="!newRoom.room_id" ng-click="createNewRoom(newRoom.room_id, newRoom.private)">Create room</button>    
+            <button ng-disabled="!newRoom.room_alias" ng-click="createNewRoom(newRoom.room_alias, newRoom.private)">Create room</button>    
         </form>
     </div>
     <div>
@@ -54,5 +49,4 @@
     {{ feedback }}
 
     </div>    
-    </div>
 </div>
diff --git a/webclient/img/default-profile.jpg b/webclient/img/default-profile.jpg
deleted file mode 100644
index 20f2a2b085..0000000000
--- a/webclient/img/default-profile.jpg
+++ /dev/null
Binary files differdiff --git a/webclient/img/default-profile.png b/webclient/img/default-profile.png
new file mode 100644
index 0000000000..6f81a3c417
--- /dev/null
+++ b/webclient/img/default-profile.png
Binary files differdiff --git a/webclient/img/logo-small.png b/webclient/img/logo-small.png
new file mode 100644
index 0000000000..411206dcdc
--- /dev/null
+++ b/webclient/img/logo-small.png
Binary files differdiff --git a/webclient/img/logo.png b/webclient/img/logo.png
new file mode 100644
index 0000000000..c4b53a8487
--- /dev/null
+++ b/webclient/img/logo.png
Binary files differdiff --git a/webclient/index.html b/webclient/index.html
index 5faf165626..3c31a8a051 100644
--- a/webclient/index.html
+++ b/webclient/index.html
@@ -4,6 +4,8 @@
     <title>[matrix]</title>
         
     <link rel="stylesheet" href="app.css">
+    <link rel="stylesheet" href="mobile.css">
+    
     <link rel="icon" href="favicon.ico">
    
     <meta name="viewport" content="width=device-width">
@@ -19,6 +21,7 @@
     <script src="app-filter.js"></script>
     <script src="home/home-controller.js"></script>
     <script src="login/login-controller.js"></script>
+    <script src="login/register-controller.js"></script>
     <script src="recents/recents-controller.js"></script>
     <script src="recents/recents-filter.js"></script>
     <script src="room/room-controller.js"></script>
@@ -38,15 +41,23 @@
 
 <body>
 
-    <header id="header">
+    <div id="header">
         <!-- Do not show buttons on the login page -->
-        <div id="header-buttons" ng-hide="'/login' == location ">
+        <div id="headerContent" ng-hide="'/login' == location || '/register' == location">
+            <a href id="headerUserId" ng-click='goToUserPage(user_id)'>{{ user_id }}</a>
+            &nbsp;
+            <button ng-click='goToPage("/")'>Home</button>
             <button ng-click='goToPage("settings")'>Settings</button>
             <button ng-click="logout()">Log out</button>
         </div>
-    </header>
+    </div>
 
-    <div ng-view></div>
+    <div id="page" ng-view></div>
 
+    <div id="footer" ng-hide="location.indexOf('/room') == 0">
+        <div id="footerContent">
+            &copy 2014 Matrix.org
+        </div>
+    </div>
 </body>
 </html>
diff --git a/webclient/login/login-controller.js b/webclient/login/login-controller.js
index 51f9a3bdf4..7369a28ef0 100644
--- a/webclient/login/login-controller.js
+++ b/webclient/login/login-controller.js
@@ -1,3 +1,19 @@
+/*
+ Copyright 2014 matrix.org
+ 
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+ 
+ http://www.apache.org/licenses/LICENSE-2.0
+ 
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+ 
 angular.module('LoginController', ['matrixService'])
 .controller('LoginController', ['$scope', '$location', 'matrixService', 'eventStreamService',
                                     function($scope, $location, matrixService, eventStreamService) {
@@ -7,7 +23,10 @@ angular.module('LoginController', ['matrixService'])
     // Assume that this is hosted on the home server, in which case the URL
     // contains the home server.
     var hs_url = $location.protocol() + "://" + $location.host();
-    if ($location.port()) {
+    if ($location.port() &&
+        !($location.protocol() === "http" && $location.port() === 80) &&
+        !($location.protocol() === "https" && $location.port() === 443))
+    {
         hs_url += ":" + $location.port();
     }
     
@@ -16,57 +35,18 @@ angular.module('LoginController', ['matrixService'])
         desired_user_name: "",
         user_id: "",
         password: "",
-        identityServer: "",
+        identityServer: "http://matrix.org:8090",
         pwd1: "",
-        pwd2: ""
+        pwd2: "",
     };
-
-    $scope.register = function() {
-
-        // Set the urls
-        matrixService.setConfig({
-            homeserver: $scope.account.homeserver,
-            identityServer: $scope.account.identityServer
-        });
-        
-        if ($scope.account.pwd1 !== $scope.account.pwd2) {
-            $scope.feedback = "Passwords don't match.";
-            return;
-        }
-        else if ($scope.account.pwd1.length < 6) {
-            $scope.feedback = "Password must be at least 6 characters.";
-            return;
-        }
-
-        matrixService.register($scope.account.desired_user_name, $scope.account.pwd1).then(
-            function(response) {
-                $scope.feedback = "Success";
-                // Update the current config 
-                var config = matrixService.config();
-                angular.extend(config, {
-                    access_token: response.data.access_token,
-                    user_id: response.data.user_id
-                });
-                matrixService.setConfig(config);
-
-                // And permanently save it
-                matrixService.saveConfig();
-                eventStreamService.resume();
-                 // Go to the user's rooms list page
-                $location.url("home");
-            },
-            function(error) {
-                if (error.data) {
-                    if (error.data.errcode === "M_USER_IN_USE") {
-                        $scope.feedback = "Username already taken.";
-                    }
-                }
-                else if (error.status === 0) {
-                    $scope.feedback = "Unable to talk to the server.";
-                }
-            });
+    
+    $scope.login_types = [ "email", "mxid" ];
+    $scope.login_type_label = {
+        "email": "Email address",
+        "mxid": "Matrix ID (e.g. @bob:matrix.org or bob)",
     };
-
+    $scope.login_type = 'mxid'; // TODO: remember the user's preferred login_type
+    
     $scope.login = function() {
         matrixService.setConfig({
             homeserver: $scope.account.homeserver,
diff --git a/webclient/login/login.html b/webclient/login/login.html
index 4b2ea60928..18e7a02815 100644
--- a/webclient/login/login.html
+++ b/webclient/login/login.html
@@ -1,55 +1,49 @@
 <div ng-controller="LoginController" class="login">    
-    <h1 id="logo">[matrix]</h1>
-
-    <div id="page">
-    <div id="wrapper">
-
-    {{ feedback }}
+    <div id="wrapper" class="loginWrapper">
         
-    <h3>Register for an account:</h3>
-    <form novalidate>
-        <input id="desired_user_name" size="70" type="text" auto-focus ng-model="account.desired_user_name" placeholder="User name (ex:bob)"/>
-        <br/>
-        <input id="pwd1" size="70" type="password" auto-focus ng-model="account.pwd1" placeholder="Type a password"/>
-        <br/>
-        <input id="pwd2" size="70" type="password" auto-focus ng-model="account.pwd2" placeholder="Re-type your password"/>
+        <a href ng-click="goToPage('/')">
+        <img src="img/logo.png" width="240" height="102" alt="[matrix]" style="padding: 50px"/>
+        </a>
+    
         <br/>
-        <!-- New user registration -->
-        <div>
-            <br/>
-            <button ng-click="register()" ng-disabled="!account.desired_user_name || !account.homeserver || !account.pwd1 || !account.pwd2 || account.pwd1 !== account.pwd2">Register</button>
-        </div>
-    </form>
 
-    <h3>Got an account?</h3>
-    <form novalidate>
-        <!-- Login with an registered user -->
-        <div>{{ login_error_msg }} </div>
-        <div>
-            <input id="user_id" size="70" type="text" auto-focus ng-model="account.user_id" placeholder="User ID (ex:@bob:localhost or bob)"/>
-            <br />
-            <input id="password" size="70" 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>
-        </div>
-       
-    </form>
+        <form id="loginForm" novalidate>
+            <!-- Login with an registered user -->
+            <div>
+                Log in using:<br/>
+                
+                <div ng-repeat="type in login_types">
+                <input type="radio" ng-model="$parent.login_type" value="{{ type }}" id="radio_{{ type }}"/>
+                <label for="radio_{{ type }}">{{ login_type_label[type] }}</label>
+                </div>
+                    
+                <div style="text-align: center">
+                    <br/>
+                    <input id="user_id" size="32" type="text" ng-focus="true" ng-model="account.user_id" placeholder="{{ login_type_label[login_type] }}"/>
+                    <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>
+                    <br/><br/>
+                </div>
 
-    <h3>Servers</h3>
-    <form novalidate>
-        <div>
-        Home Server: 
-        <input id="homeserver" size="57" type="text" ng-model="account.homeserver" placeholder="Home server URL (ex: http://localhost:8080)"/>
-        </div>
-        <br />
-        <div>
-        Identity Server: 
-        <input id="identityServer" size="56" type="text" ng-model="account.identityServer" placeholder="Identity server URL (ex: http://localhost:8090)"/>
-        </div>
-        <br />
-    </form>
-    <br/>
-    
+                <div class="feedback">{{ feedback }} {{ login_error_msg }}</div>
+                
+                <div id="serverConfig">
+                    <label for="homeserver">Home Server:</label> 
+                    <input id="homeserver" size="32" type="text" ng-model="account.homeserver" placeholder="URL (e.g. http://matrix.org:8080)"/>
+                    <div class="smallPrint">Your home server stores all your conversation and account data.</div>
+                    <label for="identityServer">Identity Server:</label>
+                    <input id="identityServer" size="32" type="text" ng-model="account.identityServer" placeholder="URL (e.g. http://matrix.org:8090)"/>
+                    <div class="smallPrint">Matrix provides identity servers to track which emails etc. belong to which Matrix IDs.<br/>
+                        Only http://matrix.org:8090 currently exists.</div>
+                    <br/>
+                    <br/>
+                    <a href="#/register" style="padding-right: 3em">Create account</a>
+                    <a href="#/reset_password">Forgotten password?</a>
+                </div>
+            </div>
+        </form>
 
     </div>
     </div>
diff --git a/webclient/login/register-controller.js b/webclient/login/register-controller.js
new file mode 100644
index 0000000000..0ece57502b
--- /dev/null
+++ b/webclient/login/register-controller.js
@@ -0,0 +1,102 @@
+/*
+ Copyright 2014 matrix.org
+ 
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+ 
+ http://www.apache.org/licenses/LICENSE-2.0
+ 
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+ 
+angular.module('RegisterController', ['matrixService'])
+.controller('RegisterController', ['$scope', '$location', 'matrixService', 'eventStreamService',
+                                    function($scope, $location, matrixService, eventStreamService) {
+    'use strict';
+    
+    // FIXME: factor out duplication with login-controller.js
+    
+    // Assume that this is hosted on the home server, in which case the URL
+    // contains the home server.
+    var hs_url = $location.protocol() + "://" + $location.host();
+    if ($location.port() &&
+        !($location.protocol() === "http" && $location.port() === 80) &&
+        !($location.protocol() === "https" && $location.port() === 443))
+    {
+        hs_url += ":" + $location.port();
+    }
+    
+    $scope.account = {
+        homeserver: hs_url,
+        desired_user_id: "",
+        desired_user_name: "",
+        password: "",
+        identityServer: "http://matrix.org:8090",
+        pwd1: "",
+        pwd2: "",
+        displayName : ""
+    };
+    
+    $scope.register = function() {
+
+        // Set the urls
+        matrixService.setConfig({
+            homeserver: $scope.account.homeserver,
+            identityServer: $scope.account.identityServer
+        });
+        
+        if ($scope.account.pwd1 !== $scope.account.pwd2) {
+            $scope.feedback = "Passwords don't match.";
+            return;
+        }
+        else if ($scope.account.pwd1.length < 6) {
+            $scope.feedback = "Password must be at least 6 characters.";
+            return;
+        }
+
+        matrixService.register($scope.account.desired_user_id, $scope.account.pwd1).then(
+            function(response) {
+                $scope.feedback = "Success";
+                // Update the current config 
+                var config = matrixService.config();
+                angular.extend(config, {
+                    access_token: response.data.access_token,
+                    user_id: response.data.user_id
+                });
+                matrixService.setConfig(config);
+
+                // And permanently save it
+                matrixService.saveConfig();
+                
+                // Update the global scoped used_id var (used in the app header)
+                $scope.updateHeader();
+                
+                eventStreamService.resume();
+                
+                if ($scope.account.displayName) {
+                    // FIXME: handle errors setting displayName
+                    matrixService.setDisplayName($scope.account.displayName);
+                }
+                
+                 // Go to the user's rooms list page
+                $location.url("home");
+            },
+            function(error) {
+                if (error.data) {
+                    if (error.data.errcode === "M_USER_IN_USE") {
+                        $scope.feedback = "Username already taken.";
+                    }
+                }
+                else if (error.status === 0) {
+                    $scope.feedback = "Unable to talk to the server.";
+                }
+            });
+    };
+
+}]);
+
diff --git a/webclient/login/register.html b/webclient/login/register.html
new file mode 100644
index 0000000000..1b470e4554
--- /dev/null
+++ b/webclient/login/register.html
@@ -0,0 +1,48 @@
+<div ng-controller="RegisterController" class="register">
+    <div id="wrapper" class="loginWrapper">
+
+        <a href ng-click="goToPage('/')">
+            <img src="img/logo.png" width="240" height="102" alt="[matrix]" style="padding: 50px"/>
+        </a>
+        <br/>
+
+        <form id="loginForm" novalidate>
+            <div>
+                Create account:<br/>
+                
+                <div style="text-align: center">
+                    <br/>
+                    <input id="email" size="32" type="text" ng-focus="true" ng-model="account.email" placeholder="Email address (optional)"/>
+                    <div class="smallPrint">Specifying an email address lets other users find you on Matrix more easily,<br/>
+                        and gives you a way to reset your password</div>
+                    <input id="desired_user_id" size="32" type="text" ng-model="account.desired_user_id" placeholder="Matrix ID (e.g. bob)"/>
+                    <br/>
+                    <input id="pwd1" size="32" type="password" ng-model="account.pwd1" placeholder="Type a password"/>
+                    <br/>
+                    <input id="pwd2" size="32" type="password" ng-model="account.pwd2" placeholder="Confirm your password"/>
+                    <br/>
+                    <input id="displayName" size="32" type="text" ng-model="account.displayName" placeholder="Display name (e.g. Bob Obson)"/>
+                    <br/>
+                    <br/>
+                    
+                    <button ng-click="register()" ng-disabled="!account.desired_user_id || !account.homeserver || !account.pwd1 || !account.pwd2 || account.pwd1 !== account.pwd2">Sign up</button>
+                    <br/><br/>
+                </div>
+
+                <div class="feedback">{{ feedback }} {{ login_error_msg }}</div>
+                
+                <div id="serverConfig">
+                    <label for="homeserver">Home Server:</label> 
+                    <input id="homeserver" size="32" type="text" ng-model="account.homeserver" placeholder="URL (e.g. http://matrix.org:8080)"/>
+                    <div class="smallPrint">Your home server stores all your conversation and account data.</div>
+                    <label for="identityServer">Identity Server:</label>
+                    <input id="identityServer" size="32" type="text" ng-model="account.identityServer" placeholder="URL (e.g. http://matrix.org:8090)"/>
+                    <div class="smallPrint">Matrix provides identity servers to track which emails etc. belong to which Matrix IDs.<br/>
+                        Only http://matrix.org:8090 currently exists.</div>
+                </div>
+            </div>
+        </form>
+
+    </div>
+    </div>
+</div>
diff --git a/webclient/mobile.css b/webclient/mobile.css
new file mode 100644
index 0000000000..7c62a072d5
--- /dev/null
+++ b/webclient/mobile.css
@@ -0,0 +1,92 @@
+/*** Mobile voodoo ***/
+@media all and (max-device-width: 640px) {
+            
+    #messageTableWrapper {
+        margin-right: 0px ! important;
+    }
+    
+    .leftBlock {
+        width: 8em ! important;
+        font-size: 8px ! important;
+    }
+    
+    .rightBlock {
+        width: 0px ! important;
+        display: none ! important;
+    }
+    
+    .avatar {
+        width: 36px ! important;
+    }
+    
+    #header {
+        background-color: transparent;
+    }
+    
+    #headerContent {
+        padding-right: 5px;
+    }
+    
+    #headerContent button {
+        font-size: 8px;
+    }
+    
+    #messageTable,
+    #wrapper,
+    #controls {
+        max-width: 640px ! important;
+    }    
+    
+    #headerUserId,
+    #roomHeader img,
+    #userIdCell,
+    #roomRecentsTableWrapper,
+    #usersTableWrapper,
+    .extraControls {
+        display: none;
+    }
+    
+    #buttonsCell {
+        width: 60px ! important;
+        padding-left: 20px ! important;
+    }
+    
+    #roomLogo {
+        display: none;
+    }
+        
+    .bubble {
+        font-size: 12px ! important;
+        min-height: 20px ! important;
+    }
+
+    #roomHeader {
+        padding-top: 10px;
+    }
+    
+    #roomName {
+        float: left;
+        font-size: 14px ! important;
+        margin-top: 0px ! important;
+    }
+        
+    #roomPage {
+        top: 35px ! important;
+        left: 5px ! important;
+        right: 5px ! important;
+        bottom: 70px ! important;
+    }
+    
+    #controlPanel {
+        height: 70px;
+    }
+        
+    /* stop zoom on select */
+    select:focus,
+    textarea,
+    input
+    {
+        font-size: 16px ! important;
+    }
+    
+}
diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js
index 09dac85d26..c6028f874e 100644
--- a/webclient/room/room-controller.js
+++ b/webclient/room/room-controller.js
@@ -88,7 +88,7 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput'])
         call.onHangup = $scope.onCallHangup;
         $scope.currentCall = call;
     });
-
+    
     $scope.memberCount = function() {
         return Object.keys($scope.members).length;
     };
@@ -175,6 +175,8 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput'])
 
         // set target_user_id to keep things clear
         var target_user_id = chunk.state_key;
+        
+        var now = new Date().getTime();
 
         var isNewMember = !(target_user_id in $scope.members);
         if (isNewMember) {
@@ -185,44 +187,14 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput'])
             if ("mtime_age" in chunk.content) {
                 chunk.mtime_age = chunk.content.mtime_age;
             }
-            // Once the HS reliably returns the displaynames & avatar_urls for both
-            // local and remote users, we should use this rather than the evalAsync block
-            // below
             if ("displayname" in chunk.content) {
                 chunk.displayname = chunk.content.displayname;
             }
             if ("avatar_url" in chunk.content) {
                 chunk.avatar_url = chunk.content.avatar_url;
             }
-            $scope.members[target_user_id] = chunk;
-
-/*
-            // Stale code for explicitly hammering the homeserver for every displayname & avatar_url
-            
-            // get their display name and profile picture and set it to their
-            // member entry in $scope.members. We HAVE to use $timeout with 0 delay 
-            // to make this function run AFTER the current digest cycle, else the 
-            // response may update a STALE VERSION of the member list (manifesting
-            // as no member names appearing, or appearing sporadically).
-            $scope.$evalAsync(function() {
-                matrixService.getDisplayName(chunk.target_user_id).then(
-                    function(response) {
-                        var member = $scope.members[chunk.target_user_id];
-                        if (member !== undefined) {
-                            member.displayname = response.data.displayname;
-                        }
-                    }
-                ); 
-                matrixService.getProfilePictureUrl(chunk.target_user_id).then(
-                    function(response) {
-                         var member = $scope.members[chunk.target_user_id];
-                         if (member !== undefined) {
-                            member.avatar_url = response.data.avatar_url;
-                         }
-                    }
-                );
-            });
-*/            
+            chunk.last_updated = now;
+            $scope.members[target_user_id] = chunk;   
 
             if (target_user_id in $rootScope.presence) {
                 updatePresence($rootScope.presence[target_user_id]);
@@ -234,6 +206,12 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput'])
             member.content.membership = chunk.content.membership;
         }
     };
+    
+    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)) {
@@ -275,6 +253,10 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput'])
         if ($scope.textInput.indexOf("/me") === 0) {
             promise = matrixService.sendEmoteMessage($scope.room_id, $scope.textInput.substr(4));
         }
+        else if ($scope.textInput.indexOf("/nick ") === 0) {
+            // Change user display name
+            promise = matrixService.setDisplayName($scope.textInput.substr(6));
+        }
         else {
             promise = matrixService.sendTextMessage($scope.room_id, $scope.textInput);
         }
@@ -395,8 +377,10 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput'])
 
         // Make recents highlight the current room
         $scope.recentsSelectedRoomID = $scope.room_id;
-        
+                
         paginate(MESSAGES_PER_PAGINATION);
+        
+        updateMemberListPresenceAge();
     }; 
     
     $scope.inviteUser = function(user_id) {
@@ -404,18 +388,13 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput'])
         matrixService.invite($scope.room_id, user_id).then(
             function() {
                 console.log("Invited.");
-                $scope.feedback = "Request for invitation succeeds";
+                $scope.feedback = "Invite sent successfully";
             },
             function(reason) {
                 $scope.feedback = "Failure: " + reason;
             });
     };
 
-    // Open the user profile page
-    $scope.goToUserPage = function(user_id) {
-        $location.url("/user/" + user_id);
-    };
-
     $scope.leaveRoom = function() {
         
         matrixService.leave($scope.room_id).then(
@@ -487,7 +466,5 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput'])
     }
 
     $scope.onCallHangup = function() {
-        $scope.feedback = "Call ended";
-        $scope.currentCall = undefined;
     }
 }]);
diff --git a/webclient/room/room.html b/webclient/room/room.html
index a3514c3a91..e5e454864b 100644
--- a/webclient/room/room.html
+++ b/webclient/room/room.html
@@ -1,13 +1,15 @@
-<div ng-controller="RoomController" data-ng-init="onInit()" class="room">
-    <h1 id="roomLogo">[matrix]</h1>
+<div ng-controller="RoomController" data-ng-init="onInit()" class="room" style="height: 100%;">
 
-    <div id="page">
-    <div id="wrapper">
-
-    <div id="roomName">
-        {{ room_alias || room_id }}
+    <div id="roomHeader">
+        <a href ng-click="goToPage('/')"><img src="img/logo-small.png" width="100" height="43" alt="[matrix]"/></a>
+        <div id="roomName">
+            {{ room_alias || room_id }}
+        </div>
     </div>
 
+    <div id="roomPage">
+    <div id="roomWrapper">
+        
     <div id="roomRecentsTableWrapper">
         <div ng-include="'recents/recents.html'"></div>
     </div>
@@ -15,17 +17,17 @@
     <div id="usersTableWrapper">
         <table id="usersTable">
             <tr ng-repeat="member in members | orderMembersList">
-                <td class="userAvatar mouse-pointer" ng-click="goToUserPage(member.id)">
+                <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.jpg'}}" 
+                         ng-src="{{member.avatar_url || 'img/default-profile.png'}}" 
                          alt="{{ member.displayname || member.id.substr(0, member.id.indexOf(':')) }}"
                          title="{{ member.id }}"
                          width="80" height="80"/>
                     <img class="userAvatarGradient" src="img/gradient.png" title="{{ member.id }}" width="80" height="24"/>
                     <div class="userName">{{ member.displayname || member.id.substr(0, member.id.indexOf(':')) }}<br/>{{ member.displayname ? "" : member.id.substr(member.id.indexOf(':')) }}</div>
                 </td>
-                <td class="userPresence" ng-class="member.presenceState === 'online' ? 'online' : (member.presenceState === 'unavailable' ? 'unavailable' : '')">
-                    {{ member.mtime_age | duration }}<br/>{{ member.mtime_age ? "ago" : "" }}
+                <td class="userPresence" ng-class="(member.presenceState === 'online' ? 'online' : (member.presenceState === 'unavailable' ? 'unavailable' : '')) + ' ' + (member.membership == 'invite' ? 'invited' : '')">
+                    <span ng-show="member.mtime_age">{{ member.mtime_age + (now - member.last_updated) | duration }}<br/>ago</span>
                 </td>
         </table>
     </div>
@@ -40,7 +42,7 @@
                     <div class="timestamp">{{ (msg.content.hsob_ts || msg.ts) | date:'MMM d HH:mm' }}</div>
                 </td>
                 <td class="avatar">
-                    <img class="avatarImage" ng-src="{{ members[msg.user_id].avatar_url || 'img/default-profile.jpg' }}" width="32" height="32"
+                    <img class="avatarImage" ng-src="{{ members[msg.user_id].avatar_url || 'img/default-profile.png' }}" width="32" height="32"
                          ng-hide="events.rooms[room_id].messages[$index - 1].user_id === msg.user_id || msg.user_id === state.user_id"/>
                 </td>
                 <td ng-class="!msg.content.membership ? (msg.content.msgtype === 'm.emote' ? 'emote text' : 'text') : 'membership text'">
@@ -64,7 +66,7 @@
                     </div>
                 </td>
                 <td class="rightBlock">
-                    <img class="avatarImage" ng-src="{{ members[msg.user_id].avatar_url || 'img/default-profile.jpg' }}" width="32" height="32"
+                    <img class="avatarImage" ng-src="{{ members[msg.user_id].avatar_url || 'img/default-profile.png' }}" width="32" height="32"
                          ng-hide="events.rooms[room_id].messages[$index - 1].user_id === msg.user_id || msg.user_id !== state.user_id"/>
                 </td>
             </tr>
@@ -86,12 +88,12 @@
                     </td>
                     <td id="buttonsCell">
                         <button ng-click="send()">Send</button>
-                        <button m-file-input="imageFileToSend">Image</button>
+                        <button m-file-input="imageFileToSend" class="extraControls">Image</button>
                     </td>
                 </tr>
             </table>
 
-            <div id="extraControls">
+            <div class="extraControls">
                 <span>
                    Invite a user: 
                         <input ng-model="userIDToInvite" size="32" type="text" placeholder="User ID (ex:@user:homeserver)"/>     
@@ -105,6 +107,10 @@
                 <button ng-click="hangupCall()">Reject</button>
                 </div>
                 <button ng-click="hangupCall()" ng-show="currentCall && currentCall.state != 'ringing'">Hang up</button>
+                <span ng-show="currentCall.state == 'invite_sent'">Calling...</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'">Call Ended</span>
                 <span style="display: none; ">{{ currentCall.state }}</span>
             </div>
         
diff --git a/webclient/settings/settings-controller.js b/webclient/settings/settings-controller.js
index f7d5e8eb75..dc680ef075 100644
--- a/webclient/settings/settings-controller.js
+++ b/webclient/settings/settings-controller.js
@@ -22,8 +22,38 @@ angular.module('SettingsController', ['matrixService', 'mFileUpload', 'mFileInpu
     $scope.config = matrixService.config();
 
     $scope.profile = {
-        displayName: $scope.config.displayName,
-        avatarUrl: $scope.config.avatarUrl
+        displayName: "",
+        avatarUrl: ""
+    };
+
+    // The profile as stored on the server
+    $scope.profileOnServer = {
+        displayName: "",
+        avatarUrl: ""
+    };
+
+    $scope.onInit = function() {
+        // Load profile data
+        // Display name
+        matrixService.getDisplayName($scope.config.user_id).then(
+            function(response) {
+                $scope.profile.displayName = response.data.displayname;
+                $scope.profileOnServer.displayName = response.data.displayname;
+            },
+            function(error) {
+                $scope.feedback = "Can't load display name";
+            } 
+        );
+        // Avatar
+        matrixService.getProfilePictureUrl($scope.config.user_id).then(
+            function(response) {
+                $scope.profile.avatarUrl = response.data.avatar_url;
+                $scope.profileOnServer.avatarUrl = response.data.avatar_url;
+            },
+            function(error) {
+                $scope.feedback = "Can't load avatar URL";
+            } 
+        );
     };
 
     $scope.$watch("profile.avatarFile", function(newValue, oldValue) {
@@ -41,10 +71,10 @@ angular.module('SettingsController', ['matrixService', 'mFileUpload', 'mFileInpu
     });
     
     $scope.saveProfile = function() {
-        if ($scope.profile.displayName !== $scope.config.displayName) {
+        if ($scope.profile.displayName !== $scope.profileOnServer.displayName) {
             setDisplayName($scope.profile.displayName);
         }
-        if ($scope.profile.avatarUrl !== $scope.config.avatarUrl) {
+        if ($scope.profile.avatarUrl !== $scope.profileOnServer.avatarUrl) {
             setAvatar($scope.profile.avatarUrl);
         }
     };
@@ -53,11 +83,6 @@ angular.module('SettingsController', ['matrixService', 'mFileUpload', 'mFileInpu
         matrixService.setDisplayName(displayName).then(
             function(response) {
                 $scope.feedback = "Updated display name.";
-                
-                var config = matrixService.config();
-                config.displayName = displayName;
-                matrixService.setConfig(config);
-                matrixService.saveConfig();
             },
             function(error) {
                 $scope.feedback = "Can't update display name: " + error.data;
@@ -71,11 +96,6 @@ angular.module('SettingsController', ['matrixService', 'mFileUpload', 'mFileInpu
             function(response) {
                 console.log("Updated avatar");
                 $scope.feedback = "Updated avatar.";
-                
-                var config = matrixService.config();
-                config.avatarUrl = avatarURL;
-                matrixService.setConfig(config);
-                matrixService.saveConfig();
             },
             function(error) {
                 $scope.feedback = "Can't update avatar: " + error.data;
@@ -143,4 +163,23 @@ angular.module('SettingsController', ['matrixService', 'mFileUpload', 'mFileInpu
             }
         );
     };
+    
+    
+    /*** Desktop notifications section ***/
+    $scope.settings = {
+        notifications: undefined
+    };
+
+    // If the browser supports it, check the desktop notification state
+    if ("Notification" in window) {
+        $scope.settings.notifications = window.Notification.permission;
+    }
+
+    $scope.requestNotifications = function() {
+        console.log("requestNotifications");
+        window.Notification.requestPermission(function (permission) {
+            console.log("   -> User decision: " + permission);
+            $scope.settings.notifications = permission;
+        });
+    };
 }]);
\ No newline at end of file
diff --git a/webclient/settings/settings.html b/webclient/settings/settings.html
index 453a4fc35f..a69a8de300 100644
--- a/webclient/settings/settings.html
+++ b/webclient/settings/settings.html
@@ -1,35 +1,29 @@
-<div ng-controller="SettingsController" class="user">
+<div ng-controller="SettingsController" class="user" data-ng-init="onInit()">
 
-    <div id="page">
     <div id="wrapper">
-        
-        <h3>Me</h3>
-        <div>
+
+        <div id="genericHeading">
+            <a href ng-click="goToPage('/')"><img src="img/logo-small.png" width="100" height="43" alt="[matrix]"/></a>
+        </div>
+
+        <h1>Settings</h1>
+        <div class="section">
             <form>
-                <table>
-                    <tr>
-                        <td>
-                            <div class="profile-avatar">
-                                <img ng-src="{{ profile.avatarUrl || 'img/default-profile.jpg' }}" m-file-input="profile.avatarFile"/>
-                            </div>
-                        </td>
-                        <td>
-                            <div id="user-ids">
-                                <input size="40" ng-model="profile.displayName" placeholder="Your name"/>            
-                            </div>
-                        </td>
-                        <td>
-                            <button ng-disabled="(profile.displayName == config.displayName) && (profile.avatarUrl == config.avatarUrl)"
-                                    ng-click="saveProfile()">Save</button>    
-                        </td>
-                    </tr>
-                </table>
+                <div class="profile-avatar">
+                    <img ng-src="{{ (null !== profile.avatarUrl) ? profile.avatarUrl : 'img/default-profile.png' }}" m-file-input="profile.avatarFile"/>
+                </div>
+                <div id="user-ids">
+                    <input size="40" ng-model="profile.displayName" placeholder="Your display name"/>
+                    <br/>
+                    <button ng-disabled="(profile.displayName == profileOnServer.displayName) && (profile.avatarUrl == profileOnServer.avatarUrl)"
+                            ng-click="saveProfile()">Save</button>    
+                </div>
             </form>
         </div>
         <br/>
 
         <h3>Linked emails</h3>
-        <div>
+        <div class="section">
             <form>
                 <input size="40" ng-model="linkedEmails.linkNewEmail" ng-enter="linkEmail(linkedEmails.linkNewEmail)" />
                 <button ng-disabled="!linkedEmails.linkNewEmail" ng-click="linkEmail(linkedEmails.linkNewEmail)">
@@ -52,22 +46,35 @@
             </table>
         </div>
         <br/>
-
+        
+        <h3>Desktop notifications</h3>
+        <div class="section" ng-switch="settings.notifications">
+            <div ng-switch-when="granted">
+                Notifications are enabled.
+            </div>
+            <div ng-switch-when="denied">
+                You have denied permission for notifications.<br/>
+                To enable it, reset the notification setting for this web site into your browser settings.
+            </div>
+            <div ng-switch-when="default">
+                <button ng-click="requestNotifications()" style="font-size: 14pt">Enable desktop notifications</button>
+            </div>
+            <div ng-switch-default="">
+                Sorry, your browser does not support notifications.
+            </div>
+        </div>
+        <br/>
+        
         <h3>Configuration</h3>
-        <div>
+        <div class="section">
             <div>Home server: {{ config.homeserver }} </div>
+            <div>Identity server: {{ config.identityServer }} </div>
             <div>User ID: {{ config.user_id }} </div>
             <div>Access token: {{ config.access_token }} </div>
         </div>
         <br/>
-        
-        <div>
-            <div><button ng-click="requestNotifications()">Request notifications</button></div>
-        </div>
-        <br/>
 
         {{ feedback }}
 
     </div>    
-    </div>
 </div>
diff --git a/webclient/user/user-controller.js b/webclient/user/user-controller.js
index 620230561c..b5b2d439a2 100644
--- a/webclient/user/user-controller.js
+++ b/webclient/user/user-controller.js
@@ -25,14 +25,42 @@ angular.module('UserController', ['matrixService'])
         avatar_url: undefined
     };
     
+    $scope.user_id = matrixService.config().user_id;
+    
     matrixService.getDisplayName($scope.user.id).then(
         function(response) {
             $scope.user.displayname = response.data.displayname;
         }
     ); 
+    
     matrixService.getProfilePictureUrl($scope.user.id).then(
         function(response) {
             $scope.user.avatar_url = response.data.avatar_url;
         }
     );
+    
+    $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.user.id).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);
+            });                
+    };
+    
 }]);
\ No newline at end of file
diff --git a/webclient/user/user.html b/webclient/user/user.html
index 4c91c8a48a..2aa981437b 100644
--- a/webclient/user/user.html
+++ b/webclient/user/user.html
@@ -1,31 +1,25 @@
 <div ng-controller="UserController" class="user">
-    <h1 id="logo">[matrix]</h1>
 
-    <div id="page">
     <div id="wrapper">
-        
+
+        <div id="genericHeading">
+            <a href ng-click="goToPage('/')"><img src="img/logo-small.png" width="100" height="43" alt="[matrix]"/></a>
+        </div>
+
+        <h1>{{ user.displayname || user.id }}</h1>
+
         <div>
-            <form>
-                <table>
-                    <tr>
-                        <td>
-                            <div class="profile-avatar">
-                                <img ng-src="{{ user.avatar_url || 'img/default-profile.jpg' }}"/>
-                            </div>
-                        </td>
-                        <td>
-                            <div id="user-ids">
-                                <div id="user-displayname">{{ user.displayname }}</div>
-                                <div>{{ user.id }}</div>                        
-                            </div>
-                        </td>
-                    </tr>
-                </table>
-            </form>
+            <div class="profile-avatar">
+                <img ng-src="{{ user.avatar_url || 'img/default-profile.png' }}"/>
+            </div>
+            <div id="user-ids">
+                <div>{{ user.id }}</div>                        
+            </div>
         </div>
 
+        <button ng-hide="user.id == user_id" ng-click="messageUser()" style="font-size: 14pt; margin-top: 40px; margin-bottom: 40px">Start chat</button>
+        <br/>
         {{ feedback }}
 
-    </div>    
     </div>
 </div>