diff --git a/CHANGES.rst b/CHANGES.rst
index e602ad6f851a37b70229d042fb301b595d0d1fb3..4e536bc4de3914f42e63df4dfb7d94372dc2fccd 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,3 +1,41 @@
+Changes in synapse 0.3.0 (2014-09-18)
+=====================================
+See UPGRADE for information about changes to the client server API, including
+breaking backwards compatibility with VoIP calls and registration API.
+
+Homeserver:
+ * When a user changes their displayname or avatar the server will now update 
+   all their join states to reflect this.
+ * The server now adds "age" key to events to indicate how old they are. This
+   is clock independent, so at no point does any server or webclient have to
+   assume their clock is in sync with everyone else.
+ * Fix bug where we didn't correctly pull in missing PDUs.
+ * Fix bug where prev_content key wasn't always returned.
+ * Add support for password resets.
+
+Webclient:
+ * Improve page content loading.
+ * Join/parts now trigger desktop notifications.
+ * Always show room aliases in the UI if one is present.
+ * No longer show user-count in the recents side panel.
+ * Add up & down arrow support to the text box for message sending to step
+   through your sent history.
+ * Don't display notifications for our own messages.
+ * Emotes are now formatted correctly in desktop notifications.
+ * The recents list now differentiates between public & private rooms.
+ * Fix bug where when switching between rooms the pagination flickered before
+   the view jumped to the bottom of the screen.
+ * Add support for password resets.
+ * Add bing word support.
+
+Registration API:
+ * The registration API has been overhauled to function like the login API. In
+   practice, this means registration requests must now include the following:
+   'type':'m.login.password'. See UPGRADE for more information on this.
+ * The 'user_id' key has been renamed to 'user' to better match the login API.
+ * There is an additional login type: 'm.login.email.identity'.
+ * The command client and web client have been updated to reflect these changes.
+
 Changes in synapse 0.2.3 (2014-09-12)
 =====================================
 
@@ -17,6 +55,9 @@ Webclient:
  * Add glare support for VoIP.
  * Improvements to initial startup speed.
  * Don't display duplicate join events.
+ * Local echo of messages.
+ * Differentiate sending and sent of local echo.
+ * Various minor bug fixes.
 
 Changes in synapse 0.2.2 (2014-09-06)
 =====================================
diff --git a/UPGRADE.rst b/UPGRADE.rst
index da2a7a0a21a114b75d7ec0f13627d225da2bea82..713fb9ae83780d8d7ee33858c414ad71670ad7f4 100644
--- a/UPGRADE.rst
+++ b/UPGRADE.rst
@@ -1,3 +1,34 @@
+Upgrading to v0.3.0
+===================
+
+This registration API now closely matches the login API. This introduces a bit
+more backwards and forwards between the HS and the client, but this improves
+the overall flexibility of the API. You can now GET on /register to retrieve a list
+of valid registration flows. Upon choosing one, they are submitted in the same
+way as login, e.g::
+
+  {
+    type: m.login.password,
+    user: foo,
+    password: bar
+  }
+
+The default HS supports 2 flows, with and without Identity Server email
+authentication. Enabling captcha on the HS will add in an extra step to all
+flows: ``m.login.recaptcha`` which must be completed before you can transition
+to the next stage. There is a new login type: ``m.login.email.identity`` which
+contains the ``threepidCreds`` key which were previously sent in the original
+register request. For more information on this, see the specification.
+
+Web Client
+----------
+
+The VoIP specification has changed between v0.2.0 and v0.3.0. Users should
+refresh any browser tabs to get the latest web client code. Users on
+v0.2.0 of the web client will not be able to call those on v0.3.0 and
+vice versa.
+
+
 Upgrading to v0.2.0
 ===================
 
diff --git a/VERSION b/VERSION
index 7179039691ce07a214e7a815893fee97a97b1422..0d91a54c7d439e84e3dd17d3594f1b2b6737f430 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.2.3
+0.3.0
diff --git a/cmdclient/console.py b/cmdclient/console.py
index 2e6b02676262899b4e4889c8743d5b3895fec1d9..d9c6ec6a70c9001db6f1f3331a3c21208472a932 100755
--- a/cmdclient/console.py
+++ b/cmdclient/console.py
@@ -145,35 +145,50 @@ class SynapseCmd(cmd.Cmd):
         <noupdate> : Do not automatically clobber config values.
         """
         args = self._parse(line, ["userid", "noupdate"])
-        path = "/register"
 
         password = None
         pwd = None
         pwd2 = "_"
         while pwd != pwd2:
-            pwd = getpass.getpass("(Optional) Type a password for this user: ")
-            if len(pwd) == 0:
-                print "Not using a password for this user."
-                break
+            pwd = getpass.getpass("Type a password for this user: ")
             pwd2 = getpass.getpass("Retype the password: ")
-            if pwd != pwd2:
+            if pwd != pwd2 or len(pwd) == 0:
                 print "Password mismatch."
+                pwd = None
             else:
                 password = pwd
 
-        body = {}
+        body = {
+            "type": "m.login.password"
+        }
         if "userid" in args:
-            body["user_id"] = args["userid"]
+            body["user"] = args["userid"]
         if password:
             body["password"] = password
 
-        reactor.callFromThread(self._do_register, "POST", path, body,
+        reactor.callFromThread(self._do_register, body,
                                "noupdate" not in args)
 
     @defer.inlineCallbacks
-    def _do_register(self, method, path, data, update_config):
-        url = self._url() + path
-        json_res = yield self.http_client.do_request(method, url, data=data)
+    def _do_register(self, data, update_config):
+        # check the registration flows
+        url = self._url() + "/register"
+        json_res = yield self.http_client.do_request("GET", url)
+        print json.dumps(json_res, indent=4)
+
+        passwordFlow = None
+        for flow in json_res["flows"]:
+            if flow["type"] == "m.login.recaptcha" or ("stages" in flow and "m.login.recaptcha" in flow["stages"]):
+                print "Unable to register: Home server requires captcha."
+                return
+            if flow["type"] == "m.login.password" and "stages" not in flow:
+                passwordFlow = flow
+                break
+
+        if not passwordFlow:
+            return
+
+        json_res = yield self.http_client.do_request("POST", url, data=data)
         print json.dumps(json_res, indent=4)
         if update_config and "user_id" in json_res:
             self.config["user"] = json_res["user_id"]
diff --git a/docs/client-server/swagger_matrix/api-docs-registration b/docs/client-server/swagger_matrix/api-docs-registration
index f4669ea2f0fb126fa7c154c761764aa57bef7c82..11c170c3ecbd269694716c5943dfaa61d1053ace 100644
--- a/docs/client-server/swagger_matrix/api-docs-registration
+++ b/docs/client-server/swagger_matrix/api-docs-registration
@@ -3,35 +3,38 @@
   "apis": [
     {
       "operations": [
+        {
+          "method": "GET", 
+          "nickname": "get_registration_info", 
+          "notes": "All login stages MUST be mentioned if there is >1 login type.", 
+          "summary": "Get the login mechanism to use when registering.", 
+          "type": "RegistrationFlows"
+        }, 
         {
           "method": "POST", 
-          "nickname": "register", 
-          "notes": "Volatile: This API is likely to change.", 
+          "nickname": "submit_registration", 
+          "notes": "If this is part of a multi-stage registration, there MUST be a 'session' key.", 
           "parameters": [
             {
-              "description": "A registration request", 
+              "description": "A registration submission", 
               "name": "body", 
               "paramType": "body", 
               "required": true, 
-              "type": "RegistrationRequest"
+              "type": "RegistrationSubmission"
             }
           ], 
           "responseMessages": [
             {
               "code": 400, 
-              "message": "No JSON object."
+              "message": "Bad login type"
             }, 
             {
               "code": 400, 
-              "message": "User ID must only contain characters which do not require url encoding."
-            },
-            {
-              "code": 400, 
-              "message": "User ID already taken."
+              "message": "Missing JSON keys"
             }
           ], 
-          "summary": "Register with the home server.", 
-          "type": "RegistrationResponse"
+          "summary": "Submit a registration action.", 
+          "type": "RegistrationResult"
         }
       ], 
       "path": "/register"
@@ -42,30 +45,68 @@
     "application/json"
   ], 
   "models": {
-    "RegistrationResponse": {
-      "id": "RegistrationResponse", 
+    "RegistrationFlows": {
+      "id": "RegistrationFlows",
+      "properties": {
+        "flows": {
+          "description": "A list of valid registration flows.",
+          "type": "array",
+          "items": {
+            "$ref": "RegistrationInfo"
+          }
+        }
+      }
+    },
+    "RegistrationInfo": {
+      "id": "RegistrationInfo", 
+      "properties": {
+        "stages": {
+          "description": "Multi-stage registration only: An array of all the login types required to registration.", 
+          "items": {
+            "$ref": "string"
+          }, 
+          "type": "array"
+        }, 
+        "type": {
+          "description": "The first login type that must be used when logging in.", 
+          "type": "string"
+        }
+      }
+    }, 
+    "RegistrationResult": {
+      "id": "RegistrationResult", 
       "properties": {
         "access_token": {
-          "description": "The access token for this user.", 
+          "description": "The access token for this user's registration if this is the final stage of the registration process.", 
           "type": "string"
-        }, 
+        },
         "user_id": {
-          "description": "The fully-qualified user ID.", 
+          "description": "The user's fully-qualified user ID.",
+          "type": "string"
+        }, 
+        "next": {
+          "description": "Multi-stage registration only: The next registration type to submit.", 
           "type": "string"
         },
-        "home_server": {
-          "description": "The name of the home server.",
+        "session": {
+          "description": "Multi-stage registration only: The session token to send when submitting the next registration type.",
           "type": "string"
         }
       }
     }, 
-    "RegistrationRequest": {
-      "id": "RegistrationRequest", 
+    "RegistrationSubmission": {
+      "id": "RegistrationSubmission", 
       "properties": {
-        "user_id": {
-          "description": "The desired user ID. If not specified, a random user ID will be allocated.", 
-          "type": "string",
-          "required": false
+        "type": {
+          "description": "The type of registration being submitted.", 
+          "type": "string"
+        },
+        "session": {
+          "description": "Multi-stage registration only: The session token from an earlier registration stage.",
+          "type": "string"
+        },
+        "_registration_type_defined_keys_": {
+          "description": "Keys as defined by the specified registration type, e.g. \"user\", \"password\""
         }
       }
     }
diff --git a/docs/specification.rst b/docs/specification.rst
index aad200c04d86f6923434369a8bae46ff5b219ad7..370e238e00514ab12b89c719a144657f59c0557b 100644
--- a/docs/specification.rst
+++ b/docs/specification.rst
@@ -1169,8 +1169,14 @@ This event is sent by the caller when they wish to establish a call.
   Required keys:
     - ``call_id`` : "string" - A unique identifier for the call
     - ``offer`` : "offer object" - The session description
-    - ``version`` : "integer" - The version of the VoIP specification this message
-                                adheres to. This specification is version 0.
+    - ``version`` : "integer" - The version of the VoIP specification this
+                                message adheres to. This specification is
+                                version 0.
+    - ``lifetime`` : "integer" - The time in milliseconds that the invite is
+                                 valid for. Once the invite age exceeds this
+                                 value, clients should discard it. They
+                                 should also no longer show the call as
+                                 awaiting an answer in the UI.
       
   Optional keys:
     None.
@@ -1182,16 +1188,16 @@ This event is sent by the caller when they wish to establish a call.
     - ``type`` : "string" - The type of session description, in this case 'offer'
     - ``sdp`` : "string" - The SDP text of the session description
 
-``m.call.candidate``
+``m.call.candidates``
 This event is sent by callers after sending an invite and by the callee after answering.
-Its purpose is to give the other party an additional ICE candidate to try using to
+Its purpose is to give the other party additional ICE candidates to try using to
 communicate.
 
   Required keys:
     - ``call_id`` : "string" - The ID of the call this event relates to
     - ``version`` : "integer" - The version of the VoIP specification this messages
                                 adheres to. his specification is version 0.
-    - ``candidate`` : "candidate object" - Object describing the candidate.
+    - ``candidates`` : "array of candidate objects" - Array of object describing the candidates.
 
 ``Candidate Object``
 
@@ -1305,12 +1311,6 @@ display name other than it being a valid unicode string.
 
 Registration and login
 ======================
-.. WARNING::
-  The registration API is likely to change.
-
-.. TODO
-  - TODO Kegan : Make registration like login (just omit the "user" key on the 
-    initial request?)
 
 Clients must register with a home server in order to use Matrix. After 
 registering, the client will be given an access token which must be used in ALL
@@ -1323,9 +1323,11 @@ a token sent to their email address, etc. This specification does not define how
 home servers should authorise their users who want to login to their existing 
 accounts, but instead defines the standard interface which implementations 
 should follow so that ANY client can login to ANY home server. Clients login
-using the |login|_ API.
+using the |login|_ API. Clients register using the |register|_ API. Registration
+follows the same procedure as login, but the path requests are sent to are
+different.
 
-The login process breaks down into the following:
+The registration/login process breaks down into the following:
   1. Determine the requirements for logging in.
   2. Submit the login stage credentials.
   3. Get credentials or be told the next stage in the login process and repeat 
@@ -1383,7 +1385,7 @@ This specification defines the following login types:
  - ``m.login.oauth2``
  - ``m.login.email.code``
  - ``m.login.email.url``
-
+ - ``m.login.email.identity``
 
 Password-based
 --------------
@@ -1531,6 +1533,31 @@ If the link has not been visited yet, a standard error response with an errcode
 ``M_LOGIN_EMAIL_URL_NOT_YET`` should be returned.
 
 
+Email-based (identity server)
+-----------------------------
+:Type:
+  ``m.login.email.identity``
+:Description:
+  Login is supported by authorising an email address with an identity server.
+
+Prior to submitting this, the client should authenticate with an identity server.
+After authenticating, the session information should be submitted to the home server.
+
+To respond to this type, reply with::
+
+  {
+    "type": "m.login.email.identity",
+    "threepidCreds": [
+      {
+        "sid": "<identity server session id>",
+        "clientSecret": "<identity server client secret>",
+        "idServer": "<url of identity server authed with, e.g. 'matrix.org:8090'>"
+      }
+    ]
+  }
+
+
+
 N-Factor Authentication
 -----------------------
 Multiple login stages can be combined to create N-factor authentication during login.
@@ -2242,6 +2269,9 @@ Transaction:
 .. |login| replace:: ``/login``
 .. _login: /docs/api/client-server/#!/-login
 
+.. |register| replace:: ``/register``
+.. _register: /docs/api/client-server/#!/-registration
+
 .. |/rooms/<room_id>/messages| replace:: ``/rooms/<room_id>/messages``
 .. _/rooms/<room_id>/messages: /docs/api/client-server/#!/-rooms/get_messages
 
diff --git a/synapse/__init__.py b/synapse/__init__.py
index d60267ebe47530810f657bb4ff450a8365b01863..8ef176ea6f743d9639d2e0cc863e7b5dd51ea313 100644
--- a/synapse/__init__.py
+++ b/synapse/__init__.py
@@ -16,4 +16,4 @@
 """ This is a reference implementation of a synapse home server.
 """
 
-__version__ = "0.2.3"
+__version__ = "0.3.0"
diff --git a/synapse/api/constants.py b/synapse/api/constants.py
index fcef062fc9cfe9b29f506e374beffb22d6e1c92a..618d3d757742c05d390c4688441c0b702e832ca3 100644
--- a/synapse/api/constants.py
+++ b/synapse/api/constants.py
@@ -50,3 +50,12 @@ class JoinRules(object):
     KNOCK = u"knock"
     INVITE = u"invite"
     PRIVATE = u"private"
+
+
+class LoginType(object):
+    PASSWORD = u"m.login.password"
+    OAUTH = u"m.login.oauth2"
+    EMAIL_CODE = u"m.login.email.code"
+    EMAIL_URL = u"m.login.email.url"
+    EMAIL_IDENTITY = u"m.login.email.identity"
+    RECAPTCHA = u"m.login.recaptcha"
\ No newline at end of file
diff --git a/synapse/api/events/__init__.py b/synapse/api/events/__init__.py
index 5f300de1086cf25d411e7cd0a1dd53cb2b067a67..a9991e9c9481c23ec7363e047a32de2355439d4d 100644
--- a/synapse/api/events/__init__.py
+++ b/synapse/api/events/__init__.py
@@ -17,6 +17,19 @@ from synapse.api.errors import SynapseError, Codes
 from synapse.util.jsonobject import JsonEncodedObject
 
 
+def serialize_event(hs, e):
+    # FIXME(erikj): To handle the case of presence events and the like
+    if not isinstance(e, SynapseEvent):
+        return e
+
+    d = e.get_dict()
+    if "age_ts" in d:
+        d["age"] = int(hs.get_clock().time_msec()) - d["age_ts"]
+        del d["age_ts"]
+
+    return d
+
+
 class SynapseEvent(JsonEncodedObject):
 
     """Base class for Synapse events. These are JSON objects which must abide
@@ -43,6 +56,8 @@ class SynapseEvent(JsonEncodedObject):
         "content",  # HTTP body, JSON
         "state_key",
         "required_power_level",
+        "age_ts",
+        "prev_content",
     ]
 
     internal_keys = [
@@ -158,10 +173,6 @@ class SynapseEvent(JsonEncodedObject):
 
 class SynapseStateEvent(SynapseEvent):
 
-    valid_keys = SynapseEvent.valid_keys + [
-        "prev_content",
-    ]
-
     def __init__(self, **kwargs):
         if "state_key" not in kwargs:
             kwargs["state_key"] = ""
diff --git a/synapse/api/events/factory.py b/synapse/api/events/factory.py
index 5e38cdbc44bd66b81771422693ea1f425842c4d3..d3d96d73eb427afd8fb906d648b9924b9988da96 100644
--- a/synapse/api/events/factory.py
+++ b/synapse/api/events/factory.py
@@ -59,6 +59,14 @@ class EventFactory(object):
         if "ts" not in kwargs:
             kwargs["ts"] = int(self.clock.time_msec())
 
+        # The "age" key is a delta timestamp that should be converted into an
+        # absolute timestamp the minute we see it.
+        if "age" in kwargs:
+            kwargs["age_ts"] = int(self.clock.time_msec()) - int(kwargs["age"])
+            del kwargs["age"]
+        elif "age_ts" not in kwargs:
+            kwargs["age_ts"] = int(self.clock.time_msec())
+
         if etype in self._event_list:
             handler = self._event_list[etype]
         else:
diff --git a/synapse/config/email.py b/synapse/config/email.py
new file mode 100644
index 0000000000000000000000000000000000000000..9bcc5a8fea55c320b396578c48d4d71b2bddd32a
--- /dev/null
+++ b/synapse/config/email.py
@@ -0,0 +1,39 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014 OpenMarket Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from ._base import Config
+
+
+class EmailConfig(Config):
+
+    def __init__(self, args):
+        super(EmailConfig, self).__init__(args)
+        self.email_from_address = args.email_from_address
+        self.email_smtp_server = args.email_smtp_server
+
+    @classmethod
+    def add_arguments(cls, parser):
+        super(EmailConfig, cls).add_arguments(parser)
+        email_group = parser.add_argument_group("email")
+        email_group.add_argument(
+            "--email-from-address",
+            default="FROM@EXAMPLE.COM",
+            help="The address to send emails from (e.g. for password resets)."
+        )
+        email_group.add_argument(
+            "--email-smtp-server",
+            default="",
+            help="The SMTP server to send emails from (e.g. for password resets)."
+        )
\ No newline at end of file
diff --git a/synapse/config/homeserver.py b/synapse/config/homeserver.py
index e16f2c733b99751ef342d6dcd700724599337e22..4b810a2302f98cd27aa6073a392cb5400e6c0c41 100644
--- a/synapse/config/homeserver.py
+++ b/synapse/config/homeserver.py
@@ -20,11 +20,15 @@ from .database import DatabaseConfig
 from .ratelimiting import RatelimitConfig
 from .repository import ContentRepositoryConfig
 from .captcha import CaptchaConfig
+from .email import EmailConfig
+
 
 class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig,
-                       RatelimitConfig, ContentRepositoryConfig, CaptchaConfig):
+                       RatelimitConfig, ContentRepositoryConfig, CaptchaConfig,
+                       EmailConfig):
     pass
 
-if __name__=='__main__':
+
+if __name__ == '__main__':
     import sys
     HomeServerConfig.load_config("Generate config", sys.argv[1:], "HomeServer")
diff --git a/synapse/federation/replication.py b/synapse/federation/replication.py
index e12510017f6d299b7be73c6310540a7136bc1535..96b82f00cb9b0298d39accccb69550fbc23daf32 100644
--- a/synapse/federation/replication.py
+++ b/synapse/federation/replication.py
@@ -291,6 +291,13 @@ class ReplicationLayer(object):
     def on_incoming_transaction(self, transaction_data):
         transaction = Transaction(**transaction_data)
 
+        for p in transaction.pdus:
+            if "age" in p:
+                p["age_ts"] = int(self._clock.time_msec()) - int(p["age"])
+                del p["age"]
+
+        pdu_list = [Pdu(**p) for p in transaction.pdus]
+
         logger.debug("[%s] Got transaction", transaction.transaction_id)
 
         response = yield self.transaction_actions.have_responded(transaction)
@@ -303,8 +310,6 @@ class ReplicationLayer(object):
 
         logger.debug("[%s] Transacition is new", transaction.transaction_id)
 
-        pdu_list = [Pdu(**p) for p in transaction.pdus]
-
         dl = []
         for pdu in pdu_list:
             dl.append(self._handle_new_pdu(pdu))
@@ -405,9 +410,14 @@ class ReplicationLayer(object):
         """Returns a new Transaction containing the given PDUs suitable for
         transmission.
         """
+        pdus = [p.get_dict() for p in pdu_list]
+        for p in pdus:
+            if "age_ts" in pdus:
+                p["age"] = int(self.clock.time_msec()) - p["age_ts"]
+
         return Transaction(
-            pdus=[p.get_dict() for p in pdu_list],
             origin=self.server_name,
+            pdus=pdus,
             ts=int(self._clock.time_msec()),
             destination=None,
         )
@@ -593,8 +603,21 @@ class _TransactionQueue(object):
             logger.debug("TX [%s] Sending transaction...", destination)
 
             # Actually send the transaction
+
+            # FIXME (erikj): This is a bit of a hack to make the Pdu age
+            # keys work
+            def cb(transaction):
+                now = int(self._clock.time_msec())
+                if "pdus" in transaction:
+                    for p in transaction["pdus"]:
+                        if "age_ts" in p:
+                            p["age"] = now - int(p["age_ts"])
+
+                return transaction
+
             code, response = yield self.transport_layer.send_transaction(
-                transaction
+                transaction,
+                on_send_callback=cb,
             )
 
             logger.debug("TX [%s] Sent transaction", destination)
diff --git a/synapse/federation/transport.py b/synapse/federation/transport.py
index 6e62ae7c747d27f79cbffc8fb679dbf8af35417a..afc777ec9edc23705e48a336e175549cfd1a5fcf 100644
--- a/synapse/federation/transport.py
+++ b/synapse/federation/transport.py
@@ -144,7 +144,7 @@ class TransportLayer(object):
 
     @defer.inlineCallbacks
     @log_function
-    def send_transaction(self, transaction):
+    def send_transaction(self, transaction, on_send_callback=None):
         """ Sends the given Transaction to it's destination
 
         Args:
@@ -165,10 +165,23 @@ class TransportLayer(object):
 
         data = transaction.get_dict()
 
+        # FIXME (erikj): This is a bit of a hack to make the Pdu age
+        # keys work
+        def cb(destination, method, path_bytes, producer):
+            if not on_send_callback:
+                return
+
+            transaction = json.loads(producer.body)
+
+            new_transaction = on_send_callback(transaction)
+
+            producer.reset(new_transaction)
+
         code, response = yield self.client.put_json(
             transaction.destination,
             path=PREFIX + "/send/%s/" % transaction.transaction_id,
-            data=data
+            data=data,
+            on_send_callback=cb,
         )
 
         logger.debug(
diff --git a/synapse/handlers/events.py b/synapse/handlers/events.py
index fd24a11fb88ae1a00067f70d9f68dd964d0a301c..93dcd40324d8f5ddadc0eaba143f241d1f8de8f9 100644
--- a/synapse/handlers/events.py
+++ b/synapse/handlers/events.py
@@ -15,7 +15,6 @@
 
 from twisted.internet import defer
 
-from synapse.api.events import SynapseEvent
 from synapse.util.logutils import log_function
 
 from ._base import BaseHandler
@@ -71,10 +70,7 @@ class EventStreamHandler(BaseHandler):
                 auth_user, room_ids, pagin_config, timeout
             )
 
-            chunks = [
-                e.get_dict() if isinstance(e, SynapseEvent) else e
-                for e in events
-            ]
+            chunks = [self.hs.serialize_event(e) for e in events]
 
             chunk = {
                 "chunk": chunks,
@@ -92,7 +88,9 @@ class EventStreamHandler(BaseHandler):
                 # 10 seconds of grace to allow the client to reconnect again
                 #   before we think they're gone
                 def _later():
-                    logger.debug("_later stopped_user_eventstream %s", auth_user)
+                    logger.debug(
+                        "_later stopped_user_eventstream %s", auth_user
+                    )
                     self.distributor.fire(
                         "stopped_user_eventstream", auth_user
                     )
diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py
index 59cbf71d78af672251d9e3bd24501cc30b499124..001c6c110c18b0802e2ccb7a37b5b1234425cf38 100644
--- a/synapse/handlers/federation.py
+++ b/synapse/handlers/federation.py
@@ -93,22 +93,18 @@ class FederationHandler(BaseHandler):
         """
         event = self.pdu_codec.event_from_pdu(pdu)
 
+        logger.debug("Got event: %s", event.event_id)
+
         with (yield self.lock_manager.lock(pdu.context)):
             if event.is_state and not backfilled:
                 is_new_state = yield self.state_handler.handle_new_state(
                     pdu
                 )
-                if not is_new_state:
-                    return
             else:
                 is_new_state = False
         # TODO: Implement something in federation that allows us to
         # respond to PDU.
 
-        if hasattr(event, "state_key") and not is_new_state:
-            logger.debug("Ignoring old state.")
-            return
-
         target_is_mine = False
         if hasattr(event, "target_host"):
             target_is_mine = event.target_host == self.hs.hostname
@@ -139,7 +135,11 @@ class FederationHandler(BaseHandler):
 
         else:
             with (yield self.room_lock.lock(event.room_id)):
-                yield self.store.persist_event(event, backfilled)
+                yield self.store.persist_event(
+                    event,
+                    backfilled,
+                    is_new_state=is_new_state
+                )
 
             room = yield self.store.get_room(event.room_id)
 
diff --git a/synapse/handlers/login.py b/synapse/handlers/login.py
index 6ee7ce5a2ddba35fc9a041918e0e051fe41fe5dd..80ffdd27264a1ab4de12d21d40d5084ba14a0ce0 100644
--- a/synapse/handlers/login.py
+++ b/synapse/handlers/login.py
@@ -17,9 +17,13 @@ from twisted.internet import defer
 
 from ._base import BaseHandler
 from synapse.api.errors import LoginError, Codes
+from synapse.http.client import PlainHttpClient
+from synapse.util.emailutils import EmailException
+import synapse.util.emailutils as emailutils
 
 import bcrypt
 import logging
+import urllib
 
 logger = logging.getLogger(__name__)
 
@@ -62,4 +66,41 @@ class LoginHandler(BaseHandler):
             defer.returnValue(token)
         else:
             logger.warn("Failed password login for user %s", user)
-            raise LoginError(403, "", errcode=Codes.FORBIDDEN)
\ No newline at end of file
+            raise LoginError(403, "", errcode=Codes.FORBIDDEN)
+
+    @defer.inlineCallbacks
+    def reset_password(self, user_id, email):
+        is_valid = yield self._check_valid_association(user_id, email)
+        logger.info("reset_password user=%s email=%s valid=%s", user_id, email,
+                    is_valid)
+        if is_valid:
+            try:
+                # send an email out
+                emailutils.send_email(
+                    smtp_server=self.hs.config.email_smtp_server,
+                    from_addr=self.hs.config.email_from_address,
+                    to_addr=email,
+                    subject="Password Reset",
+                    body="TODO."
+                )
+            except EmailException as e:
+                logger.exception(e)
+
+    @defer.inlineCallbacks
+    def _check_valid_association(self, user_id, email):
+        identity = yield self._query_email(email)
+        if identity and "mxid" in identity:
+            if identity["mxid"] == user_id:
+                defer.returnValue(True)
+                return
+        defer.returnValue(False)
+
+    @defer.inlineCallbacks
+    def _query_email(self, email):
+        httpCli = PlainHttpClient(self.hs)
+        data = yield httpCli.get_json(
+            'matrix.org:8090',  # TODO FIXME This should be configurable.
+            "/_matrix/identity/api/v1/lookup?medium=email&address=" +
+            "%s" % urllib.quote(email)
+        )
+        defer.returnValue(data)
\ No newline at end of file
diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py
index 87fc04478b92d006e78707d01d4a31655547aa7b..14fae689f25b29d64c524b8ba9656ad36e6f02e2 100644
--- a/synapse/handlers/message.py
+++ b/synapse/handlers/message.py
@@ -124,7 +124,7 @@ class MessageHandler(BaseHandler):
         )
 
         chunk = {
-            "chunk": [e.get_dict() for e in events],
+            "chunk": [self.hs.serialize_event(e) for e in events],
             "start": pagin_config.from_token.to_string(),
             "end": next_token.to_string(),
         }
@@ -268,6 +268,9 @@ class MessageHandler(BaseHandler):
             user, pagination_config, None
         )
 
+        public_rooms = yield self.store.get_rooms(is_public=True)
+        public_room_ids = [r["room_id"] for r in public_rooms]
+
         limit = pagin_config.limit
         if not limit:
             limit = 10
@@ -276,6 +279,8 @@ class MessageHandler(BaseHandler):
             d = {
                 "room_id": event.room_id,
                 "membership": event.membership,
+                "visibility": ("public" if event.room_id in
+                              public_room_ids else "private"),
             }
 
             if event.membership == Membership.INVITE:
@@ -296,7 +301,7 @@ class MessageHandler(BaseHandler):
                 end_token = now_token.copy_and_replace("room_key", token[1])
 
                 d["messages"] = {
-                    "chunk": [m.get_dict() for m in messages],
+                    "chunk": [self.hs.serialize_event(m) for m in messages],
                     "start": start_token.to_string(),
                     "end": end_token.to_string(),
                 }
@@ -304,7 +309,7 @@ class MessageHandler(BaseHandler):
                 current_state = yield self.store.get_current_state(
                     event.room_id
                 )
-                d["state"] = [c.get_dict() for c in current_state]
+                d["state"] = [self.hs.serialize_event(c) for c in current_state]
             except:
                 logger.exception("Failed to get snapshot")
 
diff --git a/synapse/handlers/profile.py b/synapse/handlers/profile.py
index 023d8c0cf2ccb1c34e11d37fcfed02c710539c33..dab9b03f045ba85081acb05118c2905ee7813980 100644
--- a/synapse/handlers/profile.py
+++ b/synapse/handlers/profile.py
@@ -15,9 +15,9 @@
 
 from twisted.internet import defer
 
-from synapse.api.errors import SynapseError, AuthError
-
-from synapse.api.errors import CodeMessageException
+from synapse.api.errors import SynapseError, AuthError, CodeMessageException
+from synapse.api.constants import Membership
+from synapse.api.events.room import RoomMemberEvent
 
 from ._base import BaseHandler
 
@@ -97,6 +97,8 @@ class ProfileHandler(BaseHandler):
             }
         )
 
+        yield self._update_join_states(target_user)
+
     @defer.inlineCallbacks
     def get_avatar_url(self, target_user):
         if target_user.is_mine:
@@ -144,6 +146,8 @@ class ProfileHandler(BaseHandler):
             }
         )
 
+        yield self._update_join_states(target_user)
+
     @defer.inlineCallbacks
     def collect_presencelike_data(self, user, state):
         if not user.is_mine:
@@ -180,3 +184,39 @@ class ProfileHandler(BaseHandler):
             )
 
         defer.returnValue(response)
+
+    @defer.inlineCallbacks
+    def _update_join_states(self, user):
+        if not user.is_mine:
+            return
+
+        joins = yield self.store.get_rooms_for_user_where_membership_is(
+            user.to_string(),
+            [Membership.JOIN],
+        )
+
+        for j in joins:
+            snapshot = yield self.store.snapshot_room(
+                j.room_id, j.state_key, RoomMemberEvent.TYPE,
+                j.state_key
+            )
+
+            content = {
+                "membership": j.content["membership"],
+                "prev": j.content["membership"],
+            }
+
+            yield self.distributor.fire(
+                "collect_presencelike_data", user, content
+            )
+
+            new_event = self.event_factory.create_event(
+                etype=j.type,
+                room_id=j.room_id,
+                state_key=j.state_key,
+                content=content,
+                user_id=j.state_key,
+            )
+
+            yield self.state_handler.handle_new_event(new_event, snapshot)
+            yield self._on_new_room_event(new_event, snapshot)
diff --git a/synapse/handlers/register.py b/synapse/handlers/register.py
index 0b841d6d3af35e8c77fbcfc326593cb313b25bd8..a019d770d4e5f73748ab24125b2218acc82abbe7 100644
--- a/synapse/handlers/register.py
+++ b/synapse/handlers/register.py
@@ -40,8 +40,7 @@ class RegistrationHandler(BaseHandler):
         self.distributor.declare("registered_user")
 
     @defer.inlineCallbacks
-    def register(self, localpart=None, password=None, threepidCreds=None, 
-                 captcha_info={}):
+    def register(self, localpart=None, password=None):
         """Registers a new client on the server.
 
         Args:
@@ -54,37 +53,6 @@ class RegistrationHandler(BaseHandler):
         Raises:
             RegistrationError if there was a problem registering.
         """
-        if captcha_info:
-            captcha_response = yield self._validate_captcha(
-                captcha_info["ip"], 
-                captcha_info["private_key"],
-                captcha_info["challenge"],
-                captcha_info["response"]
-            )
-            if not captcha_response["valid"]:
-                logger.info("Invalid captcha entered from %s. Error: %s", 
-                            captcha_info["ip"], captcha_response["error_url"])
-                raise InvalidCaptchaError(
-                    error_url=captcha_response["error_url"]
-                )
-            else:
-                logger.info("Valid captcha entered from %s", captcha_info["ip"])
-
-        if threepidCreds:
-            for c in threepidCreds:
-                logger.info("validating theeepidcred sid %s on id server %s",
-                            c['sid'], c['idServer'])
-                try:
-                    threepid = yield self._threepid_from_creds(c)
-                except:
-                    logger.err()
-                    raise RegistrationError(400, "Couldn't validate 3pid")
-                    
-                if not threepid:
-                    raise RegistrationError(400, "Couldn't validate 3pid")
-                logger.info("got threepid medium %s address %s", 
-                            threepid['medium'], threepid['address'])
-
         password_hash = None
         if password:
             password_hash = bcrypt.hashpw(password, bcrypt.gensalt())
@@ -126,15 +94,54 @@ class RegistrationHandler(BaseHandler):
                         raise RegistrationError(
                             500, "Cannot generate user ID.")
 
-        # Now we have a matrix ID, bind it to the threepids we were given
-        if threepidCreds:
-            for c in threepidCreds:
-                # XXX: This should be a deferred list, shouldn't it?
-                yield self._bind_threepid(c, user_id)
-                
-
         defer.returnValue((user_id, token))
 
+    @defer.inlineCallbacks
+    def check_recaptcha(self, ip, private_key, challenge, response):
+        """Checks a recaptcha is correct."""
+
+        captcha_response = yield self._validate_captcha(
+            ip,
+            private_key,
+            challenge,
+            response
+        )
+        if not captcha_response["valid"]:
+            logger.info("Invalid captcha entered from %s. Error: %s",
+                        ip, captcha_response["error_url"])
+            raise InvalidCaptchaError(
+                error_url=captcha_response["error_url"]
+            )
+        else:
+            logger.info("Valid captcha entered from %s", ip)
+
+    @defer.inlineCallbacks
+    def register_email(self, threepidCreds):
+        """Registers emails with an identity server."""
+
+        for c in threepidCreds:
+            logger.info("validating theeepidcred sid %s on id server %s",
+                        c['sid'], c['idServer'])
+            try:
+                threepid = yield self._threepid_from_creds(c)
+            except:
+                logger.err()
+                raise RegistrationError(400, "Couldn't validate 3pid")
+
+            if not threepid:
+                raise RegistrationError(400, "Couldn't validate 3pid")
+            logger.info("got threepid medium %s address %s",
+                        threepid['medium'], threepid['address'])
+
+    @defer.inlineCallbacks
+    def bind_emails(self, user_id, threepidCreds):
+        """Links emails with a user ID and informs an identity server."""
+
+        # Now we have a matrix ID, bind it to the threepids we were given
+        for c in threepidCreds:
+            # XXX: This should be a deferred list, shouldn't it?
+            yield self._bind_threepid(c, user_id)
+
     def _generate_token(self, user_id):
         # urlsafe variant uses _ and - so use . as the separator and replace
         # all =s with .s so http clients don't quote =s when it is used as
@@ -149,17 +156,17 @@ class RegistrationHandler(BaseHandler):
     def _threepid_from_creds(self, creds):
         httpCli = PlainHttpClient(self.hs)
         # XXX: make this configurable!
-        trustedIdServers = [ 'matrix.org:8090' ]
+        trustedIdServers = ['matrix.org:8090']
         if not creds['idServer'] in trustedIdServers:
-            logger.warn('%s is not a trusted ID server: rejecting 3pid '+
+            logger.warn('%s is not a trusted ID server: rejecting 3pid ' +
                         'credentials', creds['idServer'])
             defer.returnValue(None)
         data = yield httpCli.get_json(
             creds['idServer'],
             "/_matrix/identity/api/v1/3pid/getValidated3pid",
-            { 'sid': creds['sid'], 'clientSecret': creds['clientSecret'] }
+            {'sid': creds['sid'], 'clientSecret': creds['clientSecret']}
         )
-        
+
         if 'medium' in data:
             defer.returnValue(data)
         defer.returnValue(None)
@@ -170,44 +177,45 @@ class RegistrationHandler(BaseHandler):
         data = yield httpCli.post_urlencoded_get_json(
             creds['idServer'],
             "/_matrix/identity/api/v1/3pid/bind",
-            { 'sid': creds['sid'], 'clientSecret': creds['clientSecret'], 
-            'mxid':mxid }
+            {'sid': creds['sid'], 'clientSecret': creds['clientSecret'],
+            'mxid': mxid}
         )
         defer.returnValue(data)
-        
+
     @defer.inlineCallbacks
     def _validate_captcha(self, ip_addr, private_key, challenge, response):
         """Validates the captcha provided.
-        
+
         Returns:
             dict: Containing 'valid'(bool) and 'error_url'(str) if invalid.
-        
+
         """
-        response = yield self._submit_captcha(ip_addr, private_key, challenge, 
+        response = yield self._submit_captcha(ip_addr, private_key, challenge,
                                               response)
         # parse Google's response. Lovely format..
         lines = response.split('\n')
         json = {
             "valid": lines[0] == 'true',
-            "error_url": "http://www.google.com/recaptcha/api/challenge?"+
+            "error_url": "http://www.google.com/recaptcha/api/challenge?" +
                          "error=%s" % lines[1]
         }
         defer.returnValue(json)
-        
+
     @defer.inlineCallbacks
     def _submit_captcha(self, ip_addr, private_key, challenge, response):
         client = PlainHttpClient(self.hs)
         data = yield client.post_urlencoded_get_raw(
             "www.google.com:80",
             "/recaptcha/api/verify",
-            accept_partial=True,  # twisted dislikes google's response, no content length.
-            args={ 
-                'privatekey': private_key, 
+            # twisted dislikes google's response, no content length.
+            accept_partial=True,
+            args={
+                'privatekey': private_key,
                 'remoteip': ip_addr,
                 'challenge': challenge,
                 'response': response
             }
         )
         defer.returnValue(data)
-        
+
 
diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py
index 310cb46fe7c5f05880d40dfe41d2e498f8f6a1a3..5bc1280432b17e17b84d4bce612181fba1729955 100644
--- a/synapse/handlers/room.py
+++ b/synapse/handlers/room.py
@@ -335,7 +335,7 @@ class RoomMemberHandler(BaseHandler):
 
         member_list = yield self.store.get_room_members(room_id=room_id)
         event_list = [
-            entry.get_dict()
+            self.hs.serialize_event(entry)
             for entry in member_list
         ]
         chunk_data = {
diff --git a/synapse/http/client.py b/synapse/http/client.py
index ece6318e006073807881526025c1078636dc7c93..eb11bfd4d5bcee4084ae83526a21a8c503287b08 100644
--- a/synapse/http/client.py
+++ b/synapse/http/client.py
@@ -122,7 +122,7 @@ class TwistedHttpClient(HttpClient):
         self.hs = hs
 
     @defer.inlineCallbacks
-    def put_json(self, destination, path, data):
+    def put_json(self, destination, path, data, on_send_callback=None):
         if destination in _destination_mappings:
             destination = _destination_mappings[destination]
 
@@ -131,7 +131,8 @@ class TwistedHttpClient(HttpClient):
             "PUT",
             path.encode("ascii"),
             producer=_JsonProducer(data),
-            headers_dict={"Content-Type": ["application/json"]}
+            headers_dict={"Content-Type": ["application/json"]},
+            on_send_callback=on_send_callback,
         )
 
         logger.debug("Getting resp body")
@@ -218,7 +219,7 @@ class TwistedHttpClient(HttpClient):
     @defer.inlineCallbacks
     def _create_request(self, destination, method, path_bytes, param_bytes=b"",
                         query_bytes=b"", producer=None, headers_dict={},
-                        retry_on_dns_fail=True):
+                        retry_on_dns_fail=True, on_send_callback=None):
         """ Creates and sends a request to the given url
         """
         headers_dict[b"User-Agent"] = [b"Synapse"]
@@ -242,6 +243,9 @@ class TwistedHttpClient(HttpClient):
         endpoint = self._getEndpoint(reactor, destination);
 
         while True:
+            if on_send_callback:
+                on_send_callback(destination, method, path_bytes, producer)
+
             try:
                 response = yield self.agent.request(
                     destination,
@@ -310,6 +314,9 @@ class _JsonProducer(object):
     """ Used by the twisted http client to create the HTTP body from json
     """
     def __init__(self, jsn):
+        self.reset(jsn)
+
+    def reset(self, jsn):
         self.body = encode_canonical_json(jsn)
         self.length = len(self.body)
 
diff --git a/synapse/rest/events.py b/synapse/rest/events.py
index 7fde1432006cc80a625d0048cfb17ff2c549ae1f..097195d7cc3799c9d78c774fb53af7be7e91faa0 100644
--- a/synapse/rest/events.py
+++ b/synapse/rest/events.py
@@ -59,7 +59,7 @@ class EventRestServlet(RestServlet):
         event = yield handler.get_event(auth_user, event_id)
 
         if event:
-            defer.returnValue((200, event.get_dict()))
+            defer.returnValue((200, self.hs.serialize_event(event)))
         else:
             defer.returnValue((404, "Event not found."))
 
diff --git a/synapse/rest/login.py b/synapse/rest/login.py
index ba49afcaa7b35888cdf100d0cc0d7582929fbded..ad71f6c61dfe3c9567751443ebe307fc3a05be47 100644
--- a/synapse/rest/login.py
+++ b/synapse/rest/login.py
@@ -73,6 +73,27 @@ class LoginFallbackRestServlet(RestServlet):
         return (200, {})
 
 
+class PasswordResetRestServlet(RestServlet):
+    PATTERN = client_path_pattern("/login/reset")
+
+    @defer.inlineCallbacks
+    def on_POST(self, request):
+        reset_info = _parse_json(request)
+        try:
+            email = reset_info["email"]
+            user_id = reset_info["user_id"]
+            handler = self.handlers.login_handler
+            yield handler.reset_password(user_id, email)
+            # purposefully give no feedback to avoid people hammering different
+            # combinations.
+            defer.returnValue((200, {}))
+        except KeyError:
+            raise SynapseError(
+                400,
+                "Missing keys. Requires 'email' and 'user_id'."
+            )
+
+
 def _parse_json(request):
     try:
         content = json.loads(request.content.read())
@@ -85,3 +106,4 @@ def _parse_json(request):
 
 def register_servlets(hs, http_server):
     LoginRestServlet(hs).register(http_server)
+    # TODO PasswordResetRestServlet(hs).register(http_server)
diff --git a/synapse/rest/register.py b/synapse/rest/register.py
index 48d3c6eca087926f6f209f8ad95d23c50ff46fe1..af528a44f6c4d061dc25dad0b76a2c6ef0e047da 100644
--- a/synapse/rest/register.py
+++ b/synapse/rest/register.py
@@ -17,89 +17,218 @@
 from twisted.internet import defer
 
 from synapse.api.errors import SynapseError, Codes
+from synapse.api.constants import LoginType
 from base import RestServlet, client_path_pattern
+import synapse.util.stringutils as stringutils
 
 import json
+import logging
 import urllib
 
+logger = logging.getLogger(__name__)
+
 
 class RegisterRestServlet(RestServlet):
+    """Handles registration with the home server.
+
+    This servlet is in control of the registration flow; the registration
+    handler doesn't have a concept of multi-stages or sessions.
+    """
+
     PATTERN = client_path_pattern("/register$")
 
+    def __init__(self, hs):
+        super(RegisterRestServlet, self).__init__(hs)
+        # sessions are stored as:
+        # self.sessions = {
+        #   "session_id" : { __session_dict__ }
+        # }
+        # TODO: persistent storage
+        self.sessions = {}
+
+    def on_GET(self, request):
+        if self.hs.config.enable_registration_captcha:
+            return (200, {
+                "flows": [
+                    {
+                        "type": LoginType.RECAPTCHA,
+                        "stages": ([LoginType.RECAPTCHA,
+                                    LoginType.EMAIL_IDENTITY,
+                                    LoginType.PASSWORD])
+                    },
+                    {
+                        "type": LoginType.RECAPTCHA,
+                        "stages": [LoginType.RECAPTCHA, LoginType.PASSWORD]
+                    }
+                ]
+            })
+        else:
+            return (200, {
+                "flows": [
+                    {
+                        "type": LoginType.EMAIL_IDENTITY,
+                        "stages": ([LoginType.EMAIL_IDENTITY,
+                                    LoginType.PASSWORD])
+                    },
+                    {
+                        "type": LoginType.PASSWORD
+                    }
+                ]
+            })
+
     @defer.inlineCallbacks
     def on_POST(self, request):
-        desired_user_id = None
-        password = None
+        register_json = _parse_json(request)
+
+        session = (register_json["session"] if "session" in register_json
+                  else None)
+        login_type = None
+        if "type" not in register_json:
+            raise SynapseError(400, "Missing 'type' key.")
+
         try:
-            register_json = json.loads(request.content.read())
-            if "password" in register_json:
-                password = register_json["password"].encode("utf-8")
-
-            if type(register_json["user_id"]) == unicode:
-                desired_user_id = register_json["user_id"].encode("utf-8")
-                if urllib.quote(desired_user_id) != desired_user_id:
-                    raise SynapseError(
-                        400,
-                        "User ID must only contain characters which do not " +
-                        "require URL encoding.")
-        except ValueError:
-            defer.returnValue((400, "No JSON object."))
-        except KeyError:
-            pass  # user_id is optional
+            login_type = register_json["type"]
+            stages = {
+                LoginType.RECAPTCHA: self._do_recaptcha,
+                LoginType.PASSWORD: self._do_password,
+                LoginType.EMAIL_IDENTITY: self._do_email_identity
+            }
 
-        threepidCreds = None
-        if 'threepidCreds' in register_json:
-            threepidCreds = register_json['threepidCreds']
-            
-        captcha = {}
-        if self.hs.config.enable_registration_captcha:
-            challenge = None
-            user_response = None
-            try:
-                captcha_type = register_json["captcha"]["type"]
-                if captcha_type != "m.login.recaptcha":
-                    raise SynapseError(400, "Sorry, only m.login.recaptcha " +
-                                       "requests are supported.")
-                challenge = register_json["captcha"]["challenge"]
-                user_response = register_json["captcha"]["response"]
-            except KeyError:
-                raise SynapseError(400, "Captcha response is required",
-                                   errcode=Codes.CAPTCHA_NEEDED)
-            
-            # TODO determine the source IP : May be an X-Forwarding-For header depending on config
-            ip_addr = request.getClientIP()
-            if self.hs.config.captcha_ip_origin_is_x_forwarded:
-                # use the header
-                if request.requestHeaders.hasHeader("X-Forwarded-For"):
-                    ip_addr = request.requestHeaders.getRawHeaders(
-                        "X-Forwarded-For")[0]
-            
-            captcha = {
-                "ip": ip_addr,
-                "private_key": self.hs.config.recaptcha_private_key,
-                "challenge": challenge,
-                "response": user_response
+            session_info = self._get_session_info(request, session)
+            logger.debug("%s : session info %s   request info %s",
+                         login_type, session_info, register_json)
+            response = yield stages[login_type](
+                request,
+                register_json,
+                session_info
+            )
+
+            if "access_token" not in response:
+                # isn't a final response
+                response["session"] = session_info["id"]
+
+            defer.returnValue((200, response))
+        except KeyError as e:
+            logger.exception(e)
+            raise SynapseError(400, "Missing JSON keys for login type %s." % login_type)
+
+    def on_OPTIONS(self, request):
+        return (200, {})
+
+    def _get_session_info(self, request, session_id):
+        if not session_id:
+            # create a new session
+            while session_id is None or session_id in self.sessions:
+                session_id = stringutils.random_string(24)
+            self.sessions[session_id] = {
+                "id": session_id,
+                LoginType.EMAIL_IDENTITY: False,
+                LoginType.RECAPTCHA: False
             }
-            
 
+        return self.sessions[session_id]
+
+    def _save_session(self, session):
+        # TODO: Persistent storage
+        logger.debug("Saving session %s", session)
+        self.sessions[session["id"]] = session
+
+    def _remove_session(self, session):
+        logger.debug("Removing session %s", session)
+        self.sessions.pop(session["id"])
+
+    @defer.inlineCallbacks
+    def _do_recaptcha(self, request, register_json, session):
+        if not self.hs.config.enable_registration_captcha:
+            raise SynapseError(400, "Captcha not required.")
+
+        challenge = None
+        user_response = None
+        try:
+            challenge = register_json["challenge"]
+            user_response = register_json["response"]
+        except KeyError:
+            raise SynapseError(400, "Captcha response is required",
+                               errcode=Codes.CAPTCHA_NEEDED)
+
+        # May be an X-Forwarding-For header depending on config
+        ip_addr = request.getClientIP()
+        if self.hs.config.captcha_ip_origin_is_x_forwarded:
+            # use the header
+            if request.requestHeaders.hasHeader("X-Forwarded-For"):
+                ip_addr = request.requestHeaders.getRawHeaders(
+                    "X-Forwarded-For")[0]
+
+        handler = self.handlers.registration_handler
+        yield handler.check_recaptcha(
+            ip_addr,
+            self.hs.config.recaptcha_private_key,
+            challenge,
+            user_response
+        )
+        session[LoginType.RECAPTCHA] = True  # mark captcha as done
+        self._save_session(session)
+        defer.returnValue({
+            "next": [LoginType.PASSWORD, LoginType.EMAIL_IDENTITY]
+        })
+
+    @defer.inlineCallbacks
+    def _do_email_identity(self, request, register_json, session):
+        if (self.hs.config.enable_registration_captcha and
+                not session[LoginType.RECAPTCHA]):
+            raise SynapseError(400, "Captcha is required.")
+
+        threepidCreds = register_json['threepidCreds']
+        handler = self.handlers.registration_handler
+        yield handler.register_email(threepidCreds)
+        session["threepidCreds"] = threepidCreds  # store creds for next stage
+        session[LoginType.EMAIL_IDENTITY] = True  # mark email as done
+        self._save_session(session)
+        defer.returnValue({
+            "next": LoginType.PASSWORD
+        })
+
+    @defer.inlineCallbacks
+    def _do_password(self, request, register_json, session):
+        if (self.hs.config.enable_registration_captcha and
+                not session[LoginType.RECAPTCHA]):
+            # captcha should've been done by this stage!
+            raise SynapseError(400, "Captcha is required.")
+
+        password = register_json["password"].encode("utf-8")
+        desired_user_id = (register_json["user"].encode("utf-8") if "user"
+                          in register_json else None)
+        if desired_user_id and urllib.quote(desired_user_id) != desired_user_id:
+            raise SynapseError(
+                400,
+                "User ID must only contain characters which do not " +
+                "require URL encoding.")
         handler = self.handlers.registration_handler
         (user_id, token) = yield handler.register(
             localpart=desired_user_id,
-            password=password,
-            threepidCreds=threepidCreds,
-            captcha_info=captcha)
+            password=password
+        )
+
+        if session[LoginType.EMAIL_IDENTITY]:
+            yield handler.bind_emails(user_id, session["threepidCreds"])
 
         result = {
             "user_id": user_id,
             "access_token": token,
             "home_server": self.hs.hostname,
         }
-        defer.returnValue(
-            (200, result)
-        )
+        self._remove_session(session)
+        defer.returnValue(result)
 
-    def on_OPTIONS(self, request):
-        return (200, {})
+
+def _parse_json(request):
+    try:
+        content = json.loads(request.content.read())
+        if type(content) != dict:
+            raise SynapseError(400, "Content must be a JSON object.")
+        return content
+    except ValueError:
+        raise SynapseError(400, "Content not JSON.")
 
 
 def register_servlets(hs, http_server):
diff --git a/synapse/rest/room.py b/synapse/rest/room.py
index cef700c81c6d6fe4b0a5e2974ea982ff48e26d9c..ecb1e346d91a2af43a62c052d708b83d948bea61 100644
--- a/synapse/rest/room.py
+++ b/synapse/rest/room.py
@@ -378,7 +378,7 @@ class RoomTriggerBackfill(RestServlet):
         handler = self.handlers.federation_handler
         events = yield handler.backfill(remote_server, room_id, limit)
 
-        res = [event.get_dict() for event in events]
+        res = [self.hs.serialize_event(event) for event in events]
         defer.returnValue((200, res))
 
 
diff --git a/synapse/server.py b/synapse/server.py
index 83368ea5a7227da61c024959cd2cc6def9f0572c..7c185537aa13d061c2ddc132b840690da0187d16 100644
--- a/synapse/server.py
+++ b/synapse/server.py
@@ -20,6 +20,7 @@
 
 # Imports required for the default HomeServer() implementation
 from synapse.federation import initialize_http_replication
+from synapse.api.events import serialize_event
 from synapse.api.events.factory import EventFactory
 from synapse.notifier import Notifier
 from synapse.api.auth import Auth
@@ -138,6 +139,9 @@ class BaseHomeServer(object):
         object."""
         return RoomID.from_string(s, hs=self)
 
+    def serialize_event(self, e):
+        return serialize_event(self, e)
+
 # Build magic accessors for every dependency
 for depname in BaseHomeServer.DEPENDENCIES:
     BaseHomeServer._make_dependency_method(depname)
diff --git a/synapse/storage/__init__.py b/synapse/storage/__init__.py
index 9201a377b6c2fcceb252c1f97fb2491b880ea0f7..1cede2809d5790973e68d1af951e7dce6d1f88f3 100644
--- a/synapse/storage/__init__.py
+++ b/synapse/storage/__init__.py
@@ -68,7 +68,8 @@ class DataStore(RoomMemberStore, RoomStore,
 
     @defer.inlineCallbacks
     @log_function
-    def persist_event(self, event=None, backfilled=False, pdu=None):
+    def persist_event(self, event=None, backfilled=False, pdu=None,
+                      is_new_state=True):
         stream_ordering = None
         if backfilled:
             if not self.min_token_deferred.called:
@@ -83,6 +84,7 @@ class DataStore(RoomMemberStore, RoomStore,
                 event=event,
                 backfilled=backfilled,
                 stream_ordering=stream_ordering,
+                is_new_state=is_new_state,
             )
         except _RollbackButIsFineException as e:
             pass
@@ -109,12 +111,14 @@ class DataStore(RoomMemberStore, RoomStore,
         defer.returnValue(event)
 
     def _persist_pdu_event_txn(self, txn, pdu=None, event=None,
-                               backfilled=False, stream_ordering=None):
+                               backfilled=False, stream_ordering=None,
+                               is_new_state=True):
         if pdu is not None:
             self._persist_event_pdu_txn(txn, pdu)
         if event is not None:
             return self._persist_event_txn(
-                txn, event, backfilled, stream_ordering
+                txn, event, backfilled, stream_ordering,
+                is_new_state=is_new_state,
             )
 
     def _persist_event_pdu_txn(self, txn, pdu):
@@ -141,7 +145,8 @@ class DataStore(RoomMemberStore, RoomStore,
         self._update_min_depth_for_context_txn(txn, pdu.context, pdu.depth)
 
     @log_function
-    def _persist_event_txn(self, txn, event, backfilled, stream_ordering=None):
+    def _persist_event_txn(self, txn, event, backfilled, stream_ordering=None,
+                           is_new_state=True):
         if event.type == RoomMemberEvent.TYPE:
             self._store_room_member_txn(txn, event)
         elif event.type == FeedbackEvent.TYPE:
@@ -195,7 +200,7 @@ class DataStore(RoomMemberStore, RoomStore,
             )
             raise _RollbackButIsFineException("_persist_event")
 
-        if not backfilled and hasattr(event, "state_key"):
+        if is_new_state and hasattr(event, "state_key"):
             vals = {
                 "event_id": event.event_id,
                 "room_id": event.room_id,
diff --git a/synapse/storage/_base.py b/synapse/storage/_base.py
index 8deaaf93bd253ca9798c0446b00eb78ddc1023e4..cf88bfc22b4bf1a8f2c736ff442c87e501c3a333 100644
--- a/synapse/storage/_base.py
+++ b/synapse/storage/_base.py
@@ -315,6 +315,10 @@ class SQLBaseStore(object):
         d["content"] = json.loads(d["content"])
         del d["unrecognized_keys"]
 
+        if "age_ts" not in d:
+            # For compatibility
+            d["age_ts"] = d["ts"] if "ts" in d else 0
+
         return self.event_factory.create_event(
             etype=d["type"],
             **d
diff --git a/synapse/util/emailutils.py b/synapse/util/emailutils.py
new file mode 100644
index 0000000000000000000000000000000000000000..cdb0abd7eae5028240bb4aa7e64a684c598305bf
--- /dev/null
+++ b/synapse/util/emailutils.py
@@ -0,0 +1,71 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014 OpenMarket Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+""" This module allows you to send out emails.
+"""
+import email.utils
+import smtplib
+import twisted.python.log
+from email.mime.text import MIMEText
+from email.mime.multipart import MIMEMultipart
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class EmailException(Exception):
+    pass
+
+
+def send_email(smtp_server, from_addr, to_addr, subject, body):
+    """Sends an email.
+
+    Args:
+        smtp_server(str): The SMTP server to use.
+        from_addr(str): The address to send from.
+        to_addr(str): The address to send to.
+        subject(str): The subject of the email.
+        body(str): The plain text body of the email.
+    Raises:
+        EmailException if there was a problem sending the mail.
+    """
+    if not smtp_server or not from_addr or not to_addr:
+        raise EmailException("Need SMTP server, from and to addresses. Check " +
+                             "the config to set these.")
+
+    msg = MIMEMultipart('alternative')
+    msg['Subject'] = subject
+    msg['From'] = from_addr
+    msg['To'] = to_addr
+    plain_part = MIMEText(body)
+    msg.attach(plain_part)
+
+    raw_from = email.utils.parseaddr(from_addr)[1]
+    raw_to = email.utils.parseaddr(to_addr)[1]
+    if not raw_from or not raw_to:
+        raise EmailException("Couldn't parse from/to address.")
+
+    logger.info("Sending email to %s on server %s with subject %s",
+                to_addr, smtp_server, subject)
+
+    try:
+        smtp = smtplib.SMTP(smtp_server)
+        smtp.sendmail(raw_from, raw_to, msg.as_string())
+        smtp.quit()
+    except Exception as origException:
+        twisted.python.log.err()
+        ese = EmailException()
+        ese.cause = origException
+        raise ese
\ No newline at end of file
diff --git a/tests/api/test_ratelimiting.py b/tests/api/test_ratelimiting.py
index dc2f83c7eb0f7be4513f613515d0831c203c656f..dd0bc19ecfc26a804fa87355286f04352737dfbf 100644
--- a/tests/api/test_ratelimiting.py
+++ b/tests/api/test_ratelimiting.py
@@ -1,6 +1,6 @@
 from synapse.api.ratelimiting import Ratelimiter
 
-import unittest
+from tests import unittest
 
 class TestRatelimiter(unittest.TestCase):
 
diff --git a/tests/events/test_events.py b/tests/events/test_events.py
index 93d5c15c6f1e58b709476bc43a21f1bfb434a37a..a4b6cb3afd2ba82aa059dbfcaf72d621f500bc66 100644
--- a/tests/events/test_events.py
+++ b/tests/events/test_events.py
@@ -15,7 +15,7 @@
 
 from synapse.api.events import SynapseEvent
 
-import unittest
+from tests import unittest
 
 
 class SynapseTemplateCheckTestCase(unittest.TestCase):
diff --git a/tests/federation/test_federation.py b/tests/federation/test_federation.py
index 0b105fe723b68366a80401fdeace3fba3c069e81..bb17e9aafefc9069bbf05fceb8383041dd85184d 100644
--- a/tests/federation/test_federation.py
+++ b/tests/federation/test_federation.py
@@ -14,11 +14,10 @@
 
 # trial imports
 from twisted.internet import defer
-from twisted.trial import unittest
+from tests import unittest
 
 # python imports
-from mock import Mock
-import logging
+from mock import Mock, ANY
 
 from ..utils import MockHttpResource, MockClock
 
@@ -28,9 +27,6 @@ from synapse.federation.units import Pdu
 from synapse.storage.pdu import PduTuple, PduEntry
 
 
-logging.getLogger().addHandler(logging.NullHandler())
-
-
 def make_pdu(prev_pdus=[], **kwargs):
     """Provide some default fields for making a PduTuple."""
     pdu_fields = {
@@ -185,7 +181,8 @@ class FederationTestCase(unittest.TestCase):
                             "depth": 1,
                         },
                     ]
-                }
+                },
+                on_send_callback=ANY,
         )
 
     @defer.inlineCallbacks
@@ -216,7 +213,9 @@ class FederationTestCase(unittest.TestCase):
                             "content": {"testing": "content here"},
                         }
                     ],
-                })
+                },
+                on_send_callback=ANY,
+        )
 
     @defer.inlineCallbacks
     def test_recv_edu(self):
diff --git a/tests/federation/test_pdu_codec.py b/tests/federation/test_pdu_codec.py
index 9f74ba119ffae439393bc8d70b908315e179022c..344e1baf60c21aebe9e2e827ed232b0f9bb1b3ca 100644
--- a/tests/federation/test_pdu_codec.py
+++ b/tests/federation/test_pdu_codec.py
@@ -13,7 +13,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-from twisted.trial import unittest
+from tests import unittest
 
 from synapse.federation.pdu_codec import (
     PduCodec, encode_event_id, decode_event_id
diff --git a/tests/handlers/test_directory.py b/tests/handlers/test_directory.py
index 72a2b1443a46caf63e82b3f87907c04b808d7662..54d6e51f973b0474a17030751dab273c177a51ef 100644
--- a/tests/handlers/test_directory.py
+++ b/tests/handlers/test_directory.py
@@ -14,11 +14,10 @@
 # limitations under the License.
 
 
-from twisted.trial import unittest
+from tests import unittest
 from twisted.internet import defer
 
 from mock import Mock
-import logging
 
 from synapse.server import HomeServer
 from synapse.http.client import HttpClient
@@ -26,9 +25,6 @@ from synapse.handlers.directory import DirectoryHandler
 from synapse.storage.directory import RoomAliasMapping
 
 
-logging.getLogger().addHandler(logging.NullHandler())
-
-
 class DirectoryHandlers(object):
     def __init__(self, hs):
         self.directory_handler = DirectoryHandler(hs)
diff --git a/tests/handlers/test_federation.py b/tests/handlers/test_federation.py
index 6fc3d8f7fd2352d2bcfdd95501140fdb21d58d79..eb6b7c22ef5a2377f9f5681939ac19d992d0271c 100644
--- a/tests/handlers/test_federation.py
+++ b/tests/handlers/test_federation.py
@@ -14,7 +14,7 @@
 
 
 from twisted.internet import defer
-from twisted.trial import unittest
+from tests import unittest
 
 from synapse.api.events.room import (
     InviteJoinEvent, MessageEvent, RoomMemberEvent
@@ -26,12 +26,8 @@ from synapse.federation.units import Pdu
 
 from mock import NonCallableMock, ANY
 
-import logging
-
 from ..utils import get_mock_call_args
 
-logging.getLogger().addHandler(logging.NullHandler())
-
 
 class FederationTestCase(unittest.TestCase):
 
@@ -78,7 +74,9 @@ class FederationTestCase(unittest.TestCase):
 
         yield self.handlers.federation_handler.on_receive_pdu(pdu, False)
 
-        self.datastore.persist_event.assert_called_once_with(ANY, False)
+        self.datastore.persist_event.assert_called_once_with(
+            ANY, False, is_new_state=False
+        )
         self.notifier.on_new_room_event.assert_called_once_with(ANY)
 
     @defer.inlineCallbacks
diff --git a/tests/handlers/test_presence.py b/tests/handlers/test_presence.py
index 9eb8b6909f99befe31db8394e685201c1579d160..0cb4dfba393c64b53ae1d232f1893667a002c645 100644
--- a/tests/handlers/test_presence.py
+++ b/tests/handlers/test_presence.py
@@ -14,11 +14,10 @@
 # limitations under the License.
 
 
-from twisted.trial import unittest
+from tests import unittest
 from twisted.internet import defer, reactor
 
 from mock import Mock, call, ANY
-import logging
 import json
 
 from ..utils import MockHttpResource, MockClock, DeferredMockCallable
@@ -34,9 +33,6 @@ UNAVAILABLE = PresenceState.UNAVAILABLE
 ONLINE = PresenceState.ONLINE
 
 
-logging.getLogger().addHandler(logging.NullHandler())
-
-
 def _expect_edu(destination, edu_type, content, origin="test"):
     return {
         "origin": origin,
@@ -92,7 +88,6 @@ class PresenceStateTestCase(unittest.TestCase):
         # Mock the RoomMemberHandler
         room_member_handler = Mock(spec=[])
         hs.handlers.room_member_handler = room_member_handler
-        logging.getLogger().debug("Mocking room_member_handler=%r", room_member_handler)
 
         # Some local users to test with
         self.u_apple = hs.parse_userid("@apple:test")
@@ -324,7 +319,8 @@ class PresenceInvitesTestCase(unittest.TestCase):
                         "observer_user": "@apple:test",
                         "observed_user": "@cabbage:elsewhere",
                     }
-                )
+                ),
+                on_send_callback=ANY,
             ),
             defer.succeed((200, "OK"))
         )
@@ -350,7 +346,8 @@ class PresenceInvitesTestCase(unittest.TestCase):
                         "observer_user": "@cabbage:elsewhere",
                         "observed_user": "@apple:test",
                     }
-                )
+                ),
+                on_send_callback=ANY,
             ),
             defer.succeed((200, "OK"))
         )
@@ -381,7 +378,8 @@ class PresenceInvitesTestCase(unittest.TestCase):
                         "observer_user": "@cabbage:elsewhere",
                         "observed_user": "@durian:test",
                     }
-                )
+                ),
+                on_send_callback=ANY,
             ),
             defer.succeed((200, "OK"))
         )
@@ -770,7 +768,8 @@ class PresencePushTestCase(unittest.TestCase):
                              "last_active_ago": 0},
                         ],
                     }
-                )
+                ),
+                on_send_callback=ANY,
             ),
             defer.succeed((200, "OK"))
         )
@@ -785,7 +784,8 @@ class PresencePushTestCase(unittest.TestCase):
                              "last_active_ago": 0},
                         ],
                     }
-                )
+                ),
+                on_send_callback=ANY,
             ),
             defer.succeed((200, "OK"))
         )
@@ -911,6 +911,7 @@ class PresencePushTestCase(unittest.TestCase):
                         ],
                     }
                 ),
+                on_send_callback=ANY,
             ),
             defer.succeed((200, "OK"))
         )
@@ -925,6 +926,7 @@ class PresencePushTestCase(unittest.TestCase):
                         ],
                     }
                 ),
+                on_send_callback=ANY,
             ),
             defer.succeed((200, "OK"))
         )
@@ -954,6 +956,7 @@ class PresencePushTestCase(unittest.TestCase):
                         ],
                     }
                 ),
+                on_send_callback=ANY,
             ),
             defer.succeed((200, "OK"))
         )
@@ -1150,6 +1153,7 @@ class PresencePollingTestCase(unittest.TestCase):
                         "poll": [ "@potato:remote" ],
                     },
                 ),
+                on_send_callback=ANY,
             ),
             defer.succeed((200, "OK"))
         )
@@ -1162,6 +1166,7 @@ class PresencePollingTestCase(unittest.TestCase):
                         "push": [ {"user_id": "@clementine:test" }],
                     },
                 ),
+                on_send_callback=ANY,
             ),
             defer.succeed((200, "OK"))
         )
@@ -1190,6 +1195,7 @@ class PresencePollingTestCase(unittest.TestCase):
                         "push": [ {"user_id": "@fig:test" }],
                     },
                 ),
+                on_send_callback=ANY,
             ),
             defer.succeed((200, "OK"))
         )
@@ -1222,6 +1228,7 @@ class PresencePollingTestCase(unittest.TestCase):
                         "unpoll": [ "@potato:remote" ],
                     },
                 ),
+                on_send_callback=ANY,
             ),
             defer.succeed((200, "OK"))
         )
@@ -1253,6 +1260,7 @@ class PresencePollingTestCase(unittest.TestCase):
                         ],
                     },
                 ),
+                on_send_callback=ANY,
             ),
             defer.succeed((200, "OK"))
         )
diff --git a/tests/handlers/test_presencelike.py b/tests/handlers/test_presencelike.py
index b35980d948869499dc6020de8962fa46ba124681..047752ad68150b38e5e7c55a1fe1be8eb150a668 100644
--- a/tests/handlers/test_presencelike.py
+++ b/tests/handlers/test_presencelike.py
@@ -16,11 +16,10 @@
 """This file contains tests of the "presence-like" data that is shared between
 presence and profiles; namely, the displayname and avatar_url."""
 
-from twisted.trial import unittest
+from tests import unittest
 from twisted.internet import defer
 
 from mock import Mock, call, ANY
-import logging
 
 from ..utils import MockClock
 
@@ -35,9 +34,6 @@ UNAVAILABLE = PresenceState.UNAVAILABLE
 ONLINE = PresenceState.ONLINE
 
 
-logging.getLogger().addHandler(logging.NullHandler())
-
-
 class MockReplication(object):
     def __init__(self):
         self.edu_handlers = {}
@@ -69,6 +65,8 @@ class PresenceProfilelikeDataTestCase(unittest.TestCase):
                     "is_presence_visible",
 
                     "set_profile_displayname",
+
+                    "get_rooms_for_user_where_membership_is",
                 ]),
                 handlers=None,
                 resource_for_federation=Mock(),
@@ -136,6 +134,10 @@ class PresenceProfilelikeDataTestCase(unittest.TestCase):
         # Remote user
         self.u_potato = hs.parse_userid("@potato:remote")
 
+        self.mock_get_joined = (
+            self.datastore.get_rooms_for_user_where_membership_is
+        )
+
     @defer.inlineCallbacks
     def test_set_my_state(self):
         self.presence_list = [
@@ -156,6 +158,11 @@ class PresenceProfilelikeDataTestCase(unittest.TestCase):
 
     @defer.inlineCallbacks
     def test_push_local(self):
+        def get_joined(*args):
+            return defer.succeed([])
+
+        self.mock_get_joined.side_effect = get_joined
+
         self.presence_list = [
             {"observed_user_id": "@banana:test"},
             {"observed_user_id": "@clementine:test"},
diff --git a/tests/handlers/test_profile.py b/tests/handlers/test_profile.py
index 8e7a89b4792ada2a64312d5f1e72b0d7edbd8b1d..ee2be9b6d5cb7f5bcb2d0383530c54c8c37b4079 100644
--- a/tests/handlers/test_profile.py
+++ b/tests/handlers/test_profile.py
@@ -14,18 +14,15 @@
 # limitations under the License.
 
 
-from twisted.trial import unittest
+from tests import unittest
 from twisted.internet import defer
 
 from mock import Mock
-import logging
 
 from synapse.api.errors import AuthError
 from synapse.server import HomeServer
 from synapse.handlers.profile import ProfileHandler
-
-
-logging.getLogger().addHandler(logging.NullHandler())
+from synapse.api.constants import Membership
 
 
 class ProfileHandlers(object):
@@ -54,6 +51,7 @@ class ProfileTestCase(unittest.TestCase):
                     "set_profile_displayname",
                     "get_profile_avatar_url",
                     "set_profile_avatar_url",
+                    "get_rooms_for_user_where_membership_is",
                 ]),
                 handlers=None,
                 resource_for_federation=Mock(),
@@ -69,6 +67,10 @@ class ProfileTestCase(unittest.TestCase):
 
         self.handler = hs.get_handlers().profile_handler
 
+        self.mock_get_joined = (
+            self.datastore.get_rooms_for_user_where_membership_is
+        )
+
         # TODO(paul): Icky signal declarings.. booo
         hs.get_distributor().declare("changed_presencelike_data")
 
@@ -87,8 +89,15 @@ class ProfileTestCase(unittest.TestCase):
         mocked_set = self.datastore.set_profile_displayname
         mocked_set.return_value = defer.succeed(())
 
+        self.mock_get_joined.return_value = defer.succeed([])
+
         yield self.handler.set_displayname(self.frank, self.frank, "Frank Jr.")
 
+        self.mock_get_joined.assert_called_once_with(
+            self.frank.to_string(),
+            [Membership.JOIN]
+        )
+
         mocked_set.assert_called_with("1234ABCD", "Frank Jr.")
 
     @defer.inlineCallbacks
@@ -139,7 +148,15 @@ class ProfileTestCase(unittest.TestCase):
         mocked_set = self.datastore.set_profile_avatar_url
         mocked_set.return_value = defer.succeed(())
 
+        self.mock_get_joined.return_value = defer.succeed([])
+
         yield self.handler.set_avatar_url(self.frank, self.frank,
                 "http://my.server/pic.gif")
 
+        self.mock_get_joined.assert_called_once_with(
+            self.frank.to_string(),
+            [Membership.JOIN]
+        )
+
+
         mocked_set.assert_called_with("1234ABCD", "http://my.server/pic.gif")
diff --git a/tests/handlers/test_room.py b/tests/handlers/test_room.py
index 5687bbea0b690a40c426efb7d056029773c6c5af..a1a2e8049250b9599b8a44b2c07dbdaa090c8353 100644
--- a/tests/handlers/test_room.py
+++ b/tests/handlers/test_room.py
@@ -15,7 +15,7 @@
 
 
 from twisted.internet import defer
-from twisted.trial import unittest
+from tests import unittest
 
 from synapse.api.events.room import (
     InviteJoinEvent, RoomMemberEvent, RoomConfigEvent
@@ -27,10 +27,6 @@ from synapse.server import HomeServer
 
 from mock import Mock, NonCallableMock
 
-import logging
-
-logging.getLogger().addHandler(logging.NullHandler())
-
 
 class RoomMemberHandlerTestCase(unittest.TestCase):
 
diff --git a/tests/handlers/test_typing.py b/tests/handlers/test_typing.py
index 6532ac94a3394590a4f5a9188a7b703cc7ad23a4..a66f208abfc3d72abf8ff52913bde4fbea196b5b 100644
--- a/tests/handlers/test_typing.py
+++ b/tests/handlers/test_typing.py
@@ -14,12 +14,11 @@
 # limitations under the License.
 
 
-from twisted.trial import unittest
+from tests import unittest
 from twisted.internet import defer
 
 from mock import Mock, call, ANY
 import json
-import logging
 
 from ..utils import MockHttpResource, MockClock, DeferredMockCallable
 
@@ -27,9 +26,6 @@ from synapse.server import HomeServer
 from synapse.handlers.typing import TypingNotificationHandler
 
 
-logging.getLogger().addHandler(logging.NullHandler())
-
-
 def _expect_edu(destination, edu_type, content, origin="test"):
     return {
         "origin": origin,
@@ -173,7 +169,8 @@ class TypingNotificationsTestCase(unittest.TestCase):
                         "user_id": self.u_apple.to_string(),
                         "typing": True,
                     }
-                )
+                ),
+                on_send_callback=ANY,
             ),
             defer.succeed((200, "OK"))
         )
@@ -223,7 +220,8 @@ class TypingNotificationsTestCase(unittest.TestCase):
                         "user_id": self.u_apple.to_string(),
                         "typing": False,
                     }
-                )
+                ),
+                on_send_callback=ANY,
             ),
             defer.succeed((200, "OK"))
         )
diff --git a/tests/rest/test_events.py b/tests/rest/test_events.py
index fd2224f55fa2bf5457ce85eb1e8073a9732e576f..79b371c04dffa278560006d4c0350ce695ffa887 100644
--- a/tests/rest/test_events.py
+++ b/tests/rest/test_events.py
@@ -14,7 +14,7 @@
 # limitations under the License.
 
 """ Tests REST events for /events paths."""
-from twisted.trial import unittest
+from tests import unittest
 
 # twisted imports
 from twisted.internet import defer
@@ -27,14 +27,12 @@ from synapse.server import HomeServer
 
 # python imports
 import json
-import logging
 
 from ..utils import MockHttpResource, MemoryDataStore
 from .utils import RestTestCase
 
 from mock import Mock, NonCallableMock
 
-logging.getLogger().addHandler(logging.NullHandler())
 
 PATH_PREFIX = "/_matrix/client/api/v1"
 
diff --git a/tests/rest/test_presence.py b/tests/rest/test_presence.py
index a1db0fbcf3770e4c4a1d3248e50fc665bfa2abc5..ea3478ac5d85e276697bfbf80327f1d79011641b 100644
--- a/tests/rest/test_presence.py
+++ b/tests/rest/test_presence.py
@@ -15,11 +15,10 @@
 
 """Tests REST events for /presence paths."""
 
-from twisted.trial import unittest
+from tests import unittest
 from twisted.internet import defer
 
 from mock import Mock
-import logging
 
 from ..utils import MockHttpResource
 
@@ -28,9 +27,6 @@ from synapse.handlers.presence import PresenceHandler
 from synapse.server import HomeServer
 
 
-logging.getLogger().addHandler(logging.NullHandler())
-
-
 OFFLINE = PresenceState.OFFLINE
 UNAVAILABLE = PresenceState.UNAVAILABLE
 ONLINE = PresenceState.ONLINE
diff --git a/tests/rest/test_profile.py b/tests/rest/test_profile.py
index f41810df1fd16e188b018d613a425e66be720e0d..e6e51f6dd09a64ef322a4b44de8b5329327b40e1 100644
--- a/tests/rest/test_profile.py
+++ b/tests/rest/test_profile.py
@@ -15,7 +15,7 @@
 
 """Tests REST events for /profile paths."""
 
-from twisted.trial import unittest
+from tests import unittest
 from twisted.internet import defer
 
 from mock import Mock
@@ -28,6 +28,7 @@ from synapse.server import HomeServer
 myid = "@1234ABCD:test"
 PATH_PREFIX = "/_matrix/client/api/v1"
 
+
 class ProfileTestCase(unittest.TestCase):
     """ Tests profile management. """
 
diff --git a/tests/rest/utils.py b/tests/rest/utils.py
index 77f5ecf0df7231794db25b56371b792d6f1c4fec..579441fb4a7ab98c13705bec830c3f21ebdd2afa 100644
--- a/tests/rest/utils.py
+++ b/tests/rest/utils.py
@@ -17,7 +17,7 @@
 from twisted.internet import defer
 
 # trial imports
-from twisted.trial import unittest
+from tests import unittest
 
 from synapse.api.constants import Membership
 
@@ -95,8 +95,14 @@ class RestTestCase(unittest.TestCase):
 
     @defer.inlineCallbacks
     def register(self, user_id):
-        (code, response) = yield self.mock_resource.trigger("POST", "/register",
-                                '{"user_id":"%s"}' % user_id)
+        (code, response) = yield self.mock_resource.trigger(
+            "POST",
+            "/register",
+            json.dumps({
+                "user": user_id,
+                "password": "test",
+                "type": "m.login.password"
+            }))
         self.assertEquals(200, code)
         defer.returnValue(response)
 
diff --git a/tests/storage/test_base.py b/tests/storage/test_base.py
index 330311448d6ae773ac8ed47e46d8fb88a1925050..3ad9a4b0c01563d9229ac2cf063f2404948825ee 100644
--- a/tests/storage/test_base.py
+++ b/tests/storage/test_base.py
@@ -14,7 +14,7 @@
 # limitations under the License.
 
 
-from twisted.trial import unittest
+from tests import unittest
 from twisted.internet import defer
 
 from mock import Mock, call
diff --git a/tests/test_distributor.py b/tests/test_distributor.py
index 04933f0ecf64414f2e644aa2174224b9c7624526..39c5b8dff2ab41c8887ca3922edc39eab0baf7ef 100644
--- a/tests/test_distributor.py
+++ b/tests/test_distributor.py
@@ -13,8 +13,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from tests import unittest
 from twisted.internet import defer
-from twisted.trial import unittest
 
 from mock import Mock, patch
 
diff --git a/tests/test_state.py b/tests/test_state.py
index 16af95b7bc4b3b11346fe94e4987edda95496692..b1624f0b259df3a1e28b8e8b084484ff31548ff1 100644
--- a/tests/test_state.py
+++ b/tests/test_state.py
@@ -13,8 +13,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from tests import unittest
 from twisted.internet import defer
-from twisted.trial import unittest
 from twisted.python.log import PythonLoggingObserver
 
 from synapse.state import StateHandler
@@ -26,7 +26,6 @@ from collections import namedtuple
 
 from mock import Mock
 
-import logging
 import mock
 
 
diff --git a/tests/test_types.py b/tests/test_types.py
index 571938356c4ec15d535605fb27d4fcd9897f88cd..276ecc91fd4ac25b6a92204995a17cfac3f00199 100644
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -13,7 +13,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-import unittest
+from tests import unittest
 
 from synapse.server import BaseHomeServer
 from synapse.types import UserID, RoomAlias
diff --git a/tests/unittest.py b/tests/unittest.py
new file mode 100644
index 0000000000000000000000000000000000000000..fb97fb1148eb33abfe648ca54689083fcdc7db33
--- /dev/null
+++ b/tests/unittest.py
@@ -0,0 +1,79 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014 OpenMarket Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from twisted.trial import unittest
+
+import logging
+
+
+# logging doesn't have a "don't log anything at all EVARRRR setting,
+# but since the highest value is 50, 1000000 should do ;)
+NEVER = 1000000
+
+logging.getLogger().addHandler(logging.StreamHandler())
+logging.getLogger().setLevel(NEVER)
+
+
+def around(target):
+    """A CLOS-style 'around' modifier, which wraps the original method of the
+    given instance with another piece of code.
+
+    @around(self)
+    def method_name(orig, *args, **kwargs):
+        return orig(*args, **kwargs)
+    """
+    def _around(code):
+        name = code.__name__
+        orig = getattr(target, name)
+        def new(*args, **kwargs):
+            return code(orig, *args, **kwargs)
+        setattr(target, name, new)
+    return _around
+
+
+class TestCase(unittest.TestCase):
+    """A subclass of twisted.trial's TestCase which looks for 'loglevel'
+    attributes on both itself and its individual test methods, to override the
+    root logger's logging level while that test (case|method) runs."""
+
+    def __init__(self, methodName, *args, **kwargs):
+        super(TestCase, self).__init__(methodName, *args, **kwargs)
+
+        method = getattr(self, methodName)
+
+        level = getattr(method, "loglevel",
+                    getattr(self, "loglevel",
+                        NEVER))
+
+        @around(self)
+        def setUp(orig):
+            old_level = logging.getLogger().level
+
+            if old_level != level:
+                @around(self)
+                def tearDown(orig):
+                    ret = orig()
+                    logging.getLogger().setLevel(old_level)
+                    return ret
+
+            logging.getLogger().setLevel(level)
+            return orig()
+
+
+def DEBUG(target):
+    """A decorator to set the .loglevel attribute to logging.DEBUG.
+    Can apply to either a TestCase or an individual test method."""
+    target.loglevel = logging.DEBUG
+    return target
diff --git a/tests/util/test_lock.py b/tests/util/test_lock.py
index 5623d78423a6e41422bc5d9eea60343ad403abb5..6a1e521b1e1b294dca07dc02d70c968d09e5c1ff 100644
--- a/tests/util/test_lock.py
+++ b/tests/util/test_lock.py
@@ -15,7 +15,7 @@
 
 
 from twisted.internet import defer
-from twisted.trial import unittest
+from tests import unittest
 
 from synapse.util.lockutils import LockManager
 
@@ -105,4 +105,4 @@ class LockManagerTestCase(unittest.TestCase):
             pass
 
         with (yield self.lock_manager.lock(key)):
-            pass
\ No newline at end of file
+            pass
diff --git a/webclient/app-controller.js b/webclient/app-controller.js
index 6c3759878b487bbe7298379a23d163fb1fc9e6b0..6338624486782471cad13180f142a191f4591272 100644
--- a/webclient/app-controller.js
+++ b/webclient/app-controller.js
@@ -130,6 +130,10 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
             angular.element('#ringAudio')[0].pause();
             angular.element('#ringbackAudio')[0].pause();
             angular.element('#busyAudio')[0].play();
+        } else if (newVal == 'ended' && oldVal == 'invite_sent' && $rootScope.currentCall.hangupParty == 'local' && $rootScope.currentCall.hangupReason == 'invite_timeout') {
+            angular.element('#ringAudio')[0].pause();
+            angular.element('#ringbackAudio')[0].pause();
+            angular.element('#busyAudio')[0].play();
         } else if (oldVal == 'invite_sent') {
             angular.element('#ringbackAudio')[0].pause();
         } else if (oldVal == 'ringing') {
diff --git a/webclient/app.css b/webclient/app.css
index 064f626f0b450400a3d03dfae071c8050d67ce2e..736aea660cbfead97c92bce90318da7952203f12 100755
--- a/webclient/app.css
+++ b/webclient/app.css
@@ -528,9 +528,8 @@ a:active  { color: #000; }
 }
 
 .bubble .message {
-    /* Break lines when encountering CR+LF */
-    /* FIXME: this breaks wordwrapping.  We need to s#CRLF#<br/>#g instead */
-/*    white-space: pre; */
+    /* Wrap words and break lines on CR+LF */
+    white-space: pre-wrap;
 }
 .bubble .messagePending {
     opacity: 0.3
@@ -539,6 +538,10 @@ a:active  { color: #000; }
     color: #F00;
 }
 
+.messageBing {
+    color: #00F;
+}
+
 #room-fullscreen-image {
     position: absolute;
     top: 0px;
@@ -600,7 +603,11 @@ a:active  { color: #000; }
     width: auto;
 }
 
-.recentsRoomSummaryTS {
+.recentsPublicRoom {
+    font-weight: bold;
+}
+
+.recentsRoomSummaryUsersCount, .recentsRoomSummaryTS {
     color: #888;
     font-size: 12px;
     width: 7em;
@@ -613,6 +620,11 @@ a:active  { color: #000; }
     padding-bottom: 5px;
 }
 
+/* Do not show users count in the recents fragment displayed on the room page */
+#roomPage .recentsRoomSummaryUsersCount {
+    width: 0em;
+}
+
 /*** Recents in the room page ***/
 
 #roomRecentsTableWrapper {
diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js
index 705a5a07f2570dbb48eadd10c9491fbaa1fcfb77..ad69d297fae90a0dc86590f160e3e9fee63b9cbb 100644
--- a/webclient/components/matrix/event-handler-service.js
+++ b/webclient/components/matrix/event-handler-service.js
@@ -27,7 +27,8 @@ Typically, this service will store events or broadcast them to any listeners
 if typically all the $on method would do is update its own $scope.
 */
 angular.module('eventHandlerService', [])
-.factory('eventHandlerService', ['matrixService', '$rootScope', '$q', function(matrixService, $rootScope, $q) {
+.factory('eventHandlerService', ['matrixService', '$rootScope', '$q', '$timeout', 'mPresence', 
+function(matrixService, $rootScope, $q, $timeout, mPresence) {
     var ROOM_CREATE_EVENT = "ROOM_CREATE_EVENT";
     var MSG_EVENT = "MSG_EVENT";
     var MEMBER_EVENT = "MEMBER_EVENT";
@@ -38,6 +39,51 @@ angular.module('eventHandlerService', [])
     var TOPIC_EVENT = "TOPIC_EVENT";
     var RESET_EVENT = "RESET_EVENT";    // eventHandlerService has been resetted
 
+    // used for dedupping events - could be expanded in future...
+    // FIXME: means that we leak memory over time (along with lots of the rest
+    // of the app, given we never try to reap memory yet)
+    var eventMap = {};
+
+    $rootScope.presence = {};
+    
+    // TODO: This is attached to the rootScope so .html can just go containsBingWord
+    // for determining classes so it is easy to highlight bing messages. It seems a
+    // bit strange to put the impl in this service though, but I can't think of a better
+    // file to put it in.
+    $rootScope.containsBingWord = function(content) {
+        if (!content || $.type(content) != "string") {
+            return false;
+        }
+        var bingWords = matrixService.config().bingWords;
+        var shouldBing = false;
+        
+        // case-insensitive name check for user_id OR display_name if they exist
+        var myUserId = matrixService.config().user_id;
+        if (myUserId) {
+            myUserId = myUserId.toLocaleLowerCase();
+        }
+        var myDisplayName = matrixService.config().display_name;
+        if (myDisplayName) {
+            myDisplayName = myDisplayName.toLocaleLowerCase();
+        }
+        if ( (myDisplayName && content.toLocaleLowerCase().indexOf(myDisplayName) != -1) ||
+             (myUserId && content.toLocaleLowerCase().indexOf(myUserId) != -1) ) {
+            shouldBing = true;
+        }
+        
+        // bing word list check
+        if (bingWords && !shouldBing) {
+            for (var i=0; i<bingWords.length; i++) {
+                var re = RegExp(bingWords[i]);
+                if (content.search(re) != -1) {
+                    shouldBing = true;
+                    break;
+                }
+            }
+        }
+        return shouldBing;
+    };
+
     var initialSyncDeferred;
 
     var reset = function() {
@@ -46,26 +92,24 @@ angular.module('eventHandlerService', [])
         $rootScope.events = {
             rooms: {} // will contain roomId: { messages:[], members:{userid1: event} }
         };
-    }
-    reset();
 
-    // used for dedupping events - could be expanded in future...
-    // FIXME: means that we leak memory over time (along with lots of the rest
-    // of the app, given we never try to reap memory yet)
-    var eventMap = {};
+        $rootScope.presence = {};
+
+        eventMap = {};
+    };
+    reset();
 
-    $rootScope.presence = {};
-    
     var initRoom = function(room_id) {
         if (!(room_id in $rootScope.events.rooms)) {
             console.log("Creating new handler entry for " + room_id);
-            $rootScope.events.rooms[room_id] = {};
-            $rootScope.events.rooms[room_id].messages = [];
-            $rootScope.events.rooms[room_id].members = {};
-
-            // Pagination information
-            $rootScope.events.rooms[room_id].pagination = {
-                earliest_token: "END"   // how far back we've paginated
+            $rootScope.events.rooms[room_id] = {
+                room_id: room_id,
+                messages: [],
+                members: {},
+                // Pagination information
+                pagination: {
+                    earliest_token: "END"   // how far back we've paginated
+                }
             };
         }
     };
@@ -132,6 +176,48 @@ angular.module('eventHandlerService', [])
             else {
                 $rootScope.events.rooms[event.room_id].messages.push(event);
             }
+            
+            if (window.Notification && event.user_id != matrixService.config().user_id) {
+                var shouldBing = $rootScope.containsBingWord(event.content.body);
+            
+                // TODO: Binging every message when idle doesn't make much sense. Can we use this more sensibly?
+                // Unfortunately document.hidden = false on ubuntu chrome if chrome is minimised / does not have focus;
+                // true when you swap tabs though. However, for the case where the chat screen is OPEN and there is
+                // another window on top, we want to be notifying for those events. This DOES mean that there will be
+                // notifications when currently viewing the chat screen though, but that is preferable to the alternative imo.
+                var isIdle = (document.hidden || matrixService.presence.unavailable === mPresence.getState());
+                
+                // always bing if there are 0 bing words... apparently.
+                var bingWords = matrixService.config().bingWords;
+                if (bingWords && bingWords.length === 0) {
+                    shouldBing = true;
+                }
+                
+                if (shouldBing) {
+                    console.log("Displaying notification for "+JSON.stringify(event));
+                    var member = $rootScope.events.rooms[event.room_id].members[event.user_id];
+                    var displayname = undefined;
+                    if (member) {
+                        displayname = member.displayname;
+                    }
+
+                    var message = event.content.body;
+                    if (event.content.msgtype === "m.emote") {
+                        message = "* " + displayname + " " + message;
+                    }
+
+                    var notification = new window.Notification(
+                        (displayname || event.user_id) +
+                        " (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here
+                    {
+                        "body": message,
+                        "icon": member ? member.avatar_url : undefined
+                    });
+                    $timeout(function() {
+                        notification.close();
+                    }, 5 * 1000);
+                }
+            }
         }
         else {
             $rootScope.events.rooms[event.room_id].messages.unshift(event);
@@ -157,8 +243,9 @@ angular.module('eventHandlerService', [])
         // add membership changes as if they were a room message if something interesting changed
         // Exception: Do not do this if the event is a room state event because such events already come
         // as room messages events. Moreover, when they come as room messages events, they are relatively ordered
-        // with other other room messages
-        if (event.content.prev !== event.content.membership && !isStateEvent) {
+        // with other other room messages XXX This is no longer true, you only get a single event, not a room message event.
+        // FIXME: This possibly reintroduces multiple join messages.
+        if (event.content.prev !== event.content.membership) { // && !isStateEvent
             if (isLiveEvent) {
                 $rootScope.events.rooms[event.room_id].messages.push(event);
             }
@@ -204,7 +291,7 @@ angular.module('eventHandlerService', [])
 
     var handleCallEvent = function(event, isLiveEvent) {
         $rootScope.$broadcast(CALL_EVENT, event, isLiveEvent);
-        if (event.type == 'm.call.invite') {
+        if (event.type === 'm.call.invite') {
             $rootScope.events.rooms[event.room_id].messages.push(event);
         }
     };
@@ -231,7 +318,7 @@ angular.module('eventHandlerService', [])
             }
         }
         return index;
-    }
+    };
     
     return {
         ROOM_CREATE_EVENT: ROOM_CREATE_EVENT,
@@ -253,7 +340,9 @@ angular.module('eventHandlerService', [])
 
             // FIXME: /initialSync on a particular room is not yet available
             // So initRoom on a new room is not called. Make sure the room data is initialised here
-            initRoom(event.room_id);
+            if (event.room_id) {
+                initRoom(event.room_id);
+            }
 
             // Avoid duplicated events
             // Needed for rooms where initialSync has not been done. 
@@ -287,6 +376,7 @@ angular.module('eventHandlerService', [])
                         handleMessage(event, isLiveEvent);
                         break;
                     case "m.room.member":
+                        isStateEvent = true;
                         handleRoomMember(event, isLiveEvent, isStateEvent);
                         break;
                     case "m.presence":
@@ -316,19 +406,39 @@ angular.module('eventHandlerService', [])
         // isLiveEvents determines whether notifications should be shown, whether
         // messages get appended to the start/end of lists, etc.
         handleEvents: function(events, isLiveEvents, isStateEvents) {
+            // XXX FIXME TODO: isStateEvents is being left as undefined sometimes. It makes no sense
+            // to have isStateEvents as an arg, since things like m.room.member are ALWAYS state events.
             for (var i=0; i<events.length; i++) {
                 this.handleEvent(events[i], isLiveEvents, isStateEvents);
             }
         },
 
         // Handle messages from /initialSync or /messages
-        handleRoomMessages: function(room_id, messages, isLiveEvents) {
+        handleRoomMessages: function(room_id, messages, isLiveEvents, dir) {
             initRoom(room_id);
-            this.handleEvents(messages.chunk, isLiveEvents);
 
-            // Store how far back we've paginated
-            // This assumes the paginations requests are contiguous and in reverse chronological order
-            $rootScope.events.rooms[room_id].pagination.earliest_token = messages.end;
+            var events = messages.chunk;
+
+            // Handles messages according to their time order
+            if (dir && 'b' === dir) {
+                // paginateBackMessages requests messages to be in reverse chronological order
+                for (var i=0; i<events.length; i++) {
+                    // FIXME: Being live != being state
+                    this.handleEvent(events[i], isLiveEvents, isLiveEvents);
+                }
+                
+                // Store how far back we've paginated
+                $rootScope.events.rooms[room_id].pagination.earliest_token = messages.end;
+            }
+            else {
+                // InitialSync returns messages in chronological order
+                for (var i=events.length - 1; i>=0; i--) {
+                    // FIXME: Being live != being state
+                    this.handleEvent(events[i], isLiveEvents, isLiveEvents);
+                }
+                // Store where to start pagination
+                $rootScope.events.rooms[room_id].pagination.earliest_token = messages.start;
+            }
         },
 
         handleInitialSyncDone: function(initialSyncData) {
@@ -343,6 +453,82 @@ angular.module('eventHandlerService', [])
 
         resetRoomMessages: function(room_id) {
             resetRoomMessages(room_id);
+        },
+        
+        /**
+         * Return the last message event of a room
+         * @param {String} room_id the room id
+         * @param {Boolean} filterFake true to not take into account fake messages
+         * @returns {undefined | Event} the last message event if available
+         */
+        getLastMessage: function(room_id, filterEcho) {
+            var lastMessage;
+
+            var room = $rootScope.events.rooms[room_id];
+            if (room) {
+                for (var i = room.messages.length - 1; i >= 0; i--) {
+                    var message = room.messages[i];
+
+                    if (!filterEcho || undefined === message.echo_msg_state) {
+                        lastMessage = message;
+                        break;
+                    }
+                }
+            }
+
+            return lastMessage;
+        },
+        
+        /**
+         * Compute the room users number, ie the number of members who has joined the room.
+         * @param {String} room_id the room id
+         * @returns {undefined | Number} the room users number if available
+         */
+        getUsersCountInRoom: function(room_id) {
+            var memberCount;
+
+            var room = $rootScope.events.rooms[room_id];
+            if (room) {
+                memberCount = 0;
+
+                for (var i in room.members) {
+                    var member = room.members[i];
+
+                    if ("join" === member.membership) {
+                        memberCount = memberCount + 1;
+                    }
+                }
+            }
+
+            return memberCount;
+        },
+        
+        /**
+         * Get the member object of a room member
+         * @param {String} room_id the room id
+         * @param {String} user_id the id of the user
+         * @returns {undefined | Object} the member object of this user in this room if he is part of the room
+         */
+        getMember: function(room_id, user_id) {
+            var member;
+            
+            var room = $rootScope.events.rooms[room_id];
+            if (room) {
+                member = room.members[user_id];
+            }
+            return member;
+        },
+        
+        setRoomVisibility: function(room_id, visible) {
+            if (!visible) {
+                return;
+            }
+            initRoom(room_id);
+            
+            var room = $rootScope.events.rooms[room_id];
+            if (room) {
+                room.visibility = visible;
+            }
         }
     };
 }]);
diff --git a/webclient/components/matrix/event-stream-service.js b/webclient/components/matrix/event-stream-service.js
index 03b805213d4001cb72d90c03396b14af8ef5bec9..5af1ab2911dbd978aeb7643e348e0903bdae4317 100644
--- a/webclient/components/matrix/event-stream-service.js
+++ b/webclient/components/matrix/event-stream-service.js
@@ -104,8 +104,10 @@ angular.module('eventStreamService', [])
         settings.isActive = true;
         var deferred = $q.defer();
 
-        // Initial sync: get all information and the last message of all rooms of the user
-        matrixService.initialSync(1, false).then(
+        // Initial sync: get all information and the last 30 messages of all rooms of the user
+        // 30 messages should be enough to display a full page of messages in a room
+        // without requiring to make an additional request
+        matrixService.initialSync(30, false).then(
             function(response) {
                 var rooms = response.data.rooms;
                 for (var i = 0; i < rooms.length; ++i) {
@@ -118,6 +120,8 @@ angular.module('eventStreamService', [])
                     if ("state" in room) {
                         eventHandlerService.handleEvents(room.state, false, true);
                     }
+                    
+                    eventHandlerService.setRoomVisibility(room.room_id, room.visibility);
                 }
 
                 var presence = response.data.presence;
diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js
index 2e3e2b096703ecb670fd3810059ca61d2440ea0a..2ecb8b05fff49a232e41db2292d715cef9d585ae 100644
--- a/webclient/components/matrix/matrix-call.js
+++ b/webclient/components/matrix/matrix-call.js
@@ -47,13 +47,19 @@ angular.module('MatrixCall', [])
         this.call_id = "c" + new Date().getTime();
         this.state = 'fledgling';
         this.didConnect = false;
+
+        // a queue for candidates waiting to go out. We try to amalgamate candidates into a single candidate message where possible
+        this.candidateSendQueue = [];
+        this.candidateSendTries = 0;
     }
 
+    MatrixCall.CALL_TIMEOUT = 60000;
+
     MatrixCall.prototype.createPeerConnection = function() {
         var stunServer = 'stun:stun.l.google.com:19302';
         var pc;
         if (window.mozRTCPeerConnection) {
-            pc = window.mozRTCPeerConnection({'url': stunServer});
+            pc = new window.mozRTCPeerConnection({'url': stunServer});
         } else {
             pc = new window.RTCPeerConnection({"iceServers":[{"urls":"stun:stun.l.google.com:19302"}]});
         }
@@ -74,12 +80,30 @@ angular.module('MatrixCall', [])
         this.config = config;
     };
 
-    MatrixCall.prototype.initWithInvite = function(msg) {
-        this.msg = msg;
+    MatrixCall.prototype.initWithInvite = function(event) {
+        this.msg = event.content;
         this.peerConn = this.createPeerConnection();
         this.peerConn.setRemoteDescription(new RTCSessionDescription(this.msg.offer), this.onSetRemoteDescriptionSuccess, this.onSetRemoteDescriptionError);
         this.state = 'ringing';
         this.direction = 'inbound';
+        var self = this;
+        $timeout(function() {
+            if (self.state == 'ringing') {
+                self.state = 'ended';
+                self.hangupParty = 'remote'; // effectively
+                self.stopAllMedia();
+                if (self.peerConn.signalingState != 'closed') self.peerConn.close();
+                if (self.onHangup) self.onHangup(self);
+            }
+        }, this.msg.lifetime - event.age);
+    };
+
+    // perverse as it may seem, sometimes we want to instantiate a call with a hangup message
+    // (because when getting the state of the room on load, events come in reverse order and
+    // we want to remember that a call has been hung up)
+    MatrixCall.prototype.initWithHangup = function(event) {
+        this.msg = event.content;
+        this.state = 'ended';
     };
 
     MatrixCall.prototype.answer = function() {
@@ -174,12 +198,7 @@ angular.module('MatrixCall', [])
     MatrixCall.prototype.gotLocalIceCandidate = function(event) {
         console.log(event);
         if (event.candidate) {
-            var content = {
-                version: 0,
-                call_id: this.call_id,
-                candidate: event.candidate
-            };
-            this.sendEventWithRetry('m.call.candidate', content);
+            this.sendCandidate(event.candidate);
         }
     }
 
@@ -189,14 +208,12 @@ angular.module('MatrixCall', [])
             console.log("Ignoring remote ICE candidate because call has ended");
             return;
         }
-        var candidateObject = new RTCIceCandidate({
-            sdpMLineIndex: cand.label,
-            candidate: cand.candidate
-        });
-        this.peerConn.addIceCandidate(candidateObject, function() {}, function(e) {});
+        this.peerConn.addIceCandidate(new RTCIceCandidate(cand), function() {}, function(e) {});
     };
 
     MatrixCall.prototype.receivedAnswer = function(msg) {
+        if (this.state == 'ended') return;
+
         this.peerConn.setRemoteDescription(new RTCSessionDescription(msg.answer), this.onSetRemoteDescriptionSuccess, this.onSetRemoteDescriptionError);
         this.state = 'connecting';
     };
@@ -214,11 +231,19 @@ angular.module('MatrixCall', [])
         var content = {
             version: 0,
             call_id: this.call_id,
-            offer: description
+            offer: description,
+            lifetime: MatrixCall.CALL_TIMEOUT
         };
         this.sendEventWithRetry('m.call.invite', content);
 
         var self = this;
+        $timeout(function() {
+            if (self.state == 'invite_sent') {
+                self.hangupReason = 'invite_timeout';
+                self.hangup();
+            }
+        }, MatrixCall.CALL_TIMEOUT);
+
         $rootScope.$apply(function() {
             self.state = 'invite_sent';
         });
@@ -370,5 +395,53 @@ angular.module('MatrixCall', [])
         }, delayMs);
     };
 
+    // Sends candidates with are sent in a special way because we try to amalgamate them into one message
+    MatrixCall.prototype.sendCandidate = function(content) {
+        this.candidateSendQueue.push(content);
+        var self = this;
+        if (this.candidateSendTries == 0) $timeout(function() { self.sendCandidateQueue(); }, 100);
+    };
+
+    MatrixCall.prototype.sendCandidateQueue = function(content) {
+        if (this.candidateSendQueue.length == 0) return;
+
+        var cands = this.candidateSendQueue;
+        this.candidateSendQueue = [];
+        ++this.candidateSendTries;
+        var content = {
+            version: 0,
+            call_id: this.call_id,
+            candidates: cands
+        };
+        var self = this;
+        console.log("Attempting to send "+cands.length+" candidates");
+        matrixService.sendEvent(self.room_id, 'm.call.candidates', undefined, content).then(function() { self.candsSent(); }, function(error) { self.candsSendFailed(cands, error); } );
+    };
+
+    MatrixCall.prototype.candsSent = function() {
+        this.candidateSendTries = 0;
+        this.sendCandidateQueue();
+    };
+
+    MatrixCall.prototype.candsSendFailed = function(cands, error) {
+        for (var i = 0; i < cands.length; ++i) {
+            this.candidateSendQueue.push(cands[i]);
+        }
+
+        if (this.candidateSendTries > 5) {
+            console.log("Failed to send candidates on attempt "+ev.tries+". Giving up for now.");
+            this.candidateSendTries = 0;
+            return;
+        }
+
+        var delayMs = 500 * Math.pow(2, this.candidateSendTries);
+        ++this.candidateSendTries;
+        console.log("Failed to send candidates. Retrying in "+delayMs+"ms");
+        var self = this;
+        $timeout(function() {
+            self.sendCandidateQueue();
+        }, delayMs);
+    };
+
     return MatrixCall;
 }]);
diff --git a/webclient/components/matrix/matrix-filter.js b/webclient/components/matrix/matrix-filter.js
index 015a88bcad23865bfb5caa0460fa2412e90588e1..8b168cdedbe4a89498dbc61ec1256b98be3a6d62 100644
--- a/webclient/components/matrix/matrix-filter.js
+++ b/webclient/components/matrix/matrix-filter.js
@@ -26,72 +26,74 @@ angular.module('matrixFilter', [])
         // If there is an alias, use it
         // TODO: only one alias is managed for now
         var alias = matrixService.getRoomIdToAliasMapping(room_id);
-        if (alias) {
-            roomName = alias;
-        }
-
-        if (undefined === roomName) {
 
-            var room = $rootScope.events.rooms[room_id];
-            if (room) {
-                // Get name from room state date
-                var room_name_event = room["m.room.name"];
-                if (room_name_event) {
-                    roomName = room_name_event.content.name;
-                }
-                else if (room.members) {
-                    // Else, build the name from its users
-                    // FIXME: Is it still required?
-                    // Limit the room renaming to 1:1 room
-                    if (2 === Object.keys(room.members).length) {
-                        for (var i in room.members) {
-                            var member = room.members[i];
-                            if (member.state_key !== matrixService.config().user_id) {
+        var room = $rootScope.events.rooms[room_id];
+        if (room) {
+            // Get name from room state date
+            var room_name_event = room["m.room.name"];
+            if (room_name_event) {
+                roomName = room_name_event.content.name;
+            }
+            else if (alias) {
+                roomName = alias;
+            }
+            else if (room.members) {
+                // Else, build the name from its users
+                // FIXME: Is it still required?
+                // Limit the room renaming to 1:1 room
+                if (2 === Object.keys(room.members).length) {
+                    for (var i in room.members) {
+                        var member = room.members[i];
+                        if (member.state_key !== matrixService.config().user_id) {
 
-                                if (member.state_key in $rootScope.presence) {
-                                    // If the user is available in presence, use the displayname there
-                                    // as it is the most uptodate
-                                    roomName = $rootScope.presence[member.state_key].content.displayname;
-                                }
-                                else if (member.content.displayname) {
-                                    roomName = member.content.displayname;
-                                }
-                                else {
-                                    roomName = member.state_key;
-                                }
+                            if (member.state_key in $rootScope.presence) {
+                                // If the user is available in presence, use the displayname there
+                                // as it is the most uptodate
+                                roomName = $rootScope.presence[member.state_key].content.displayname;
                             }
-                        }
-                    }
-                    else if (1 === Object.keys(room.members).length) {
-                        // The other member may be in the invite list, get all invited users
-                        var invitedUserIDs = [];
-                        for (var i in room.messages) {
-                            var message = room.messages[i];
-                            if ("m.room.member" === message.type && "invite" === message.membership) {
-                                // Make sure there is no duplicate user
-                                if (-1 === invitedUserIDs.indexOf(message.state_key)) {
-                                    invitedUserIDs.push(message.state_key);
-                                }
-                            } 
-                        }
-                        
-                        // For now, only 1:1 room needs to be renamed. It means only 1 invited user
-                        if (1 === invitedUserIDs.length) {
-                            var userID = invitedUserIDs[0];
-
-                            // Try to resolve his displayname in presence global data
-                            if (userID in $rootScope.presence) {
-                                roomName = $rootScope.presence[userID].content.displayname;
+                            else if (member.content.displayname) {
+                                roomName = member.content.displayname;
                             }
                             else {
-                                roomName = userID;
+                                roomName = member.state_key;
                             }
                         }
                     }
                 }
+                else if (1 === Object.keys(room.members).length) {
+                    // The other member may be in the invite list, get all invited users
+                    var invitedUserIDs = [];
+                    for (var i in room.messages) {
+                        var message = room.messages[i];
+                        if ("m.room.member" === message.type && "invite" === message.membership) {
+                            // Make sure there is no duplicate user
+                            if (-1 === invitedUserIDs.indexOf(message.state_key)) {
+                                invitedUserIDs.push(message.state_key);
+                            }
+                        } 
+                    }
+
+                    // For now, only 1:1 room needs to be renamed. It means only 1 invited user
+                    if (1 === invitedUserIDs.length) {
+                        var userID = invitedUserIDs[0];
+
+                        // Try to resolve his displayname in presence global data
+                        if (userID in $rootScope.presence) {
+                            roomName = $rootScope.presence[userID].content.displayname;
+                        }
+                        else {
+                            roomName = userID;
+                        }
+                    }
+                }
             }
         }
 
+        // Always show the alias in the room displayed name
+        if (roomName && alias && alias !== roomName) {
+            roomName += " (" + alias + ")";
+        }
+
         if (undefined === roomName) {
             // By default, use the room ID
             roomName = room_id;
diff --git a/webclient/components/matrix/matrix-phone-service.js b/webclient/components/matrix/matrix-phone-service.js
index b0dcf191002d70ac6871159f98410c52720abb71..d05eecf72a6f65c2734bc4e007302e7b92ebb250 100644
--- a/webclient/components/matrix/matrix-phone-service.js
+++ b/webclient/components/matrix/matrix-phone-service.js
@@ -24,22 +24,52 @@ angular.module('matrixPhoneService', [])
     matrixPhoneService.INCOMING_CALL_EVENT = "INCOMING_CALL_EVENT";
     matrixPhoneService.REPLACED_CALL_EVENT = "REPLACED_CALL_EVENT";
     matrixPhoneService.allCalls = {};
+    // a place to save candidates that come in for calls we haven't got invites for yet (when paginating backwards)
+    matrixPhoneService.candidatesByCall = {};
 
     matrixPhoneService.callPlaced = function(call) {
         matrixPhoneService.allCalls[call.call_id] = call;
     };
 
     $rootScope.$on(eventHandlerService.CALL_EVENT, function(ngEvent, event, isLive) {
-        if (!isLive) return; // until matrix supports expiring messages
         if (event.user_id == matrixService.config().user_id) return;
+
         var msg = event.content;
+
         if (event.type == 'm.call.invite') {
+            if (event.age == undefined || msg.lifetime == undefined) {
+                // if the event doesn't have either an age (the HS is too old) or a lifetime
+                // (the sending client was too old when it sent it) then fall back to old behaviour
+                if (!isLive) return; // until matrix supports expiring messages
+            }
+
+            if (event.age > msg.lifetime) {
+                console.log("Ignoring expired call event of type "+event.type);
+                return;
+            }
+
+            var call = undefined;
+            if (!isLive) {
+                // if this event wasn't live then this call may already be over
+                call = matrixPhoneService.allCalls[msg.call_id];
+                if (call && call.state == 'ended') {
+                    return;
+                }
+            }
+
             var MatrixCall = $injector.get('MatrixCall');
             var call = new MatrixCall(event.room_id);
             call.call_id = msg.call_id;
-            call.initWithInvite(msg);
+            call.initWithInvite(event);
             matrixPhoneService.allCalls[call.call_id] = call;
 
+            // if we stashed candidate events for that call ID, play them back now
+            if (!isLive && matrixPhoneService.candidatesByCall[call.call_id] != undefined) {
+                for (var i = 0; i < matrixPhoneService.candidatesByCall[call.call_id].length; ++i) {
+                    call.gotRemoteIceCandidate(matrixPhoneService.candidatesByCall[call.call_id][i]);
+                }
+            }
+
             // Were we trying to call that user (room)?
             var existingCall;
             var callIds = Object.keys(matrixPhoneService.allCalls);
@@ -77,21 +107,37 @@ angular.module('matrixPhoneService', [])
                 return;
             }
             call.receivedAnswer(msg);
-        } else if (event.type == 'm.call.candidate') {
+        } else if (event.type == 'm.call.candidates') {
             var call = matrixPhoneService.allCalls[msg.call_id];
-            if (!call) {
-                console.log("Got candidate for unknown call ID "+msg.call_id);
+            if (!call && isLive) {
+                console.log("Got candidates for unknown call ID "+msg.call_id);
                 return;
+            } else if (!call) {
+                if (matrixPhoneService.candidatesByCall[msg.call_id] == undefined) {
+                    matrixPhoneService.candidatesByCall[msg.call_id] = [];
+                }
+                matrixPhoneService.candidatesByCall[msg.call_id] = matrixPhoneService.candidatesByCall[msg.call_id].concat(msg.candidates);
+            } else {
+                for (var i = 0; i < msg.candidates.length; ++i) {
+                    call.gotRemoteIceCandidate(msg.candidates[i]);
+                }
             }
-            call.gotRemoteIceCandidate(msg.candidate);
         } else if (event.type == 'm.call.hangup') {
             var call = matrixPhoneService.allCalls[msg.call_id];
-            if (!call) {
+            if (!call && isLive) {
                 console.log("Got hangup for unknown call ID "+msg.call_id);
-                return;
+            } else if (!call) {
+                // if not live, store the fact that the call has ended because we're probably getting events backwards so
+                // the hangup will come before the invite
+                var MatrixCall = $injector.get('MatrixCall');
+                var call = new MatrixCall(event.room_id);
+                call.call_id = msg.call_id;
+                call.initWithHangup(event);
+                matrixPhoneService.allCalls[msg.call_id] = call;
+            } else {
+                call.onHangupReceived();
+                delete(matrixPhoneService.allCalls[msg.call_id]);
             }
-            call.onHangupReceived();
-            delete(matrixPhoneService.allCalls[msg.call_id]);
         }
     });
     
diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js
index 68ef16800bbaa101b6d83bb7b73c06843614e359..069e02e939ef6b265897429b96c8252fd5caa1dc 100644
--- a/webclient/components/matrix/matrix-service.js
+++ b/webclient/components/matrix/matrix-service.js
@@ -81,38 +81,155 @@ angular.module('matrixService', [])
 
         return $http(request);
     };
+    
+    var doRegisterLogin = function(path, loginType, sessionId, userName, password, threepidCreds) {
+        var data = {};
+        if (loginType === "m.login.recaptcha") {
+            var challengeToken = Recaptcha.get_challenge();
+            var captchaEntry = Recaptcha.get_response();
+            data = {
+                type: "m.login.recaptcha",
+                challenge: challengeToken,
+                response: captchaEntry
+            };
+        }
+        else if (loginType === "m.login.email.identity") {
+            data = {
+                threepidCreds: threepidCreds
+            };
+        }
+        else if (loginType === "m.login.password") {
+            data = {
+                user: userName,
+                password: password
+            };
+        }
+        
+        if (sessionId) {
+            data.session = sessionId;
+        }
+        data.type = loginType;
+        console.log("doRegisterLogin >>> " + loginType);
+        return doRequest("POST", path, undefined, data);
+    };
 
     return {
         /****** Home server API ******/
         prefix: prefixPath,
 
         // Register an user
-        register: function(user_name, password, threepidCreds, useCaptcha) {         
-            // The REST path spec
+        register: function(user_name, password, threepidCreds, useCaptcha) {
+            // registration is composed of multiple requests, to check you can
+            // register, then to actually register. This deferred will fire when
+            // all the requests are done, along with the final response.
+            var deferred = $q.defer();
             var path = "/register";
             
-            var data = {
-                 user_id: user_name,
-                 password: password,
-                 threepidCreds: threepidCreds
-            };
+            // check we can actually register with this HS.
+            doRequest("GET", path, undefined, undefined).then(
+                function(response) {
+                    console.log("/register [1] : "+JSON.stringify(response));
+                    var flows = response.data.flows;
+                    var knownTypes = [
+                        "m.login.password",
+                        "m.login.recaptcha",
+                        "m.login.email.identity"
+                    ];
+                    // if they entered 3pid creds, we want to use a flow which uses it.
+                    var useThreePidFlow = threepidCreds != undefined;
+                    var flowIndex = 0;
+                    var firstRegType = undefined;
+                    
+                    for (var i=0; i<flows.length; i++) {
+                        var isThreePidFlow = false;
+                        if (flows[i].stages) {
+                            for (var j=0; j<flows[i].stages.length; j++) {
+                                var regType = flows[i].stages[j];
+                                if (knownTypes.indexOf(regType) === -1) {
+                                    deferred.reject("Unknown type: "+regType);
+                                    return;
+                                }
+                                if (regType == "m.login.email.identity") {
+                                    isThreePidFlow = true;
+                                }
+                                if (!useCaptcha && regType == "m.login.recaptcha") {
+                                    console.error("Web client setup to not use captcha, but HS demands a captcha.");
+                                    deferred.reject({
+                                        data: {
+                                            errcode: "M_CAPTCHA_NEEDED",
+                                            error: "Home server requires a captcha."
+                                        }
+                                    });
+                                    return;
+                                }
+                            }
+                        }
+                        
+                        if ( (isThreePidFlow && useThreePidFlow) || (!isThreePidFlow && !useThreePidFlow) ) {
+                            flowIndex = i;
+                        }
+                        
+                        if (knownTypes.indexOf(flows[i].type) == -1) {
+                            deferred.reject("Unknown type: "+flows[i].type);
+                            return;
+                        }
+                    }
+                    
+                    // looks like we can register fine, go ahead and do it.
+                    console.log("Using flow " + JSON.stringify(flows[flowIndex]));
+                    firstRegType = flows[flowIndex].type;
+                    var sessionId = undefined;
+                    
+                    // generic response processor so it can loop as many times as required
+                    var loginResponseFunc = function(response) {
+                        if (response.data.session) {
+                            sessionId = response.data.session;
+                        }
+                        console.log("login response: " + JSON.stringify(response.data));
+                        if (response.data.access_token) {
+                            deferred.resolve(response);
+                        }
+                        else if (response.data.next) {
+                            var nextType = response.data.next;
+                            if (response.data.next instanceof Array) {
+                                for (var i=0; i<response.data.next.length; i++) {
+                                    if (useThreePidFlow && response.data.next[i] == "m.login.email.identity") {
+                                        nextType = response.data.next[i];
+                                        break;
+                                    }
+                                    else if (!useThreePidFlow && response.data.next[i] != "m.login.email.identity") {
+                                        nextType = response.data.next[i];
+                                        break;
+                                    }
+                                }
+                            }
+                            return doRegisterLogin(path, nextType, sessionId, user_name, password, threepidCreds).then(
+                                loginResponseFunc,
+                                function(err) {
+                                    deferred.reject(err);
+                                }
+                            );
+                        }
+                        else {
+                            deferred.reject("Unknown continuation: "+JSON.stringify(response));
+                        }
+                    };
+                    
+                    // set the ball rolling
+                    doRegisterLogin(path, firstRegType, undefined, user_name, password, threepidCreds).then(
+                        loginResponseFunc,
+                        function(err) {
+                            deferred.reject(err);
+                        }
+                    );
+                    
+                },
+                function(err) {
+                    deferred.reject(err);
+                }
+            );
             
-            if (useCaptcha) {
-                // Not all home servers will require captcha on signup, but if this flag is checked,
-                // send captcha information.
-                // TODO: Might be nice to make this a bit more flexible..
-                var challengeToken = Recaptcha.get_challenge();
-                var captchaEntry = Recaptcha.get_response();
-                var captchaType = "m.login.recaptcha";
-                
-                data.captcha = {
-                    type: captchaType,
-                    challenge: challengeToken,
-                    response: captchaEntry
-                };
-            }   
-
-            return doRequest("POST", path, undefined, data);
+            return deferred.promise;
         },
 
         // Create a room
diff --git a/webclient/home/home-controller.js b/webclient/home/home-controller.js
index c0c4ea11aa6878cd8250fca8493f4e11410d6238..e35219bebbb3cc0d663234e8994cb29333c017e3 100644
--- a/webclient/home/home-controller.js
+++ b/webclient/home/home-controller.js
@@ -53,6 +53,8 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
 
                     // Add room_alias & room_display_name members
                     angular.extend(room, matrixService.getRoomAliasAndDisplayName(room));
+                    
+                    eventHandlerService.setRoomVisibility(room.room_id, "public");
                 }
             }
         );
@@ -117,6 +119,10 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen
         matrixService.getDisplayName($scope.config.user_id).then(
             function(response) {
                 $scope.profile.displayName = response.data.displayname;
+                var config = matrixService.config();
+                config.display_name = response.data.displayname;
+                matrixService.setConfig(config);
+                matrixService.saveConfig();
             },
             function(error) {
                 $scope.feedback = "Can't load display name";
diff --git a/webclient/index.html b/webclient/index.html
index 9eea08215cb48bf5fc6e9494e15346bda4e8f733..7e4dcb83452e9aaf44cebbcacd4b23fa780ca88b 100644
--- a/webclient/index.html
+++ b/webclient/index.html
@@ -62,7 +62,8 @@
                         <span ng-show="currentCall.state == 'connecting'">Call Connecting...</span>
                         <span ng-show="currentCall.state == 'connected'">Call Connected</span>
                         <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'remote'">Call Rejected</span>
-                        <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local'">Call Canceled</span>
+                        <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local' && currentCall.hangupReason == undefined">Call Canceled</span>
+                        <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local' && currentCall.hangupReason == 'invite_timeout'">User Not Responding</span>
                         <span ng-show="currentCall.state == 'ended' && currentCall.didConnect && currentCall.direction == 'outbound'">Call Ended</span>
                         <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'inbound'">Call Canceled</span>
                         <span ng-show="currentCall.state == 'ended' && currentCall.didConnect && currentCall.direction == 'inbound'">Call Ended</span>
diff --git a/webclient/recents/recents-controller.js b/webclient/recents/recents-controller.js
index a0db0538f3f50c22a5e5f7ad6832c27610ede365..ee8a41c36678d19538e7b88b88ebf42f03405115 100644
--- a/webclient/recents/recents-controller.js
+++ b/webclient/recents/recents-controller.js
@@ -16,134 +16,16 @@
 
 'use strict';
 
-angular.module('RecentsController', ['matrixService', 'matrixFilter', 'eventHandlerService'])
-.controller('RecentsController', ['$rootScope', '$scope', 'matrixService', 'eventHandlerService', 
-                               function($rootScope, $scope, matrixService, eventHandlerService) {
-                                   
-    // FIXME: Angularjs reloads the controller (and resets its $scope) each time
-    // the page URL changes, use $rootScope to avoid to have to reload data
-    $rootScope.rooms;
+angular.module('RecentsController', ['matrixService', 'matrixFilter'])
+.controller('RecentsController', ['$rootScope', '$scope', 'eventHandlerService', 
+                               function($rootScope, $scope, eventHandlerService) {
+
+    // Expose the service to the view
+    $scope.eventHandlerService = eventHandlerService;
 
     // $rootScope of the parent where the recents component is included can override this value
     // in order to highlight a specific room in the list
     $rootScope.recentsSelectedRoomID;
-    
-    var listenToEventStream = function() {
-        // Refresh the list on matrix invitation and message event
-        $rootScope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) {
-            if (isLive) {
-                if (!$rootScope.rooms[event.room_id]) {
-                    // The user has joined a new room, which we do not have data yet. The reason is that
-                    // the room has appeared in the scope of the user rooms after the global initialSync
-                    // FIXME: an initialSync on this specific room should be done
-                    $rootScope.rooms[event.room_id] = {
-                        room_id:event.room_id
-                    };
-                }
-                else if (event.state_key === matrixService.config().user_id && "invite" !== event.membership && "join" !== event.membership) {
-                    // The user has been kicked or banned from the room, remove this room from the recents
-                    delete $rootScope.rooms[event.room_id];
-                }
-                
-                if ($rootScope.rooms[event.room_id]) {
-                    $rootScope.rooms[event.room_id].lastMsg = event;
-                }
-                
-                // Update room users count
-                $rootScope.rooms[event.room_id].numUsersInRoom = getUsersCountInRoom(event.room_id);
-            }
-        });
-        $rootScope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) {
-            if (isLive) {
-                $rootScope.rooms[event.room_id].lastMsg = event;              
-            }
-        });
-        $rootScope.$on(eventHandlerService.CALL_EVENT, function(ngEvent, event, isLive) {
-            if (isLive) {
-                $rootScope.rooms[event.room_id].lastMsg = event;
-            }
-        });
-        $rootScope.$on(eventHandlerService.ROOM_CREATE_EVENT, function(ngEvent, event, isLive) {
-            if (isLive) {
-                $rootScope.rooms[event.room_id] = event;
-            }
-        });
-        $rootScope.$on(eventHandlerService.NAME_EVENT, function(ngEvent, event, isLive) {
-            if (isLive) {
-                $rootScope.rooms[event.room_id].lastMsg = event;
-            }
-        });
-        $rootScope.$on(eventHandlerService.TOPIC_EVENT, function(ngEvent, event, isLive) {
-            if (isLive) {
-                $rootScope.rooms[event.room_id].lastMsg = event;
-            }
-        });
-    };
-    
-    /**
-     * Compute the room users number, ie the number of members who has joined the room.
-     * @param {String} room_id the room id
-     * @returns {undefined | Number} the room users number if available
-     */
-    var getUsersCountInRoom = function(room_id) {
-        var memberCount;
-        
-        var room = $rootScope.events.rooms[room_id];
-        if (room) {
-            memberCount = 0;
-            
-            for (var i in room.members) {
-                var member = room.members[i];
-                
-                if ("join" === member.membership) {
-                    memberCount = memberCount + 1;
-                }
-            }
-        }
-        
-        return memberCount;
-    };
-
-    $scope.onInit = function() {
-        // Init recents list only once
-        if ($rootScope.rooms) {
-            return;
-        }
-        
-        $rootScope.rooms = {};
-        
-        // Use initialSync data to init the recents list
-        eventHandlerService.waitForInitialSyncCompletion().then(
-            function(initialSyncData) {
-            
-                var rooms = initialSyncData.data.rooms;
-                for (var i=0; i<rooms.length; i++) {
-                    var room = rooms[i];
-                    
-                    // Add room_alias & room_display_name members
-                    $rootScope.rooms[room.room_id] = angular.extend(room, matrixService.getRoomAliasAndDisplayName(room));
-
-                    // Create a shortcut for the last message of this room
-                    if (room.messages && room.messages.chunk && room.messages.chunk[0]) {
-                        $rootScope.rooms[room.room_id].lastMsg = room.messages.chunk[0];
-                    }
-                    
-                    $rootScope.rooms[room.room_id].numUsersInRoom = getUsersCountInRoom(room.room_id);
-                }
-
-                // From now, update recents from the stream
-                listenToEventStream();
-            },
-            function(error) {
-                $rootScope.feedback = "Failure: " + error.data;
-            }
-        );
-    };
-
-    // Clean data when user logs out
-    $scope.$on(eventHandlerService.RESET_EVENT, function() {
 
-        delete $rootScope.rooms;
-    });
 }]);
 
diff --git a/webclient/recents/recents-filter.js b/webclient/recents/recents-filter.js
index d80de6fbeb63197d0a6fe80f62a4347e575b6a16..d948205e191a6c7064368a5b95f764fa9a610caf 100644
--- a/webclient/recents/recents-filter.js
+++ b/webclient/recents/recents-filter.js
@@ -17,31 +17,47 @@
 'use strict';
 
 angular.module('RecentsController')
-.filter('orderRecents', function() {
+.filter('orderRecents', ["matrixService", "eventHandlerService", function(matrixService, eventHandlerService) {
     return function(rooms) {
 
+        var user_id = matrixService.config().user_id;
+
         // Transform the dict into an array
         // The key, room_id, is already in value objects
         var filtered = [];
-        angular.forEach(rooms, function(value, key) {
-            filtered.push( value );
+        angular.forEach(rooms, function(room, room_id) {
+
+            // Show the room only if the user has joined it or has been invited
+            // (ie, do not show it if he has been banned)
+            var member = eventHandlerService.getMember(room_id, user_id);
+            if (member && ("invite" === member.membership || "join" === member.membership)) {
+            
+                // Count users here
+                // TODO: Compute it directly in eventHandlerService
+                room.numUsersInRoom = eventHandlerService.getUsersCountInRoom(room_id);
+            }
+            filtered.push(room);
         });
 
         // And time sort them
         // The room with the lastest message at first
-        filtered.sort(function (a, b) {
+        filtered.sort(function (roomA, roomB) {
+
+            var lastMsgRoomA = eventHandlerService.getLastMessage(roomA.room_id, true);
+            var lastMsgRoomB = eventHandlerService.getLastMessage(roomB.room_id, true);
+
             // Invite message does not have a body message nor ts
             // Puth them at the top of the list
-            if (undefined === a.lastMsg) {
+            if (undefined === lastMsgRoomA) {
                 return -1;
             }
-            else if (undefined === b.lastMsg) {
+            else if (undefined === lastMsgRoomB) {
                 return 1;
             }
             else {
-                return b.lastMsg.ts - a.lastMsg.ts;
+                return lastMsgRoomB.ts - lastMsgRoomA.ts;
             }
         });
         return filtered;
     };
-});
\ No newline at end of file
+}]);
diff --git a/webclient/recents/recents.html b/webclient/recents/recents.html
index 3d736b6694a7be0e93676289ac9bc6dd30b16ee3..edfc1677ebd429e83ef2d5edba98c84c01fa1e41 100644
--- a/webclient/recents/recents.html
+++ b/webclient/recents/recents.html
@@ -1,20 +1,24 @@
-<div ng-controller="RecentsController" data-ng-init="onInit()">
+<div ng-controller="RecentsController">
     <table class="recentsTable">
-        <tbody ng-repeat="(rm_id, room) in rooms | orderRecents" 
+        <tbody ng-repeat="(index, room) in events.rooms | orderRecents" 
                ng-click="goToPage('room/' + (room.room_alias ? room.room_alias : room.room_id) )" 
                class ="recentsRoom" 
-               ng-class="{'recentsRoomSelected': (room.room_id === recentsSelectedRoomID)}">
+               ng-class="{'recentsRoomSelected': (room.room_id === recentsSelectedRoomID)}">                                           
             <tr>
-                <td class="recentsRoomName">
+                <td ng-class="room['m.room.join_rules'].content.join_rule == 'public' ? 'recentsRoomName recentsPublicRoom' : 'recentsRoomName'">
                     {{ room.room_id | mRoomName }}
                 </td>
-                <td class="recentsRoomSummaryTS">
+                <td class="recentsRoomSummaryUsersCount">
                     <span ng-show="undefined !== room.numUsersInRoom">
                         {{ room.numUsersInRoom || '1' }} {{ room.numUsersInRoom == 1 ? 'user' : 'users' }}                     
                     </span>
                 </td>
                 <td class="recentsRoomSummaryTS">
-                    {{ (room.lastMsg.ts) | date:'MMM d HH:mm' }}
+                    <!-- Use a temp var as alias to the last room message.
+                         Declaring it in this way ensures the data-binding -->
+                    {{ lastMsg = eventHandlerService.getLastMessage(room.room_id, true);"" }}
+
+                    {{ (lastMsg.ts) | date:'MMM d HH:mm' }}
                 </td>
             </tr>
 
@@ -22,70 +26,70 @@
                 <td colspan="3" class="recentsRoomSummary">
 
                     <div ng-show="room.membership === 'invite'">
-                        {{ room.lastMsg.inviter | mUserDisplayName: room.room_id }} invited you
+                        {{ room.inviter | mUserDisplayName: room.room_id }} invited you
                     </div>
                     
-                    <div ng-hide="room.membership === 'invite'" ng-switch="room.lastMsg.type">
+                    <div ng-hide="room.membership === 'invite'" ng-switch="lastMsg.type">
                         <div ng-switch-when="m.room.member">
-                            <span ng-if="'join' === room.lastMsg.content.membership">
-                                {{ room.lastMsg.state_key | mUserDisplayName: room.room_id}} joined
+                            <span ng-if="'join' === lastMsg.content.membership">
+                                {{ lastMsg.state_key | mUserDisplayName: room.room_id}} joined
                             </span>
-                            <span ng-if="'leave' === room.lastMsg.content.membership">
-                                <span ng-if="room.lastMsg.user_id === room.lastMsg.state_key">
-                                    {{room.lastMsg.state_key | mUserDisplayName: room.room_id }} left
+                            <span ng-if="'leave' === lastMsg.content.membership">
+                                <span ng-if="lastMsg.user_id === lastMsg.state_key">
+                                    {{lastMsg.state_key | mUserDisplayName: room.room_id }} left
                                 </span>
-                                <span ng-if="room.lastMsg.user_id !== room.lastMsg.state_key">
-                                    {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }}
-                                    {{ {"join": "kicked", "ban": "unbanned"}[room.lastMsg.content.prev] }}
-                                    {{ room.lastMsg.state_key | mUserDisplayName: room.room_id }}
+                                <span ng-if="lastMsg.user_id !== lastMsg.state_key">
+                                    {{ lastMsg.user_id | mUserDisplayName: room.room_id }}
+                                    {{ {"join": "kicked", "ban": "unbanned"}[lastMsg.content.prev] }}
+                                    {{ lastMsg.state_key | mUserDisplayName: room.room_id }}
                                 </span>
-                                <span ng-if="'join' === room.lastMsg.content.prev && room.lastMsg.content.reason">
-                                    : {{ room.lastMsg.content.reason }}
+                                <span ng-if="'join' === lastMsg.content.prev && lastMsg.content.reason">
+                                    : {{ lastMsg.content.reason }}
                                 </span>
                             </span>
-                            <span ng-if="'invite' === room.lastMsg.content.membership || 'ban' === room.lastMsg.content.membership">
-                                {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }}
-                                {{ {"invite": "invited", "ban": "banned"}[room.lastMsg.content.membership] }}
-                                {{ room.lastMsg.state_key | mUserDisplayName: room.room_id }}
-                                <span ng-if="'ban' === room.lastMsg.content.prev && room.lastMsg.content.reason">
-                                    : {{ room.lastMsg.content.reason }}
+                            <span ng-if="'invite' === lastMsg.content.membership || 'ban' === lastMsg.content.membership">
+                                {{ lastMsg.user_id | mUserDisplayName: room.room_id }}
+                                {{ {"invite": "invited", "ban": "banned"}[lastMsg.content.membership] }}
+                                {{ lastMsg.state_key | mUserDisplayName: room.room_id }}
+                                <span ng-if="'ban' === lastMsg.content.prev && lastMsg.content.reason">
+                                    : {{ lastMsg.content.reason }}
                                 </span>
                             </span>
                         </div>
 
                         <div ng-switch-when="m.room.message">
-                            <div ng-switch="room.lastMsg.content.msgtype">
+                            <div ng-switch="lastMsg.content.msgtype">
                                 <div ng-switch-when="m.text">
-                                    {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} :
-                                    <span ng-bind-html="(room.lastMsg.content.body) | linky:'_blank'">
+                                    {{ lastMsg.user_id | mUserDisplayName: room.room_id }} :
+                                    <span ng-bind-html="(lastMsg.content.body) | linky:'_blank'">
                                     </span>
                                 </div>
 
                                 <div ng-switch-when="m.image">
-                                    {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} sent an image
+                                    {{ lastMsg.user_id | mUserDisplayName: room.room_id }} sent an image
                                 </div>
 
                                 <div ng-switch-when="m.emote">
-                                    <span ng-bind-html="'* ' + (room.lastMsg.user_id | mUserDisplayName: room.room_id) + ' ' + room.lastMsg.content.body | linky:'_blank'">
+                                    <span ng-bind-html="'* ' + (lastMsg.user_id | mUserDisplayName: room.room_id) + ' ' + lastMsg.content.body | linky:'_blank'">
                                     </span>
                                 </div>
 
                                 <div ng-switch-default>
-                                    {{ room.lastMsg.content }}
+                                    {{ lastMsg.content }}
                                 </div>
                             </div>
                         </div>
 
                         <div ng-switch-when="m.room.topic">
-                            {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} changed the topic to: {{ room.lastMsg.content.topic }}
+                            {{ lastMsg.user_id | mUserDisplayName: room.room_id }} changed the topic to: {{ lastMsg.content.topic }}
                         </div>
 
                         <div ng-switch-when="m.room.name">
-                            {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} changed the room name to: {{ room.lastMsg.content.name }}
+                            {{ lastMsg.user_id | mUserDisplayName: room.room_id }} changed the room name to: {{ lastMsg.content.name }}
                         </div>
 
                         <div ng-switch-default>
-                            <div ng-if="room.lastMsg.type.indexOf('m.call.') === 0">
+                            <div ng-if="lastMsg.type.indexOf('m.call.') === 0">
                                 Call
                             </div>
                         </div>
diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js
index 50d902ae4763ef2b247a8a56f7cfad2034249e45..de500587437ce92e56dbe99ef2fcb4a7b461d4c6 100644
--- a/webclient/room/room-controller.js
+++ b/webclient/room/room-controller.js
@@ -15,8 +15,8 @@ limitations under the License.
 */
 
 angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
-.controller('RoomController', ['$filter', '$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'eventHandlerService', 'mFileUpload', 'mPresence', 'matrixPhoneService', 'MatrixCall',
-                               function($filter, $scope, $timeout, $routeParams, $location, $rootScope, matrixService, eventHandlerService, mFileUpload, mPresence, matrixPhoneService, MatrixCall) {
+.controller('RoomController', ['$filter', '$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'eventHandlerService', 'mFileUpload', 'matrixPhoneService', 'MatrixCall',
+                               function($filter, $scope, $timeout, $routeParams, $location, $rootScope, matrixService, eventHandlerService, mFileUpload, matrixPhoneService, MatrixCall) {
    'use strict';
     var MESSAGES_PER_PAGINATION = 30;
     var THUMBNAIL_SIZE = 320;
@@ -32,7 +32,8 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         can_paginate: false, // this is toggled off when we are not ready yet to paginate or when we run out of items
         paginating: false, // used to avoid concurrent pagination requests pulling in dup contents
         stream_failure: undefined, // the response when the stream fails
-        waiting_for_joined_event: false  // true when the join request is pending. Back to false once the corresponding m.room.member event is received
+        waiting_for_joined_event: false,  // true when the join request is pending. Back to false once the corresponding m.room.member event is received
+        messages_visibility: "hidden" // In order to avoid flickering when scrolling down the message table at the page opening, delay the message table display
     };
     $scope.members = {};
     $scope.autoCompleting = false;
@@ -53,8 +54,13 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
                 return;
             };
 
-            // Use the filter applied in html to set the input value
-            $scope.name.newNameText = $filter('mRoomName')($scope.room_id);
+            var nameEvent = $rootScope.events.rooms[$scope.room_id]['m.room.name'];
+            if (nameEvent) {
+                $scope.name.newNameText = nameEvent.content.name;
+            }
+            else {
+                $scope.name.newNameText = "";
+            }
 
             // Force focus to the input
             $timeout(function() {
@@ -131,6 +137,13 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
             
             $timeout(function() {
                 objDiv.scrollTop = objDiv.scrollHeight;
+
+                // Show the message table once the first scrolldown is done 
+                if ("visible" !== $scope.state.messages_visibility) {
+                    $timeout(function() {
+                        $scope.state.messages_visibility = "visible";
+                    }, 0);
+                }
             }, 0);
         }
     };
@@ -139,27 +152,11 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         if (isLive && event.room_id === $scope.room_id) {
             
             scrollToBottom();
-
-            if (window.Notification) {
-                // Show notification when the window is hidden, or the user is idle
-                if (document.hidden || matrixService.presence.unavailable === mPresence.getState()) {
-                    var notification = new window.Notification(
-                        ($scope.members[event.user_id].displayname || event.user_id) +
-                        " (" + ($scope.room_alias || $scope.room_id) + ")", // FIXME: don't leak room_ids here
-                    {
-                        "body": event.content.body,
-                        "icon": $scope.members[event.user_id].avatar_url
-                    });
-                    $timeout(function() {
-                        notification.close();
-                    }, 5 * 1000);
-                }
-            }
         }
     });
     
     $scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) {
-        if (isLive) {
+        if (isLive && event.room_id === $scope.room_id) {
             if ($scope.state.waiting_for_joined_event) {
                 // The user has successfully joined the room, we can getting data for this room
                 $scope.state.waiting_for_joined_event = false;
@@ -177,19 +174,33 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
                 else {
                     user = event.user_id;
                 }
-
                  
                 if ("ban" === event.membership) {
                     $scope.state.permission_denied = "You have been banned by " + user;
                 }
                 else {
                     $scope.state.permission_denied = "You have been kicked by " + user;
-                }
-                
+                }  
             }
             else {
                 scrollToBottom();
                 updateMemberList(event); 
+
+                // Notify when a user joins
+                if ((document.hidden  || matrixService.presence.unavailable === mPresence.getState())
+                        && event.state_key !== $scope.state.user_id  && "join" === event.membership) {
+                    debugger;
+                    var notification = new window.Notification(
+                        event.content.displayname +
+                        " (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here
+                    {
+                        "body": event.content.displayname + " joined",
+                        "icon": event.content.avatar_url ? event.content.avatar_url : undefined
+                    });
+                    $timeout(function() {
+                        notification.close();
+                    }, 5 * 1000);
+                }
             }
         }
     });
@@ -235,7 +246,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         matrixService.paginateBackMessages($scope.room_id, $rootScope.events.rooms[$scope.room_id].pagination.earliest_token, numItems).then(
             function(response) {
 
-                eventHandlerService.handleRoomMessages($scope.room_id, response.data, false);
+                eventHandlerService.handleRoomMessages($scope.room_id, response.data, false, 'b');
                 if (response.data.chunk.length < MESSAGES_PER_PAGINATION) {
                     // no more messages to paginate. this currently never gets turned true again, as we never
                     // expire paginated contents in the current implementation.
@@ -406,12 +417,15 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
     };
 
     $scope.send = function() {
-        if ($scope.textInput === "") {
+        if (undefined === $scope.textInput || $scope.textInput === "") {
             return;
         }
         
         scrollToBottom(true);
-        
+
+        // Store the command in the history
+        history.push($scope.textInput);
+
         var promise;
         var cmd;
         var args;
@@ -676,6 +690,10 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
     var onInit2 = function() {
         console.log("onInit2");
         
+        // Scroll down as soon as possible so that we point to the last message
+        // if it already exists in memory
+        scrollToBottom(true);
+
         // Make sure the initialSync has been before going further
         eventHandlerService.waitForInitialSyncCompletion().then(
             function() {
@@ -684,6 +702,10 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
                 
                 // The room members is available in the data fetched by initialSync
                 if ($rootScope.events.rooms[$scope.room_id]) {
+
+                    // There is no need to do a 1st pagination (initialSync provided enough to fill a page)
+                    $scope.state.first_pagination = false;
+
                     var members = $rootScope.events.rooms[$scope.room_id].members;
 
                     // Update the member list
@@ -729,7 +751,10 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         // Make recents highlight the current room
         $scope.recentsSelectedRoomID = $scope.room_id;
 
-		// Get the up-to-date the current member list
+        // Init the history for this room
+        history.init();
+
+        // Get the up-to-date the current member list
         matrixService.getMemberList($scope.room_id).then(
             function(response) {
                 for (var i = 0; i < response.data.chunk.length; i++) {
@@ -743,9 +768,18 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
                 // Arm list timing update timer
                 updateMemberListPresenceAge();
 
-                // Start pagination
+                // Allow pagination
                 $scope.state.can_paginate = true;
-                paginate(MESSAGES_PER_PAGINATION);
+
+                // Do a first pagination only if it is required
+                // FIXME: Should be no more require when initialSync/{room_id} will be available
+                if ($scope.state.first_pagination) {
+                    paginate(MESSAGES_PER_PAGINATION);
+                }
+                else {
+                    // There are already messages, go to the last message
+                    scrollToBottom(true);
+                }
             },
             function(error) {
                 $scope.feedback = "Failed get member list: " + error.data.error;
@@ -832,4 +866,82 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         $rootScope.currentCall = call;
     };
 
+    // Manage history of typed messages
+    // History is saved in sessionStoratge so that it survives when the user
+    // navigates through the rooms and when it refreshes the page
+    var history = {
+        // The list of typed messages. Index 0 is the more recents
+        data: [],
+
+        // The position in the history currently displayed
+        position: -1,
+
+        // The message the user has started to type before going into the history
+        typingMessage: undefined,
+
+        // Init/load data for the current room
+        init: function() {
+            var data = sessionStorage.getItem("history_" + $scope.room_id);
+            if (data) {
+                this.data = JSON.parse(data);
+            }
+        },
+
+        // Store a message in the history
+        push: function(message) {
+            this.data.unshift(message);
+
+            // Update the session storage
+            sessionStorage.setItem("history_" + $scope.room_id, JSON.stringify(this.data));
+
+            // Reset history position
+            this.position = -1;
+            this.typingMessage = undefined;
+        },
+
+        // Move in the history
+        go: function(offset) {
+
+            if (-1 === this.position) {
+                // User starts to go to into the history, save the current line
+                this.typingMessage = $scope.textInput;
+            }
+            else {
+                // If the user modified this line in history, keep the change
+                this.data[this.position] = $scope.textInput;
+            }
+
+            // Bounds the new position to valid data
+            var newPosition = this.position + offset;
+            newPosition = Math.max(-1, newPosition);
+            newPosition = Math.min(newPosition, this.data.length - 1);
+            this.position = newPosition;
+
+            if (-1 !== this.position) {
+                // Show the message from the history
+                $scope.textInput = this.data[this.position];
+            }
+            else if (undefined !== this.typingMessage) {
+                // Go back to the message the user started to type
+                $scope.textInput = this.typingMessage;
+            }
+        }
+    };
+
+    // Make history singleton methods available from HTML
+    $scope.history = {
+        goUp: function($event) {
+            if ($scope.room_id) {
+                history.go(1);
+            }
+            $event.preventDefault();
+        },
+        goDown: function($event) {
+            if ($scope.room_id) {
+                history.go(-1);
+            }
+            $event.preventDefault();
+        }
+    };
+
 }]);
diff --git a/webclient/room/room.html b/webclient/room/room.html
index 9d617eadd8e7a8eaed796d8d907fabcd8f74e8af..44a0e34d9f9a90720b46f745c787177e61169d63 100644
--- a/webclient/room/room.html
+++ b/webclient/room/room.html
@@ -9,7 +9,7 @@
                     {{ room_id | mRoomName }}
                 </div>
                 <form ng-submit="name.updateName()" ng-show="name.isEditing" class="roomNameForm">
-                    <input ng-model="name.newNameText" ng-blur="name.cancelEdit()" class="roomNameInput" />
+                    <input ng-model="name.newNameText" ng-blur="name.cancelEdit()" class="roomNameInput" placeholder="Room name"/>
                 </form>
             </div>
 
@@ -23,7 +23,7 @@
                         {{ events.rooms[room_id]['m.room.topic'].content.topic | limitTo: 200}}
                     </div>
                     <form ng-submit="topic.updateTopic()" ng-show="topic.isEditing" class="roomTopicForm">
-                        <input ng-model="topic.newTopicText" ng-blur="topic.cancelEdit()" class="roomTopicInput" />
+                        <input ng-model="topic.newTopicText" ng-blur="topic.cancelEdit()" class="roomTopicInput"  placeholder="Topic"/>
                     </form>
                 </div>
             </div>
@@ -56,7 +56,10 @@
         </table>
     </div>
     
-    <div id="messageTableWrapper" ng-hide="state.permission_denied" keep-scroll>
+    <div id="messageTableWrapper" 
+         ng-hide="state.permission_denied" 
+         ng-style="{ 'visibility': state.messages_visibility }"
+         keep-scroll>
         <!-- FIXME: need to have better timestamp semantics than the (msg.content.hsob_ts || msg.ts) hack below -->
         <table id="messageTable" infinite-scroll="paginateMore()">
             <tr ng-repeat="msg in events.rooms[room_id].messages"
@@ -105,7 +108,7 @@
                         
                         <span ng-show='msg.content.msgtype === "m.text"' 
                               class="message"
-                              ng-class="msg.echo_msg_state"
+                              ng-class="containsBingWord(msg.content.body) && msg.user_id != state.user_id ? msg.echo_msg_state + ' messageBing' : msg.echo_msg_state"
                               ng-bind-html="((msg.content.msgtype === 'm.text') ? msg.content.body : '') | linky:'_blank'"/>
 
                         <span ng-show='msg.type === "m.call.invite" && msg.user_id == state.user_id'>Outgoing Call</span>
@@ -156,7 +159,8 @@
                     <td width="*">
                         <textarea id="mainInput" rows="1" ng-model="textInput" ng-enter="send()"
                                   ng-disabled="state.permission_denied"
-                                  ng-focus="true"  autocomplete="off" tab-complete/>
+                                  ng-keydown="(38 === $event.which) ? history.goUp($event) : ((40 === $event.which) ? history.goDown($event) : 0)"
+                                  ng-focus="true" autocomplete="off" tab-complete/>
                     </td>
                     <td id="buttonsCell">
                         <button ng-click="send()" ng-disabled="state.permission_denied">Send</button>
diff --git a/webclient/settings/settings-controller.js b/webclient/settings/settings-controller.js
index 8c877a24e9f331e05b59c0e980e2c30ed73a9e9e..9cdace704a11e4d00ef6f712bbffa0116d647f40 100644
--- a/webclient/settings/settings-controller.js
+++ b/webclient/settings/settings-controller.js
@@ -194,7 +194,16 @@ angular.module('SettingsController', ['matrixService', 'mFileUpload', 'mFileInpu
     
     /*** Desktop notifications section ***/
     $scope.settings = {
-        notifications: undefined
+        notifications: undefined,
+        bingWords: matrixService.config().bingWords
+    };
+    
+    $scope.saveBingWords = function() {
+        console.log("Saving words: "+JSON.stringify($scope.settings.bingWords));
+        var config = matrixService.config();
+        config.bingWords = $scope.settings.bingWords;
+        matrixService.setConfig(config);
+        matrixService.saveConfig();
     };
 
     // If the browser supports it, check the desktop notification state
diff --git a/webclient/settings/settings.html b/webclient/settings/settings.html
index c358a6e9d8571107610377e80133af6c1210e87f..0a5a3db51f3ac849f7e7bee2ef8a22c7e09bea6f 100644
--- a/webclient/settings/settings.html
+++ b/webclient/settings/settings.html
@@ -51,7 +51,16 @@
         <h3>Desktop notifications</h3>
         <div class="section" ng-switch="settings.notifications">
             <div ng-switch-when="granted">
-                Notifications are enabled.
+                Notifications are enabled. You will be alerted when a message contains your user ID or display name.
+                <div class="section">
+                    <h4>Additional words to alert on:</h4>
+                    <p>Leave blank to alert on all messages.</p>
+                    <input size=40 name="bingWords" ng-model="settings.bingWords" ng-list placeholder="Enter words separated with , (supports regex)"
+                    ng-blur="saveBingWords()"/>
+                    <ul>
+                        <li ng-repeat="word in settings.bingWords">{{word}}</li>
+                    </ul>
+                </div>
             </div>
             <div ng-switch-when="denied">
                 You have denied permission for notifications.<br/>