diff --git a/.gitignore b/.gitignore
index 1917768116..491fc35975 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,2 @@
 node_modules
-build
-bundle.css
-bundle.js
+lib
diff --git a/.npmignore b/.npmignore
index 1ce5400d43..598224712f 100644
--- a/.npmignore
+++ b/.npmignore
@@ -1,3 +1,3 @@
 example
 examples
-build/.module-cache
+.module-cache
diff --git a/README.md b/README.md
index 499dbee4a5..2b38265713 100644
--- a/README.md
+++ b/README.md
@@ -2,58 +2,29 @@ matrix-react-sdk
 ================
 
 This is a react-based SDK for inserting a Matrix chat/voip client into a web page.
-It provides reusable and customisable UI components backed by the matrix-js-sdk.
 
-Getting started with the trivial example
-========================================
+This package provides the logic and 'controller' parts for the UI components. This
+forms one part of a complete matrix client, but it not useable in isolation. It
+must be used from a 'skin'. A skin provides:
+ * The HTML for the UI components (in the form of React `render` methods)
+ * The CSS for this HTML
+ * The containing application 
+ * Zero or more 'modules' containing non-UI functionality
 
-1. Install or update `node.js` so that your `npm` is at least at version `2.0.0`
-2. Clone the repo: `git clone https://github.com/matrix-org/matrix-react-sdk.git` 
-3. Switch to the SDK directory: `cd matrix-react-sdk`
-4. Install the prerequisites: `npm install`
-5. Switch to the example directory: `cd examples/trivial`
-6. Install the example app prerequisites: `npm install`
-7. Build the example and start a server: `npm start`
+Skins are modules are exported from such a package in the `lib` directory.
+`lib/skins` contains one directory per-skin, named after the skin, and the
+`modules` directory contains modules as their javascript files.
 
-Now open http://127.0.0.1:8080/ in your browser to see your newly built
-Matrix client.
+A basic skin is provided in the matrix-react-skin package. This also contains
+a minimal application that instantiates the basic skin making a working matrix
+client.
 
-Using the example app for development
-=====================================
-
-To work on the CSS and Javascript and have the bundle files update as you
-change the source files, you'll need to do two extra things:
-
-1. Link the react sdk package into the example:
-   `cd matrix-react-sdk/examples/trivial; npm link ../../`
-2. Start a watcher for the CSS files:
-   `cd matrix-react-sdk; npm run start:css`
-
-Note that you may need to restart the CSS builder if you add a new file. Note
-that `npm start` builds debug versions of the javascript and CSS, which are
-much larger than the production versions build by the `npm run build` commands.
-
-IMPORTANT: If you customise components in your application (and hence require
-react from your app) you must be sure to:
-
-1. Make your app depend on react directly
-2. If you `npm link` matrix-react-sdk, manually remove the 'react' directory
-   from matrix-react-sdk's `node_modules` folder, otherwise browserify will
-   pull in both copies of react which causes the app to break.
+You can use matrix-react-sdk directly, but to do this you would have to provide
+'views' for each UI component. To get started quickly, use matrix-react-skin.
 
 How to customise the SDK
 ========================
 
-The matrix-react-sdk provides well-defined reusable UI components which may be
-customised/replaced by the developer to build into an app.  A set of consistent
-UI components (View + CSS classes) is called a 'skin' - currently the SDK
-provides a very vanilla whitelabelled 'base skin'.  In future the SDK could
-provide alternative skins (probably by extending the base skin) that provide more
-specific look and feels (e.g. "IRC-style", "Skype-style") etc.  However, unlike
-Wordpress themes and similar, we don't normally expect app developers to define
-reusable skins.  Instead you just go and incorporate your view customisations
-into your actual app.
-
 The SDK uses the 'atomic' design pattern as seen at http://patternlab.io to
 encourage a very modular and reusable architecture, making it easy to
 customise and use UI widgets independently of the rest of the SDK and your app.
@@ -131,18 +102,41 @@ components to embed a Matrix client into your app:
   * Create a new NPM project. Be sure to directly depend on react, (otherwise
     you can end up with two copies of react).
   * Create an index.js file that sets up react. Add require statements for
-    React, the ComponentBroker and matrix-react-sdk and a call to Render
-    the root React element as in the examples.
-  * Create React classes for any custom components you wish to add. These
-    can be based off the files in `views` in the `matrix-react-sdk` package,
-    modifying the require() statement appropriately.
-    You only need to copy files you want to customise.
-  * Add a ComponentBroker.set() call for each of your custom components. These
-    must come *before* `require("matrix-react-sdk")`.
-  * Add a way to build your project: we suggest copying the browserify calls
-    from the example projects, but you could use grunt or gulp.
-  * Create an index.html file pulling in your compiled index.js file, the
-    CSS bundle from matrix-react-sdk.
+    React and matrix-react-sdk. Load a skin using the 'loadSkin' method on the
+    SDK and call Render. This can be a skin provided by a separate package or
+    a skin in the same package.
+  * Add a way to build your project: we suggest copying the scripts block 
+    from matrix-react-skin (which uses babel and webpack). You could use 
+    different tools but remember that at least the skins and modules of
+    your project should end up in plain (ie. non ES6, non JSX) javascript in
+    the lib directory at the end of the build process, as well as any
+    packaging that you might do.
+  * Create an index.html file pulling in your compiled javascript and the
+    CSS bundle from the skin you use. For now, you'll also need to manually
+    import CSS from any skins that your skin inherts from.
 
-For more specific detail on any of these steps, look at the `custom` example in
-matrix-react-sdk/examples.
+To Create Your Own Skin
+=======================
+To actually change the look of a skin, you can create a base skin (which
+does not use views from any other skin) or you can make a derived skin.
+Note that derived skins are currently experimental: for example, the CSS
+from the skins it is based on will not be automatically included.
+
+To make a skin, create React classes for any custom components you wish to add
+in a skin within `src/skins/<skin name>`. These can be based off the files in
+`views` in the `matrix-react-skin` package, modifying the require() statement
+appropriately.
+
+If you make a derived skin, you only need copy the files you wish to customise.
+
+Once you've made all your view files, you need to make a `skinfo.json`. This
+contains all the metadata for a skin. This is a JSON file with, currently, a
+single key, 'baseSkin'. Set this to the empty string if your skin is a base skin,
+or for a derived skin, set it to the path of your base skin's skinfo.json file, as
+you would use in a require call.
+
+Now you have the basis of a skin, you need to generate a skindex.json file. The
+`reskindex.js` tool in matrix-react-sdk does this for you. It is suggested that
+you add an npm script to run this, as in matrix-react-skin.
+
+For more specific detail on any of these steps, look at matrix-react-skin.
diff --git a/examples/custom/CustomMTextTile.js b/examples/custom/CustomMTextTile.js
deleted file mode 100644
index e58ed4c1e6..0000000000
--- a/examples/custom/CustomMTextTile.js
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var MTextTileController = require("matrix-react-sdk/src/controllers/molecules/MTextTile");
-
-module.exports = React.createClass({
-    displayName: 'MTextTile',
-    mixins: [MTextTileController],
-
-    render: function() {
-        var content = this.props.mxEvent.getContent();
-        return (
-            <span ref="content" className="mx_MTextTile mx_MessageTile_content" onClick={this.onClick}>
-                {content.body}
-            </span>
-        );
-    },
-
-    onClick: function(ev) {
-        global.alert(this.props.mxEvent.getContent().body);
-    }
-});
-
diff --git a/examples/custom/README.md b/examples/custom/README.md
deleted file mode 100644
index 8125053ce0..0000000000
--- a/examples/custom/README.md
+++ /dev/null
@@ -1,4 +0,0 @@
-matrix-react-example
-====================
-
-An example of how to use the Matrix React SDK to build a more customised app
diff --git a/examples/custom/index.html b/examples/custom/index.html
deleted file mode 100644
index 04c1645c8a..0000000000
--- a/examples/custom/index.html
+++ /dev/null
@@ -1,12 +0,0 @@
-<!doctype html>
-<html lang="en" style="height: 100%; overflow: hidden">
-  <head>
-    <meta charset="utf-8">
-    <title>Matrix React SDK Custom Example</title>
-  </head>
-  <body style="height: 100%; ">
-    <section id="matrixchat" style="height: 100%; "></section>
-    <script src="bundle.js"></script>
-    <link rel="stylesheet" href="node_modules/matrix-react-sdk/bundle.css">
-  </body>
-</html>
diff --git a/examples/custom/index.js b/examples/custom/index.js
deleted file mode 100644
index 66602a0ada..0000000000
--- a/examples/custom/index.js
+++ /dev/null
@@ -1,40 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-// Remember to make your project depend on react directly as soon as
-// you add a require('react') to any file in your project. Do not rely
-// on react being pulled in via matrix-react-sdk: browserify breaks
-// horribly in this situation and can end up pulling in multiple copies
-// of react.
-var React = require("react");
-
-// We pull in the component broker first, separately, as we need to replace
-// components before the SDK loads.
-var ComponentBroker = require("matrix-react-sdk/src/ComponentBroker");
-
-var CustomMTextTile = require('./CustomMTextTile');
-
-ComponentBroker.set('molecules/MTextTile', CustomMTextTile);
-
-var MatrixReactSdk = require("matrix-react-sdk");
-//var MatrixReactSdk = require("../../src/index");
-
-React.render(
-    <MatrixReactSdk.MatrixChat />,
-    document.getElementById('matrixchat')
-);
diff --git a/examples/custom/package.json b/examples/custom/package.json
deleted file mode 100644
index 6acec803fa..0000000000
--- a/examples/custom/package.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
-  "name": "matrix-react-example",
-  "version": "0.0.1",
-  "description": "Example usage of matrix-react-sdk",
-  "author": "matrix.org",
-  "repository": {
-    "type": "git",
-    "url": "https://github.com/matrix-org/matrix-react-sdk"
-  },
-  "license": "Apache-2.0",
-  "devDependencies": {
-    "browserify": "^10.2.3",
-    "envify": "^3.4.0",
-    "http-server": "^0.8.0",
-    "matrix-react-sdk": "../../",
-    "npm-css": "^0.2.3",
-    "parallelshell": "^1.2.0",
-    "reactify": "^1.1.1",
-    "uglify-js": "^2.4.23",
-    "watchify": "^3.2.1"
-  },
-  "scripts": {
-    "build": "browserify -t [ envify --NODE_ENV production ] -g reactify index.js | uglifyjs -c -m -o bundle.js",
-    "start": "parallelshell 'watchify -v -d -g reactify index.js -o bundle.js' 'http-server'"
-  },
-  "dependencies": {
-    "react": "^0.13.3"
-  }
-}
diff --git a/examples/trivial/README.md b/examples/trivial/README.md
deleted file mode 100644
index ac26627732..0000000000
--- a/examples/trivial/README.md
+++ /dev/null
@@ -1,4 +0,0 @@
-matrix-react-example
-====================
-
-A simple example of how to use the Matrix React SDK
diff --git a/examples/trivial/index.html b/examples/trivial/index.html
deleted file mode 100644
index 4ec5b9093a..0000000000
--- a/examples/trivial/index.html
+++ /dev/null
@@ -1,12 +0,0 @@
-<!doctype html>
-<html lang="en" style="height: 100%; overflow: hidden">
-  <head>
-    <meta charset="utf-8">
-    <title>Matrix React SDK Example</title>
-  </head>
-  <body style="height: 100%;">
-    <section id="matrixchat" style="height: 100%;"></section>
-    <script src="bundle.js"></script>
-    <link rel="stylesheet" href="node_modules/matrix-react-sdk/bundle.css">
-  </body>
-</html>
diff --git a/examples/trivial/index.js b/examples/trivial/index.js
deleted file mode 100644
index 2be9054954..0000000000
--- a/examples/trivial/index.js
+++ /dev/null
@@ -1,75 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require("react");
-// In normal usage of the module:
-//var MatrixReactSdk = require("matrix-react-sdk");
-// Or to import the source directly from the file system:
-// (This is useful for debugging the SDK as it seems source
-// maps cannot pass through two stages).
-var MatrixReactSdk = require("../../src/index");
-
-// Here, we do some crude URL analysis to allow
-// deep-linking. We only support registration
-// deep-links in this example.
-function routeUrl(location) {
-    if (location.hash.indexOf('#/register') == 0) {
-        var hashparts = location.hash.split('?');
-        var params = {};
-        if (hashparts.length == 2) {
-            var pairs = hashparts[1].split('&');
-            for (var i = 0; i < pairs.length; ++i) {
-                var parts = pairs[i].split('=');
-                if (parts.length != 2) continue;
-                params[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1]);
-            }
-        }
-        window.matrixChat.showScreen('register', params);
-    }
-}
-
-var loaded = false;
-
-window.onload = function() {
-    routeUrl(window.location);
-    loaded = true;
-}
-
-// This will be called whenever the SDK changes screens,
-// so a web page can update the URL bar appropriately.
-var onNewScreen = function(screen) {
-    if (!loaded) return;
-    window.location.hash = '#/'+screen;
-}
-
-// We use this to work out what URL the SDK should
-// pass through when registering to allow the user to
-// click back to the client having registered.
-// It's up to us to recognise if we're loaded with
-// this URL and tell MatrixClient to resume registration.
-var makeRegistrationUrl = function() {
-    return window.location.protocol + '//' +
-           window.location.host +
-           window.location.pathname +
-           '#/register';
-}
-
-window.matrixChat = React.render(
-    <MatrixReactSdk.MatrixChat onNewScreen={onNewScreen} registrationUrl={makeRegistrationUrl()} />,
-    document.getElementById('matrixchat')
-);
diff --git a/examples/trivial/package.json b/examples/trivial/package.json
deleted file mode 100644
index 40a7150731..0000000000
--- a/examples/trivial/package.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
-  "name": "matrix-react-example",
-  "version": "0.0.1",
-  "description": "Example usage of matrix-react-sdk",
-  "author": "matrix.org",
-  "repository": {
-    "type": "git",
-    "url": "https://github.com/matrix-org/matrix-react-sdk"
-  },
-  "license": "Apache-2.0",
-  "devDependencies": {
-    "browserify": "^10.2.3",
-    "envify": "^3.4.0",
-    "http-server": "^0.8.0",
-    "matrix-react-sdk": "../../",
-    "parallelshell": "^1.2.0",
-    "reactify": "^1.1.1",
-    "uglify-js": "^2.4.23",
-    "watchify": "^3.2.1"
-  },
-  "scripts": {
-    "build": "browserify --ignore olm -t [ envify --NODE_ENV production ] -t reactify index.js | uglifyjs -c -m -o bundle.js",
-    "start": "parallelshell 'watchify --ignore olm -v -d -t reactify index.js -o bundle.js' 'http-server'"
-  }
-}
diff --git a/package.json b/package.json
index 0287121355..1ccc475feb 100644
--- a/package.json
+++ b/package.json
@@ -8,33 +8,34 @@
     "url": "https://github.com/matrix-org/matrix-react-sdk"
   },
   "license": "Apache-2.0",
-  "main": "src/index.js",
-  "style": "bundle.css",
+  "main": "lib/index.js",
+  "bin": {
+    "reskindex": "./reskindex.js"
+  },
   "scripts": {
-    "build:skins": "jsx skins build/skins",
-    "build:logic": "jsx src build/src",
-    "build:js": "npm run build:skins && npm run build:logic",
-    "start:js": "jsx -w skins/base/views/ build --source-map-inline",
-    "build:css": "catw 'skins/base/css/**/*.css' -o bundle.css -c uglifycss --no-watch",
-    "start:css": "catw 'skins/base/css/**/*.css' -o bundle.css -v",
-    "build": "npm run build:js && npm run build:css",
-    "start": "parallelshell 'npm run start:js' 'npm run start:css'",
+    "build": "babel src -d lib --source-maps",
+    "start": "babel src -w -d lib --source-maps",
+    "clean": "rimraf lib",
     "prepublish": "npm run build"
   },
   "dependencies": {
     "classnames": "^2.1.2",
     "filesize": "^3.1.2",
     "flux": "^2.0.3",
-    "matrix-js-sdk": "0.2.0",
+    "glob": "^5.0.14",
+    "linkifyjs": "^2.0.0-beta.4",
+    "matrix-js-sdk": "^0.2.1",
+    "optimist": "^0.6.1",
     "q": "^1.4.1",
     "react": "^0.13.3",
-    "react-loader": "^1.4.0",
-    "linkifyjs": "^2.0.0-beta.4"
+    "react-loader": "^1.4.0"
   },
+  "//deps": "The loader packages are here because webpack in a project that depends on us needs them in this package's node_modules folder",
+  "//depsbuglink": "https://github.com/webpack/webpack/issues/1472",
   "devDependencies": {
-    "catw": "^1.0.1",
-    "parallelshell": "^1.1.1",
-    "react-tools": "^0.13.3",
-    "uglifycss": "0.0.15"
+    "babel": "^5.8.23",
+    "rimraf": "^2.4.3",
+    "json-loader": "^0.5.3",
+    "source-map-loader": "^0.1.5"
   }
 }
diff --git a/reskindex.js b/reskindex.js
new file mode 100755
index 0000000000..f8d45493d3
--- /dev/null
+++ b/reskindex.js
@@ -0,0 +1,83 @@
+#!/usr/bin/env node
+
+var fs = require('fs');
+var path = require('path');
+var glob = require('glob');
+
+var args = require('optimist').argv;
+
+var header = args.h || args.header;
+
+if (args._.length == 0) {
+    console.log("No skin given");
+    process.exit(1);
+}
+
+var skin = args._[0];
+
+try {
+    fs.accessSync(path.join('src', 'skins', skin), fs.F_OK);
+} catch (e) {
+    console.log("Skin "+skin+" not found");
+    process.exit(1);
+}
+
+var skinfoFile = path.join('src', 'skins', skin, 'skinfo.json');
+
+try {
+    fs.accessSync(skinfoFile, fs.F_OK);
+} catch (e) {
+    console.log("Skin "+skin+" has no skinfo.json");
+    process.exit(1);
+}
+
+try {
+    fs.accessSync(path.join('src', 'skins', skin, 'views'), fs.F_OK);
+} catch (e) {
+    console.log("Skin "+skin+" has no views directory");
+    process.exit(1);
+}
+
+var skindex = path.join('src', 'skins', skin, 'skindex.js');
+var viewsDir = path.join('src', 'skins', skin, 'views');
+
+var strm = fs.createWriteStream(skindex);
+
+if (header) {
+   strm.write(fs.readFileSync(header));
+   strm.write('\n');
+}
+
+strm.write("/*\n");
+strm.write(" * THIS FILE IS AUTO-GENERATED\n");
+strm.write(" * You can edit it you like, but your changes will be overwritten,\n");
+strm.write(" * so you'd just be trying to swim upstream like a salmon.\n");
+strm.write(" * You are not a salmon.\n");
+strm.write(" */\n\n");
+
+var mySkinfo = JSON.parse(fs.readFileSync(skinfoFile, "utf8"));
+
+strm.write("var skin = {};\n");
+strm.write('\n');
+
+var files = glob.sync('**/*.js', {cwd: viewsDir});
+for (var i = 0; i < files.length; ++i) {
+    var file = files[i].replace('.js', '');
+    var module = (file.replace(/\//g, '.'));
+
+    strm.write("skin['"+module+"'] = require('./views/"+file+"');\n");
+    strm.uncork();
+}
+
+strm.write("\n");
+
+if (mySkinfo.baseSkin) {
+    strm.write("module.exports = require('"+mySkinfo.baseSkin+"');");
+    strm.write("var extend = require('matrix-react-sdk/lib/extend');\n");
+    strm.write("extend(module.exports, skin);\n");
+} else {
+    strm.write("module.exports = skin;");
+}
+
+strm.end();
+
diff --git a/skins/base/css/atoms/MessageTimestamp.css b/skins/base/css/atoms/MessageTimestamp.css
deleted file mode 100644
index 62b3065661..0000000000
--- a/skins/base/css/atoms/MessageTimestamp.css
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
-Copyright 2015 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.
-*/
-
-.mx_MessageTimestamp {
-    display: table-cell;
-    white-space: pre;
-}
diff --git a/skins/base/css/common.css b/skins/base/css/common.css
deleted file mode 100644
index 5153f97065..0000000000
--- a/skins/base/css/common.css
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
-Copyright 2015 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.
-*/
-
-body {
-	font-family: Helvetica, Arial, Sans-Serif;
-}
-
-div.error {
-    color: red;
-}
diff --git a/skins/base/css/molecules/MessageComposer.css b/skins/base/css/molecules/MessageComposer.css
deleted file mode 100644
index 829e25a938..0000000000
--- a/skins/base/css/molecules/MessageComposer.css
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
-Copyright 2015 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.
-*/
-
-.mx_MessageComposer textarea {
-    width: 100%;
-    margin: auto;
-}
diff --git a/skins/base/css/molecules/ProgressBar.css b/skins/base/css/molecules/ProgressBar.css
deleted file mode 100644
index 8b8adc09c1..0000000000
--- a/skins/base/css/molecules/ProgressBar.css
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
-Copyright 2015 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.
-*/
-
-.mx_ProgressBar {
-    height: 5px;
-    border: 1px solid black;
-}
-
-.mx_ProgressBar_fill {
-    height: 100%;
-    background-color: #000;
-}
diff --git a/skins/base/css/molecules/RoomHeader.css b/skins/base/css/molecules/RoomHeader.css
deleted file mode 100644
index 63d6fc33fb..0000000000
--- a/skins/base/css/molecules/RoomHeader.css
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
-Copyright 2015 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.
-*/
-
-.mx_RoomHeader {
-    height: 1em;
-    padding: 0px;
-}
diff --git a/skins/base/css/molecules/RoomTile.css b/skins/base/css/molecules/RoomTile.css
deleted file mode 100644
index 719551cb57..0000000000
--- a/skins/base/css/molecules/RoomTile.css
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
-Copyright 2015 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.
-*/
-
-.mx_RoomTile {
-    padding: 5px;
-    cursor: pointer;
-}
-
-.mx_RoomTile.selected {
-    text-decoration: underline;
-}
-
-.mx_RoomTile_name {
-}
-
-.mx_RoomTile div {
-    overflow: hidden;
-    text-overflow: ellipsis;
-}
-
-.mx_RoomTile.unread {
-    font-weight: bold;
-}
-
-.mx_RoomTile.highlight {
-    background-color: lime;
-}
-
-.mx_RoomTile.invited {
-    font-weight: bold;
-}
-
-.mx_RoomTile:hover {
-}
diff --git a/skins/base/css/molecules/SenderProfile.css b/skins/base/css/molecules/SenderProfile.css
deleted file mode 100644
index 549b598458..0000000000
--- a/skins/base/css/molecules/SenderProfile.css
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
-Copyright 2015 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.
-*/
-
-.mx_SenderProfile {
-    display: table-cell;
-    padding: 0px 1em 0em 1em;
-}
diff --git a/skins/base/css/organisms/RoomView.css b/skins/base/css/organisms/RoomView.css
deleted file mode 100644
index 0c75f8fad4..0000000000
--- a/skins/base/css/organisms/RoomView.css
+++ /dev/null
@@ -1,86 +0,0 @@
-/*
-Copyright 2015 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.
-*/
-
-.mx_RoomView {
-    word-wrap: break-word;
-    position: relative;
-}
-
-.mx_RoomView .mx_RoomHeader {
-    height: 30px;
-}
-
-.mx_RoomView_roomWrapper {
-    display: -webkit-box;
-    display: -moz-box;
-    display: -ms-flexbox;
-    display: -webkit-flex;
-    display: flex;    
-    position: absolute;
-    width: 100%;
-    top: 32px;
-    bottom: 0px;
-}
-
-.mx_RoomView_messagePanel {
-    -webkit-box-ordinal-group: 1;
-    -moz-box-ordinal-group: 1;
-    -ms-flex-order: 1;
-    -webkit-order: 1;
-    order: 1;
-    width: 100%;
-    height: 100%;
-    /* background-color: #ff0; */
-}
-
-.mx_RoomView_messageListWrapper {
-    height: 100%;
-    overflow-y: scroll;    
-}
-
-.mx_RoomView_MessageList {
-    display: table;
-}
-
-.mx_RoomView_MessageList_ul {
-    list-style-type: none;
-}
-
-.mx_RoomView_invitePrompt {
-}
-
-.mx_RoomView .mx_MemberList {
-    -webkit-box-ordinal-group: 2;
-    -moz-box-ordinal-group: 2;
-    -ms-flex-order: 2;
-    -webkit-order: 2;
-    order: 2;
-
-    /* background-color: #0f0; */
-    width: 250px;
-    overflow-y: scroll;
-    height: 100%;
-}
-
-.mx_RoomView .mx_MemberList ul {
-    margin: 0px;
-    padding: 0px;
-}
-
-.mx_RoomView .mx_MessageComposer {
-    width: 100%;
-    bottom: 0px;
-}
diff --git a/skins/base/css/pages/MatrixChat.css b/skins/base/css/pages/MatrixChat.css
deleted file mode 100644
index 7ce88ec7ff..0000000000
--- a/skins/base/css/pages/MatrixChat.css
+++ /dev/null
@@ -1,89 +0,0 @@
-/*
-Copyright 2015 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.
-*/
-
-.mx_MatrixChat {
-    position: relative;
-    width: 100%;
-    height: 100%;
-}
-
-.mx_MatrixChat_chatWrapper {
-    display: -webkit-box;
-    display: -moz-box;
-    display: -ms-flexbox;
-    display: -webkit-flex;
-    display: flex;
-    position: absolute;
-    width: 100%;
-    top: 0px;
-    bottom: 42px;
-}
-
-.mx_MatrixChat_leftPanel {
-    -webkit-box-ordinal-group: 1;
-    -moz-box-ordinal-group: 1;
-    -ms-flex-order: 1;
-    -webkit-order: 1;
-    order: 1;
-
-    display: -webkit-box;
-    display: -moz-box;
-    display: -ms-flexbox;
-    display: -webkit-flex;
-    display: flex;
-    flex-direction: column;
-    -webkit-flex-direction: column;
-
-    /* background-color: #f00; */
-    width: 250px;
-    height: 100%;
-}
-
-.mx_MatrixChat_leftPanel .mx_MatrixToolbar {
-    -webkit-box-ordinal-group: 1;
-    -moz-box-ordinal-group: 1;
-    -ms-flex-order: 1;
-    -webkit-order: 1;
-    order: 1;
-
-    width: 100%;
-    height: 40px;
-}
-
-.mx_MatrixChat_leftPanel .mx_RoomList {
-    -webkit-box-ordinal-group: 2;
-    -moz-box-ordinal-group: 2;
-    -ms-flex-order: 2;
-    -webkit-order: 2;
-    order: 2;
-
-    /* background-color: #0ff; */
-    width: 100%;
-    height: 100%;
-    overflow-y: scroll;
-}
-
-.mx_MatrixChat .mx_RoomView {
-    -webkit-box-ordinal-group: 2;
-    -moz-box-ordinal-group: 2;
-    -ms-flex-order: 2;
-    -webkit-order: 2;
-    order: 2;
-
-    /* background-color: #00f; */
-    width: 100%;
-    height: 100%;       
-}
diff --git a/skins/base/css/templates/Login.css b/skins/base/css/templates/Login.css
deleted file mode 100644
index 7dbcde1caa..0000000000
--- a/skins/base/css/templates/Login.css
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
-Copyright 2015 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.
-*/
-
-.mx_Login {
-    width: 600px;
-    height: 350px;
-    position: relative;
-}
-
diff --git a/skins/base/views/atoms/EditableText.js b/skins/base/views/atoms/EditableText.js
deleted file mode 100644
index a8f55814e7..0000000000
--- a/skins/base/views/atoms/EditableText.js
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var EditableTextController = require("../../../../src/controllers/atoms/EditableText");
-
-module.exports = React.createClass({
-    displayName: 'EditableText',
-    mixins: [EditableTextController],
-
-    onKeyUp: function(ev) {
-        if (ev.key == "Enter") {
-            this.onFinish(ev);
-        } else if (ev.key == "Escape") {
-            this.cancelEdit();
-        }
-    },
-
-    onClickDiv: function() {
-        this.setState({
-            phase: this.Phases.Edit,
-        })
-    },
-
-    onFocus: function(ev) {
-        ev.target.setSelectionRange(0, ev.target.value.length);
-    },
-
-    onFinish: function(ev) {
-        this.setValue(ev.target.value);
-    },
-
-    render: function() {
-        var editable_el;
-
-        if (this.state.phase == this.Phases.Display) {
-            editable_el = <div ref="display_div" onClick={this.onClickDiv}>{this.state.value}</div>;
-        } else if (this.state.phase == this.Phases.Edit) {
-            editable_el = (
-                <div>
-                    <input type="text" defaultValue={this.state.value} onKeyUp={this.onKeyUp} onFocus={this.onFocus} onBlur={this.onFinish} autoFocus/>
-                </div>
-            );
-        }
-
-        return (
-            <div className="mx_EditableText">
-                {editable_el}
-            </div>
-        );
-    }
-});
diff --git a/skins/base/views/atoms/EnableNotificationsButton.js b/skins/base/views/atoms/EnableNotificationsButton.js
deleted file mode 100644
index 7caebb76c5..0000000000
--- a/skins/base/views/atoms/EnableNotificationsButton.js
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var EnableNotificationsButtonController = require("../../../../src/controllers/atoms/EnableNotificationsButton");
-
-module.exports = React.createClass({
-    displayName: 'EnableNotificationsButton',
-    mixins: [EnableNotificationsButtonController],
-
-    render: function() {
-        if (this.enabled()) {
-            return (
-                <button className="mx_EnableNotificationsButton" onClick={this.onClick}>Disable Notifications</button>
-            );
-        } else {
-            return (
-                <button className="mx_EnableNotificationsButton" onClick={this.onClick}>Enable Notifications</button>
-            );
-        }
-    }
-});
diff --git a/skins/base/views/atoms/LogoutButton.js b/skins/base/views/atoms/LogoutButton.js
deleted file mode 100644
index 8cc5b27d5e..0000000000
--- a/skins/base/views/atoms/LogoutButton.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var LogoutButtonController = require("../../../../src/controllers/atoms/LogoutButton");
-
-module.exports = React.createClass({
-    displayName: 'LogoutButton',
-    mixins: [LogoutButtonController],
-
-    render: function() {
-        return (
-            <button className="mx_LogoutButton" onClick={this.onClick}>Sign out</button>
-        );
-    }
-});
diff --git a/skins/base/views/atoms/MessageTimestamp.js b/skins/base/views/atoms/MessageTimestamp.js
deleted file mode 100644
index 52eb1462eb..0000000000
--- a/skins/base/views/atoms/MessageTimestamp.js
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var MessageTimestampController = require("../../../../src/controllers/atoms/MessageTimestamp");
-
-module.exports = React.createClass({
-    displayName: 'MessageTimestamp',
-    mixins: [MessageTimestampController],
-
-    render: function() {
-        var date = new Date(this.props.ts);
-        return (
-            <span className="mx_MessageTimestamp">
-                {date.toLocaleTimeString()}
-            </span>
-        );
-    },
-});
-
diff --git a/skins/base/views/atoms/create_room/CreateRoomButton.js b/skins/base/views/atoms/create_room/CreateRoomButton.js
deleted file mode 100644
index 2f9ccae030..0000000000
--- a/skins/base/views/atoms/create_room/CreateRoomButton.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var CreateRoomButtonController = require("../../../../../src/controllers/atoms/create_room/CreateRoomButton");
-
-module.exports = React.createClass({
-    displayName: 'CreateRoomButton',
-    mixins: [CreateRoomButtonController],
-
-    render: function() {
-        return (
-            <button className="mx_CreateRoomButton" onClick={this.onClick}>Create Room</button>
-        );
-    }
-});
diff --git a/skins/base/views/atoms/create_room/Presets.js b/skins/base/views/atoms/create_room/Presets.js
deleted file mode 100644
index 83fe61bdbb..0000000000
--- a/skins/base/views/atoms/create_room/Presets.js
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var PresetsController = require("../../../../../src/controllers/atoms/create_room/Presets");
-
-module.exports = React.createClass({
-    displayName: 'CreateRoomPresets',
-    mixins: [PresetsController],
-
-    onValueChanged: function(ev) {
-        this.setState({preset: ev.target.value})
-    },
-
-    render: function() {
-        return (
-            <select className="mx_Presets" onChange={this.onValueChanged} defaultValue={this.state.preset}>
-                <option value="private_chat">Private Chat</option>
-                <option value="public_chat">Public Chat</option>
-            </select>
-        );
-    }
-});
diff --git a/skins/base/views/atoms/create_room/RoomNameTextbox.js b/skins/base/views/atoms/create_room/RoomNameTextbox.js
deleted file mode 100644
index c358a14cb3..0000000000
--- a/skins/base/views/atoms/create_room/RoomNameTextbox.js
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var RoomNameTextboxController = require("../../../../../src/controllers/atoms/create_room/RoomNameTextbox");
-
-module.exports = React.createClass({
-    displayName: 'RoomNameTextbox',
-    mixins: [RoomNameTextboxController],
-
-    onValueChanged: function(ev) {
-        this.setState({room_name: ev.target.value})
-    },
-
-    render: function() {
-        return (
-            <input type="text" className="mx_RoomNameTextbox" placeholder="ex. MyNewRoom" onChange={this.onValueChanged}/>
-        );
-    }
-});
diff --git a/skins/base/views/molecules/MEmoteTile.js b/skins/base/views/molecules/MEmoteTile.js
deleted file mode 100644
index e1b5045db7..0000000000
--- a/skins/base/views/molecules/MEmoteTile.js
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var MEmoteTileController = require("../../../../src/controllers/molecules/MEmoteTile");
-
-module.exports = React.createClass({
-    displayName: 'MEmoteTile',
-    mixins: [MEmoteTileController],
-
-    render: function() {
-        var mxEvent = this.props.mxEvent;
-        var content = mxEvent.getContent();
-        var name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
-        return (
-            <li className="mx_MEmoteTile mx_MessageTile_content">
-                * {name} {content.body}
-            </li>
-        );
-    },
-});
-
diff --git a/skins/base/views/molecules/MFileTile.js b/skins/base/views/molecules/MFileTile.js
deleted file mode 100644
index 7685fc75e0..0000000000
--- a/skins/base/views/molecules/MFileTile.js
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var MFileTileController = require("../../../../src/controllers/molecules/MFileTile");
-
-var MatrixClientPeg = require('../../../../src/MatrixClientPeg');
-
-module.exports = React.createClass({
-    displayName: 'MFileTile',
-    mixins: [MFileTileController],
-
-    render: function() {
-        var content = this.props.mxEvent.getContent();
-        var cli = MatrixClientPeg.get();
-
-        return (
-            <li className="mx_MFileTile">
-                <a href={cli.mxcUrlToHttp(content.url)} target="_blank">
-                    {this.presentableTextForFile(content)}
-                </a>
-            </li>
-        );
-    },
-});
diff --git a/skins/base/views/molecules/MImageTile.js b/skins/base/views/molecules/MImageTile.js
deleted file mode 100644
index 97cefc538e..0000000000
--- a/skins/base/views/molecules/MImageTile.js
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var MImageTileController = require("../../../../src/controllers/molecules/MImageTile");
-
-var MatrixClientPeg = require('../../../../src/MatrixClientPeg');
-
-module.exports = React.createClass({
-    displayName: 'MImageTile',
-    mixins: [MImageTileController],
-
-    thumbHeight: function(fullWidth, fullHeight, thumbWidth, thumbHeight) {
-        if (!fullWidth || !fullHeight) {
-            // Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even
-            // log this because it's spammy
-            return undefined;
-        }
-        if (fullWidth < thumbWidth && fullHeight < thumbHeight) {
-            // no scaling needs to be applied
-            return fullHeight;
-        }
-        var widthMulti = thumbWidth / fullWidth;
-        var heightMulti = thumbHeight / fullHeight;
-        if (widthMulti < heightMulti) {
-            // width is the dominant dimension so scaling will be fixed on that
-            return Math.floor(widthMulti * fullHeight);
-        }
-        else {
-            // height is the dominant dimension so scaling will be fixed on that
-            return Math.floor(heightMulti * fullHeight);
-        }
-    },
-
-    render: function() {
-        var content = this.props.mxEvent.getContent();
-        var cli = MatrixClientPeg.get();
-
-        var thumbHeight = null;
-        if (content.info) thumbHeight = this.thumbHeight(content.info.w, content.info.h, 320, 240);
-
-        var imgStyle = {};
-        if (thumbHeight) imgStyle['height'] = thumbHeight;
-
-        return (
-            <li className="mx_MImageTile">
-                <a href={cli.mxcUrlToHttp(content.url)} target="_blank">
-                    <img src={cli.mxcUrlToHttp(content.url, 320, 240)} alt={content.body} style={imgStyle} />
-                </a>
-            </li>
-        );
-    },
-});
diff --git a/skins/base/views/molecules/MNoticeTile.js b/skins/base/views/molecules/MNoticeTile.js
deleted file mode 100644
index f63a8c2cea..0000000000
--- a/skins/base/views/molecules/MNoticeTile.js
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var MNoticeTileController = require("../../../../src/controllers/molecules/MNoticeTile");
-
-module.exports = React.createClass({
-    displayName: 'MNoticeTile',
-    mixins: [MNoticeTileController],
-
-    render: function() {
-        var content = this.props.mxEvent.getContent();
-        return (
-            <span ref="content" className="mx_MNoticeTile mx_MessageTile_content">
-                {content.body}
-            </span>
-        );
-    },
-});
-
diff --git a/skins/base/views/molecules/MRoomMemberTile.js b/skins/base/views/molecules/MRoomMemberTile.js
deleted file mode 100644
index f0755e2614..0000000000
--- a/skins/base/views/molecules/MRoomMemberTile.js
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var MRoomMemberTileController = require("../../../../src/controllers/molecules/MRoomMemberTile");
-
-var ComponentBroker = require('../../../../src/ComponentBroker');
-var MessageTimestamp = ComponentBroker.get('atoms/MessageTimestamp');
-
-module.exports = React.createClass({
-    displayName: 'MRoomMemberTile',
-    mixins: [MRoomMemberTileController],
-
-    getMemberEventText: function() {
-        var ev = this.props.mxEvent;
-        // XXX: SYJS-16
-        var senderName = ev.sender ? ev.sender.name : "Someone";
-        switch (ev.getContent().membership) {
-            case 'invite':
-                return senderName + " invited " + ev.target.name + ".";
-            case 'join':
-                return senderName + " joined the room.";
-            case 'leave':
-                return senderName + " left the room.";
-        }
-    },
-
-    render: function() {
-        // XXX: for now, just cheekily borrow the css from message tile...
-        return (
-            <div className="mx_MessageTile">
-                <MessageTimestamp ts={this.props.mxEvent.getTs()} />
-                <span className="mx_SenderProfile"></span>
-                <span className="mx_MessageTile_content">
-                    {this.getMemberEventText()}
-                </span>
-            </div>
-        );
-    },
-});
-
diff --git a/skins/base/views/molecules/MTextTile.js b/skins/base/views/molecules/MTextTile.js
deleted file mode 100644
index d08f42ed9a..0000000000
--- a/skins/base/views/molecules/MTextTile.js
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var MTextTileController = require("../../../../src/controllers/molecules/MTextTile");
-
-module.exports = React.createClass({
-    displayName: 'MTextTile',
-    mixins: [MTextTileController],
-
-    render: function() {
-        var content = this.props.mxEvent.getContent();
-        return (
-            <span ref="content" className="mx_MTextTile mx_MessageTile_content">
-                {content.body}
-            </span>
-        );
-    },
-});
-
diff --git a/skins/base/views/molecules/MatrixToolbar.js b/skins/base/views/molecules/MatrixToolbar.js
deleted file mode 100644
index e4444ee9c8..0000000000
--- a/skins/base/views/molecules/MatrixToolbar.js
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var ComponentBroker = require('../../../../src/ComponentBroker');
-
-var LogoutButton = ComponentBroker.get("atoms/LogoutButton");
-var EnableNotificationsButton = ComponentBroker.get("atoms/EnableNotificationsButton");
-
-var MatrixToolbarController = require("../../../../src/controllers/molecules/MatrixToolbar");
-
-module.exports = React.createClass({
-    displayName: 'MatrixToolbar',
-    mixins: [MatrixToolbarController],
-
-    render: function() {
-        return (
-            <div className="mx_MatrixToolbar">
-                <LogoutButton />
-                <EnableNotificationsButton />
-            </div>
-        );
-    }
-});
-
diff --git a/skins/base/views/molecules/MemberTile.js b/skins/base/views/molecules/MemberTile.js
deleted file mode 100644
index 60d1cadd84..0000000000
--- a/skins/base/views/molecules/MemberTile.js
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var MemberTileController = require("../../../../src/controllers/molecules/MemberTile");
-
-module.exports = React.createClass({
-    displayName: 'MemberTile',
-    mixins: [MemberTileController],
-    render: function() {
-        return (
-            <div className="mx_MemberTile">
-                <div className="mx_MemberTile_name">{this.props.member.name}</div>
-            </div>
-        );
-    }
-});
diff --git a/skins/base/views/molecules/MessageTile.js b/skins/base/views/molecules/MessageTile.js
deleted file mode 100644
index b28e562b20..0000000000
--- a/skins/base/views/molecules/MessageTile.js
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var classNames = require("classnames");
-
-var ComponentBroker = require('../../../../src/ComponentBroker');
-
-var MessageTimestamp = ComponentBroker.get('atoms/MessageTimestamp');
-var SenderProfile = ComponentBroker.get('molecules/SenderProfile');
-
-var UnknownMessageTile = ComponentBroker.get('molecules/UnknownMessageTile');
-
-var tileTypes = {
-    'm.text': ComponentBroker.get('molecules/MTextTile'),
-    'm.notice': ComponentBroker.get('molecules/MNoticeTile'),
-    'm.emote': ComponentBroker.get('molecules/MEmoteTile'),
-    'm.image': ComponentBroker.get('molecules/MImageTile'),
-    'm.file': ComponentBroker.get('molecules/MFileTile')
-};
-
-var MessageTileController = require("../../../../src/controllers/molecules/MessageTile");
-
-module.exports = React.createClass({
-    displayName: 'MessageTile',
-    mixins: [MessageTileController],
-
-    render: function() {
-        var content = this.props.mxEvent.getContent();
-        var msgtype = content.msgtype;
-        var TileType = UnknownMessageTile;
-        if (msgtype && tileTypes[msgtype]) {
-            TileType = tileTypes[msgtype];
-        }
-        var classes = classNames({
-            mx_MessageTile: true,
-            mx_MessageTile_sending: this.props.mxEvent.status == 'sending',
-            mx_MessageTile_notSent: this.props.mxEvent.status == 'not_sent',
-            mx_MessageTile_highlight: this.shouldHighlight()
-        });
-        return (
-            <li className={classes}>
-                <MessageTimestamp ts={this.props.mxEvent.getTs()} />
-                <SenderProfile mxEvent={this.props.mxEvent} />
-                <TileType mxEvent={this.props.mxEvent} />
-            </li>
-        );
-    },
-});
-
diff --git a/skins/base/views/molecules/ProgressBar.js b/skins/base/views/molecules/ProgressBar.js
deleted file mode 100644
index 0946ffcc26..0000000000
--- a/skins/base/views/molecules/ProgressBar.js
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var ProgressBarController = require("../../../../src/controllers/molecules/ProgressBar");
-
-module.exports = React.createClass({
-    displayName: 'ProgressBar',
-    mixins: [ProgressBarController],
-
-    render: function() {
-        // Would use an HTML5 progress tag but if that doesn't animate if you
-        // use the HTML attributes rather than styles
-        var progressStyle = {
-            width: ((this.props.value / this.props.max) * 100)+"%"
-        };
-        return (
-            <div className="mx_ProgressBar"><div className="mx_ProgressBar_fill" style={progressStyle}></div></div>
-        );
-    }
-});
diff --git a/skins/base/views/molecules/RoomTile.js b/skins/base/views/molecules/RoomTile.js
deleted file mode 100644
index 0e80fc2015..0000000000
--- a/skins/base/views/molecules/RoomTile.js
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-var classNames = require('classnames');
-
-var RoomTileController = require("../../../../src/controllers/molecules/RoomTile");
-
-var MatrixClientPeg = require("../../../../src/MatrixClientPeg");
-
-module.exports = React.createClass({
-    displayName: 'RoomTile',
-    mixins: [RoomTileController],
-    render: function() {
-        var myUserId = MatrixClientPeg.get().credentials.userId;
-        var classes = classNames({
-            'mx_RoomTile': true,
-            'selected': this.props.selected,
-            'unread': this.props.unread,
-            'highlight': this.props.highlight,
-            'invited': this.props.room.currentState.members[myUserId].membership == 'invite'
-        });
-        return (
-            <div className={classes} onClick={this.onClick}>
-                <div className="mx_RoomTile_name">{this.props.room.name}</div>
-            </div>
-        );
-    }
-});
diff --git a/skins/base/views/molecules/SenderProfile.js b/skins/base/views/molecules/SenderProfile.js
deleted file mode 100644
index d71d1c2226..0000000000
--- a/skins/base/views/molecules/SenderProfile.js
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var SenderProfileController = require("../../../../src/controllers/molecules/SenderProfile");
-
-module.exports = React.createClass({
-    displayName: 'SenderProfile',
-    mixins: [SenderProfileController],
-
-    render: function() {
-        var mxEvent = this.props.mxEvent;
-        var name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender();
-
-        var msgtype = mxEvent.getContent().msgtype;
-        if (msgtype && msgtype == 'm.emote') {
-            name = ''; // emote message must include the name so don't duplicate it
-        }
-        return (
-            <span className="mx_SenderProfile">
-                {name}
-            </span>
-        );
-    },
-});
-
diff --git a/skins/base/views/molecules/ServerConfig.js b/skins/base/views/molecules/ServerConfig.js
deleted file mode 100644
index e06536c552..0000000000
--- a/skins/base/views/molecules/ServerConfig.js
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var ServerConfigController = require("../../../../src/controllers/molecules/ServerConfig");
-
-module.exports = React.createClass({
-    displayName: 'ServerConfig',
-    mixins: [ServerConfigController],
-
-    render: function() {
-        return (
-            <div className="HomeServerTextBox">
-                <table className="serverConfig">
-                <tr>
-                <td>Home Server URL</td>
-                <td><input type="text" value={this.state.hs_url} onChange={this.hsChanged} /></td>
-                </tr>
-                <tr>
-                <td>Identity Server URL</td>
-                <td><input type="text" value={this.state.is_url} onChange={this.isChanged} /></td>
-                </tr>
-                </table>
-            </div>
-        );
-    }
-});
diff --git a/skins/base/views/molecules/UnknownMessageTile.js b/skins/base/views/molecules/UnknownMessageTile.js
deleted file mode 100644
index 27e801c994..0000000000
--- a/skins/base/views/molecules/UnknownMessageTile.js
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var UnknownMessageTileController = require("../../../../src/controllers/molecules/UnknownMessageTile");
-
-module.exports = React.createClass({
-    displayName: 'UnknownMessageTile',
-    mixins: [UnknownMessageTileController],
-
-    render: function() {
-        return (
-            <span className="mx_UnknownMessageTile">
-                ?
-            </span>
-        );
-    },
-});
diff --git a/skins/base/views/molecules/UserSelector.js b/skins/base/views/molecules/UserSelector.js
deleted file mode 100644
index 7517e29d0f..0000000000
--- a/skins/base/views/molecules/UserSelector.js
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var UserSelectorController = require("../../../../src/controllers/molecules/UserSelector");
-
-module.exports = React.createClass({
-    displayName: 'UserSelector',
-    mixins: [UserSelectorController],
-
-    onAddUserId: function() {
-        this.addUser(this.refs.user_id_input.getDOMNode().value);
-    },
-
-    render: function() {
-        return (
-            <div>
-                <ul className="mx_UserSelector_UserIdList" ref="list">
-                    {this.state.selected_users.map(function(user_id, i) {
-                        return <li key={user_id}>{user_id}</li>
-                    })}
-                </ul>
-                <input type="text" ref="user_id_input" className="mx_UserSelector_userIdInput" placeholder="ex. @bob:example.com"/>
-                <button onClick={this.onAddUserId} className="mx_UserSelector_AddUserId">Add User</button>
-            </div>
-        );
-    }
-});
diff --git a/skins/base/views/organisms/CreateRoom.js b/skins/base/views/organisms/CreateRoom.js
deleted file mode 100644
index 36f6e466e5..0000000000
--- a/skins/base/views/organisms/CreateRoom.js
+++ /dev/null
@@ -1,73 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var CreateRoomController = require("../../../../src/controllers/organisms/CreateRoom");
-
-var ComponentBroker = require('../../../../src/ComponentBroker');
-
-var CreateRoomButton = ComponentBroker.get("atoms/create_room/CreateRoomButton");
-var RoomNameTextbox = ComponentBroker.get("atoms/create_room/RoomNameTextbox");
-var Presets = ComponentBroker.get("atoms/create_room/Presets");
-var UserSelector = ComponentBroker.get("molecules/UserSelector");
-
-
-module.exports = React.createClass({
-    displayName: 'CreateRoom',
-    mixins: [CreateRoomController],
-
-    getPreset: function() {
-        return this.refs.presets.getPreset();
-    },
-
-    getName: function() {
-        return this.refs.name_textbox.getName();
-    },
-
-    getInvitedUsers: function() {
-        return this.refs.user_selector.getUserIds();
-    },
-
-    render: function() {
-        var curr_phase = this.state.phase;
-        if (curr_phase == this.phases.CREATING) {
-            return (
-                <div>Creating...</div>
-            );
-        } else {
-            var error_box = "";
-            if (curr_phase == this.phases.ERROR) {
-                error_box = (
-                    <div className="mx_Error">
-                        An error occured: {this.state.error_string}
-                    </div>
-                );
-            }
-            return (
-                <div className="mx_CreateRoom">
-                    <label>Room Name <RoomNameTextbox ref="name_textbox" /></label>
-                    <Presets ref="presets"/>
-                    <UserSelector ref="user_selector"/>
-                    <CreateRoomButton onCreateRoom={this.onCreateRoom} />
-                    {error_box}
-                </div>
-            );
-        }
-    }
-});
diff --git a/skins/base/views/organisms/MemberList.js b/skins/base/views/organisms/MemberList.js
deleted file mode 100644
index 5d1b2fd0f9..0000000000
--- a/skins/base/views/organisms/MemberList.js
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var MemberListController = require("../../../../src/controllers/organisms/MemberList");
-
-var ComponentBroker = require('../../../../src/ComponentBroker');
-
-var MemberTile = ComponentBroker.get("molecules/MemberTile");
-
-
-module.exports = React.createClass({
-    displayName: 'MemberList',
-    mixins: [MemberListController],
-
-    makeMemberTiles: function() {
-        var that = this;
-        return Object.keys(that.state.memberDict).map(function(userId) {
-            var m = that.state.memberDict[userId];
-            return (
-                <li key={userId}>
-                <MemberTile
-                    member={m}
-                />
-                </li>
-            );
-        });
-    },
-
-    render: function() {
-        return (
-            <div className="mx_MemberList">
-                <ul>
-                    {this.makeMemberTiles()}
-                </ul>
-            </div>
-        );
-    }
-});
-
diff --git a/skins/base/views/organisms/Notifier.js b/skins/base/views/organisms/Notifier.js
deleted file mode 100644
index 09f1921ac3..0000000000
--- a/skins/base/views/organisms/Notifier.js
+++ /dev/null
@@ -1,102 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var NotifierController = require("../../../../src/controllers/organisms/Notifier");
-
-var MatrixClientPeg = require("../../../../src/MatrixClientPeg");
-var extend = require("../../../../src/extend");
-var dis = require("../../../../src/dispatcher");
-
-
-var NotifierView = {
-    notificationMessageForEvent: function(ev) {
-        var senderDisplayName = ev.sender ? ev.sender.name : '';
-        var message = null;
-
-        if (ev.event.type === "m.room.message") {
-            message = ev.getContent().body;
-            if (ev.getContent().msgtype === "m.emote") {
-                message = "* " + senderDisplayName + " " + message;
-            } else if (ev.getContent().msgtype === "m.image") {
-                message = senderDisplayName + " sent an image.";
-            }
-        } else if (ev.event.type == "m.room.member") {
-            if (ev.event.state_key !== MatrixClientPeg.get().credentials.userId  && "join" === ev.getContent().membership) {
-                // Notify when another user joins
-                message = senderDisplayName + " joined";
-            } else if (ev.event.state_key === MatrixClientPeg.get().credentials.userId  && "invite" === ev.getContent().membership) {
-                // notify when you are invited
-                message = senderDisplayName + " invited you to a room";
-            }
-        }
-        return message;
-    },
-
-    displayNotification: function(ev, room) {
-        if (!global.Notification || global.Notification.permission != 'granted') {
-            return;
-        }
-        if (global.document.hasFocus()) {
-            return;
-        }
-
-        var msg = this.notificationMessageForEvent(ev);
-        if (!msg) return;
-
-        var title;
-        if (!ev.sender ||  room.name == ev.sender.name) {
-            title = room.name;
-        } else if (ev.sender) {
-            title = ev.sender.name + " (" + room.name + ")";
-        }
-
-        var notification = new global.Notification(
-            title,
-            {
-                "body": msg,
-                "icon": MatrixClientPeg.get().getAvatarUrlForMember(ev.sender)
-            }
-        );
-
-        notification.onclick = function() {
-            dis.dispatch({
-                action: 'view_room',
-                room_id: room.roomId
-            });
-            global.focus();
-        };
-        
-        /*var audioClip;
-        
-        if (audioNotification) {
-            audioClip = playAudio(audioNotification);
-        }*/
-
-        global.setTimeout(function() {
-            notification.close();
-        }, 5 * 1000);
-        
-    }
-};
-
-var NotifierClass = function() {};
-extend(NotifierClass.prototype, NotifierController);
-extend(NotifierClass.prototype, NotifierView);
-
-module.exports = new NotifierClass();
-
diff --git a/skins/base/views/organisms/RoomList.js b/skins/base/views/organisms/RoomList.js
deleted file mode 100644
index f8be66f76e..0000000000
--- a/skins/base/views/organisms/RoomList.js
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var RoomListController = require("../../../../src/controllers/organisms/RoomList");
-
-
-module.exports = React.createClass({
-    displayName: 'RoomList',
-    mixins: [RoomListController],
-
-    render: function() {
-        return (
-            <div className="mx_RoomList">
-                {this.makeRoomTiles()}
-            </div>
-        );
-    }
-});
-
diff --git a/skins/base/views/organisms/RoomView.js b/skins/base/views/organisms/RoomView.js
deleted file mode 100644
index 20d073b990..0000000000
--- a/skins/base/views/organisms/RoomView.js
+++ /dev/null
@@ -1,99 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var MatrixClientPeg = require("../../../../src/MatrixClientPeg");
-
-var ComponentBroker = require('../../../../src/ComponentBroker');
-var classNames = require("classnames");
-
-var MessageTile = ComponentBroker.get('molecules/MessageTile');
-var RoomHeader = ComponentBroker.get('molecules/RoomHeader');
-var MemberList = ComponentBroker.get('organisms/MemberList');
-var MessageComposer = ComponentBroker.get('molecules/MessageComposer');
-
-var RoomViewController = require("../../../../src/controllers/organisms/RoomView");
-
-var Loader = require("react-loader");
-
-
-module.exports = React.createClass({
-    displayName: 'RoomView',
-    mixins: [RoomViewController],
-
-    render: function() {
-        if (!this.state.room) {
-            return (
-                <div />
-            );
-        }
-
-        var myUserId = MatrixClientPeg.get().credentials.userId;
-        if (this.state.room.currentState.members[myUserId].membership == 'invite') {
-            if (this.state.joining) {
-                return (
-                    <div className="mx_RoomView">
-                        <Loader />
-                    </div>
-                );
-            } else {
-                var inviteEvent = this.state.room.currentState.members[myUserId].events.member.event;
-                // XXX: Leaving this intentionally basic for now because invites are about to change totally
-                var joinErrorText = this.state.joinError ? "Failed to join room!" : "";
-                return (
-                    <div className="mx_RoomView">
-                        <div className="mx_RoomView_invitePrompt">
-                            <div>{inviteEvent.user_id} has invited you to a room</div>
-                            <button ref="joinButton" onClick={this.onJoinButtonClicked}>Join</button>
-                            <div className="error">{joinErrorText}</div>
-                        </div>
-                    </div>
-                );
-            }
-        } else {
-            var scrollheader_classes = classNames({
-                mx_RoomView_scrollheader: true,
-                loading: this.state.paginating
-            });
-            return (
-                <div className="mx_RoomView">
-                    <RoomHeader room={this.state.room} />
-                    <div className="mx_RoomView_roomWrapper">
-                        <main className="mx_RoomView_messagePanel">
-                            <div ref="messageWrapper" className="mx_RoomView_messageListWrapper" onScroll={this.onMessageListScroll}>
-                                <div className="mx_RoomView_MessageList">
-                                    <div className={scrollheader_classes}>
-                                    </div>
-                                    <ul className="mx_RoomView_MessageList_ul" aria-live="polite">
-                                        {this.getEventTiles()}
-                                    </ul>
-                                </div>
-                            </div>
-                            <MessageComposer roomId={this.props.roomId} />
-                        </main>
-                        <aside>
-                            <MemberList roomId={this.props.roomId} key={this.props.roomId} />
-                        </aside>
-                    </div>
-                </div>
-            );
-        }
-    },
-});
-
diff --git a/skins/base/views/pages/MatrixChat.js b/skins/base/views/pages/MatrixChat.js
deleted file mode 100644
index be4cd43417..0000000000
--- a/skins/base/views/pages/MatrixChat.js
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-var ComponentBroker = require('../../../../src/ComponentBroker');
-
-var RoomList = ComponentBroker.get('organisms/RoomList');
-var RoomView = ComponentBroker.get('organisms/RoomView');
-var MatrixToolbar = ComponentBroker.get('molecules/MatrixToolbar');
-var Login = ComponentBroker.get('templates/Login');
-var Register = ComponentBroker.get('templates/Register');
-
-var MatrixChatController = require("../../../../src/controllers/pages/MatrixChat");
-
-// should be atomised
-var Loader = require("react-loader");
-
-
-module.exports = React.createClass({
-    displayName: 'MatrixChat',
-    mixins: [MatrixChatController],
-
-    render: function() {
-        if (this.state.logged_in && this.state.ready) {
-            return (
-                <div className="mx_MatrixChat">
-                    <div className="mx_MatrixChat_chatWrapper">
-                        <aside className="mx_MatrixChat_leftPanel">
-                            <RoomList selectedRoom={this.state.currentRoom} />
-                            <MatrixToolbar />
-                        </aside>
-                        <RoomView roomId={this.state.currentRoom} key={this.state.currentRoom} />
-                    </div>
-                </div>
-            );
-        } else if (this.state.logged_in) {
-            return (
-                <Loader />
-            );
-        } else if (this.state.screen == 'register') {
-            return (
-                <Register onLoggedIn={this.onLoggedIn} clientSecret={this.state.register_client_secret}
-                    sessionId={this.state.register_session_id} idSid={this.state.register_id_sid}
-                    hsUrl={this.state.register_hs_url} isUrl={this.state.register_is_url}
-                    registrationUrl={this.props.registrationUrl}
-                />
-            );
-        } else {
-            return (
-                <Login onLoggedIn={this.onLoggedIn} />
-            );
-        }
-    }
-});
-
diff --git a/skins/base/views/templates/Login.js b/skins/base/views/templates/Login.js
deleted file mode 100644
index f71e307068..0000000000
--- a/skins/base/views/templates/Login.js
+++ /dev/null
@@ -1,102 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var ComponentBroker = require("../../../../src/ComponentBroker");
-
-var ProgressBar = ComponentBroker.get("molecules/ProgressBar");
-var Loader = require("react-loader");
-
-var LoginController = require("../../../../src/controllers/templates/Login");
-
-var ServerConfig = ComponentBroker.get("molecules/ServerConfig");
-
-module.exports = React.createClass({
-    displayName: 'Login',
-    mixins: [LoginController],
-
-    getHsUrl: function() {
-        return this.refs.serverConfig.getHsUrl();
-    },
-
-    getIsUrl: function() {
-        return this.refs.serverConfig.getIsUrl();
-    },
-
-    /**
-     * Gets the form field values for the current login stage
-     */
-    getFormVals: function() {
-        return {
-            'username': this.refs.user.getDOMNode().value,
-            'password': this.refs.pass.getDOMNode().value
-        };
-    },
-
-    componentForStep: function(step) {
-        switch (step) {
-            case 'choose_hs':
-                return (
-                    <div>
-                        <form onSubmit={this.onHSChosen}>
-                        <ServerConfig ref="serverConfig" />
-                        <input type="submit" value="Continue" />
-                        </form>
-                    </div>
-                );
-            // XXX: clearly these should be separate organisms
-            case 'stage_m.login.password':
-                return (
-                    <div>
-                        <form onSubmit={this.onUserPassEntered}>
-                        <input ref="user" type="text" placeholder="username" /><br />
-                        <input ref="pass" type="password" placeholder="password" /><br />
-                        <input type="submit" value="Log in" />
-                        </form>
-                    </div>
-                );
-        }
-    },
-
-    loginContent: function() {
-        if (this.state.busy) {
-            return (
-                <Loader />
-            );
-        } else {
-            return (
-                <div>
-                    <h1>Please log in:</h1>
-                    {this.componentForStep(this.state.step)}
-                    <div className="error">{this.state.errorText}</div>
-                    <a onClick={this.showRegister} href="#">Create a new account</a>
-                </div>
-            );
-        }
-    },
-
-    render: function() {
-        return (
-            <div className="mx_Login">
-            <ProgressBar value={this.state.currentStep} max={this.state.totalSteps} />
-            {this.loginContent()}
-            </div>
-        );
-    }
-});
diff --git a/skins/base/views/templates/Register.js b/skins/base/views/templates/Register.js
deleted file mode 100644
index 94f3b96971..0000000000
--- a/skins/base/views/templates/Register.js
+++ /dev/null
@@ -1,131 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-var React = require('react');
-
-var ComponentBroker = require("../../../../src/ComponentBroker");
-
-var Loader = require("react-loader");
-
-var RegisterController = require("../../../../src/controllers/templates/Register");
-
-var ServerConfig = ComponentBroker.get("molecules/ServerConfig");
-
-module.exports = React.createClass({
-    displayName: 'Register',
-    mixins: [RegisterController],
-
-    getRegFormVals: function() {
-        return {
-            email: this.refs.email.getDOMNode().value,
-            username: this.refs.username.getDOMNode().value,
-            password: this.refs.password.getDOMNode().value,
-            confirmPassword: this.refs.confirmPassword.getDOMNode().value
-        };
-    },
-
-    getHsUrl: function() {
-        return this.refs.serverConfig.getHsUrl();
-    },
-
-    getIsUrl: function() {
-        return this.refs.serverConfig.getIsUrl();
-    },
-
-    componentForStep: function(step) {
-        switch (step) {
-            case 'initial':
-                return (
-                    <div>
-                        <form onSubmit={this.onInitialStageSubmit}>
-                        Email: <input type="text" ref="email" defaultValue={this.savedParams.email} /><br />
-                        Username: <input type="text" ref="username" defaultValue={this.savedParams.username} /><br />
-                        Password: <input type="password" ref="password" defaultValue={this.savedParams.password} /><br />
-                        Confirm Password: <input type="password" ref="confirmPassword" defaultValue={this.savedParams.confirmPassword} /><br />
-                        <ServerConfig ref="serverConfig" />
-                        <input type="submit" value="Continue" />
-                        </form>
-                    </div>
-                );
-            // XXX: clearly these should be separate organisms
-            case 'stage_m.login.email.identity':
-                return (
-                    <div>
-                        Please check your email to continue registration.
-                    </div>
-                );
-            case 'stage_m.login.recaptcha':
-                return (
-                    <div ref="recaptchaContainer">
-                        This Home Server would like to make sure you're not a robot
-                        <div id="mx_recaptcha"></div>
-                    </div>
-                );
-        }
-    },
-
-    registerContent: function() {
-        if (this.state.busy) {
-            return (
-                <Loader />
-            );
-        } else {
-            return (
-                <div>
-                    <h1>Create a new account:</h1>
-                    {this.componentForStep(this.state.step)}
-                    <div className="error">{this.state.errorText}</div>
-                    <a onClick={this.showLogin} href="#">Sign in with existing account</a>
-                </div>
-            );
-        }
-    },
-
-    onBadFields: function(bad) {
-        var keys = Object.keys(bad);
-        var strings = [];
-        for (var i = 0; i < keys.length; ++i) {
-            switch (bad[keys[i]]) {
-                case this.FieldErrors.PasswordMismatch:
-                    strings.push("Passwords don't match");
-                    break;
-                case this.FieldErrors.Missing:
-                    strings.push("Missing "+keys[i]);
-                    break;
-                case this.FieldErrors.TooShort:
-                    strings.push(keys[i]+" is too short");
-                    break;
-                case this.FieldErrors.InUse:
-                    strings.push(keys[i]+" is already taken");
-                    break;
-            }
-        }
-        var errtxt = strings.join(', ');
-        this.setState({
-            errorText: errtxt
-        });
-    },
-
-    render: function() {
-        return (
-            <div className="mx_Register">
-            {this.registerContent()}
-            </div>
-        );
-    }
-});
diff --git a/src/CallHandler.js b/src/CallHandler.js
new file mode 100644
index 0000000000..d0cf16f801
--- /dev/null
+++ b/src/CallHandler.js
@@ -0,0 +1,302 @@
+/*
+Copyright 2015 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.
+*/
+
+/*
+ * Manages a list of all the currently active calls.
+ *
+ * This handler dispatches when voip calls are added/updated/removed from this list:
+ * {
+ *   action: 'call_state'
+ *   room_id: <room ID of the call>
+ * }
+ *
+ * To know the state of the call, this handler exposes a getter to
+ * obtain the call for a room:
+ *   var call = CallHandler.getCall(roomId)
+ *   var state = call.call_state; // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing
+ *
+ * This handler listens for and handles the following actions:
+ * {
+ *   action: 'place_call',
+ *   type: 'voice|video',
+ *   room_id: <room that the place call button was pressed in>
+ * }
+ *
+ * {
+ *   action: 'incoming_call'
+ *   call: MatrixCall
+ * }
+ *
+ * {
+ *   action: 'hangup'
+ *   room_id: <room that the hangup button was pressed in>
+ * }
+ *
+ * {
+ *   action: 'answer'
+ *   room_id: <room that the answer button was pressed in>
+ * }
+ */
+
+var MatrixClientPeg = require('./MatrixClientPeg');
+var Modal = require('./Modal');
+var sdk = require('./index');
+var Matrix = require("matrix-js-sdk");
+var dis = require("./dispatcher");
+var Modulator = require("./Modulator");
+
+global.mxCalls = {
+    //room_id: MatrixCall
+};
+var calls = global.mxCalls;
+
+function play(audioId) {
+    // TODO: Attach an invisible element for this instead
+    // which listens?
+    var audio = document.getElementById(audioId);
+    if (audio) {
+        audio.load();
+        audio.play();
+    }
+}
+
+function pause(audioId) {
+    // TODO: Attach an invisible element for this instead
+    // which listens?
+    var audio = document.getElementById(audioId);
+    if (audio) {
+        audio.pause();
+    }
+}
+
+function _setCallListeners(call) {
+    call.on("error", function(err) {
+        console.error("Call error: %s", err);
+        console.error(err.stack);
+        call.hangup();
+        _setCallState(undefined, call.roomId, "ended");
+    });
+    call.on("hangup", function() {
+        _setCallState(undefined, call.roomId, "ended");
+    });
+    // map web rtc states to dummy UI state
+    // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing
+    call.on("state", function(newState, oldState) {
+        if (newState === "ringing") {
+            _setCallState(call, call.roomId, "ringing");
+            pause("ringbackAudio");
+        }
+        else if (newState === "invite_sent") {
+            _setCallState(call, call.roomId, "ringback");
+            play("ringbackAudio");
+        }
+        else if (newState === "ended" && oldState === "connected") {
+            _setCallState(undefined, call.roomId, "ended");
+            pause("ringbackAudio");
+            play("callendAudio");
+        }
+        else if (newState === "ended" && oldState === "invite_sent" &&
+                (call.hangupParty === "remote" ||
+                (call.hangupParty === "local" && call.hangupReason === "invite_timeout")
+                )) {
+            _setCallState(call, call.roomId, "busy");
+            pause("ringbackAudio");
+            play("busyAudio");
+            var ErrorDialog = sdk.getComponent("organisms.ErrorDialog");
+            Modal.createDialog(ErrorDialog, {
+                title: "Call Timeout",
+                description: "The remote side failed to pick up."
+            });
+        }
+        else if (oldState === "invite_sent") {
+            _setCallState(call, call.roomId, "stop_ringback");
+            pause("ringbackAudio");
+        }
+        else if (oldState === "ringing") {
+            _setCallState(call, call.roomId, "stop_ringing");
+            pause("ringbackAudio");
+        }
+        else if (newState === "connected") {
+            _setCallState(call, call.roomId, "connected");
+            pause("ringbackAudio");
+        }
+    });
+}
+
+function _setCallState(call, roomId, status) {
+    console.log(
+        "Call state in %s changed to %s (%s)", roomId, status, (call ? call.state : "-")
+    );
+    calls[roomId] = call;
+    if (call) {
+        call.call_state = status;
+    }
+    dis.dispatch({
+        action: 'call_state',
+        room_id: roomId
+    });
+}
+
+function _onAction(payload) {
+    function placeCall(newCall) {
+        _setCallListeners(newCall);
+        _setCallState(newCall, newCall.roomId, "ringback");
+        if (payload.type === 'voice') {
+            newCall.placeVoiceCall();
+        }
+        else if (payload.type === 'video') {
+            newCall.placeVideoCall(
+                payload.remote_element,
+                payload.local_element
+            );
+        }
+        else {
+            console.error("Unknown conf call type: %s", payload.type);
+        }
+    }
+
+    switch (payload.action) {
+        case 'place_call':
+            if (module.exports.getAnyActiveCall()) {
+                var ErrorDialog = sdk.getComponent("organisms.ErrorDialog");
+                Modal.createDialog(ErrorDialog, {
+                    title: "Existing Call",
+                    description: "You are already in a call."
+                });
+                return; // don't allow >1 call to be placed.
+            }
+            var room = MatrixClientPeg.get().getRoom(payload.room_id);
+            if (!room) {
+                console.error("Room %s does not exist.", payload.room_id);
+                return;
+            }
+
+            var members = room.getJoinedMembers();
+            if (members.length <= 1) {
+                var ErrorDialog = sdk.getComponent("organisms.ErrorDialog");
+                Modal.createDialog(ErrorDialog, {
+                    description: "You cannot place a call with yourself."
+                });
+                return;
+            }
+            else if (members.length === 2) {
+                console.log("Place %s call in %s", payload.type, payload.room_id);
+                var call = Matrix.createNewMatrixCall(
+                    MatrixClientPeg.get(), payload.room_id
+                );
+                placeCall(call);
+            }
+            else { // > 2
+                dis.dispatch({
+                    action: "place_conference_call",
+                    room_id: payload.room_id,
+                    type: payload.type,
+                    remote_element: payload.remote_element,
+                    local_element: payload.local_element
+                });
+            }
+            break;
+        case 'place_conference_call':
+            console.log("Place conference call in %s", payload.room_id);
+            if (!Modulator.hasConferenceHandler()) {
+                var ErrorDialog = sdk.getComponent("organisms.ErrorDialog");
+                Modal.createDialog(ErrorDialog, {
+                    description: "Conference calls are not supported in this client"
+                });
+            } else {
+                var ConferenceHandler = Modulator.getConferenceHandler();
+                ConferenceHandler.createNewMatrixCall(
+                    MatrixClientPeg.get(), payload.room_id
+                ).done(function(call) {
+                    placeCall(call);
+                }, function(err) {
+                    console.error("Failed to setup conference call: %s", err);
+                });
+            }
+            break;
+        case 'incoming_call':
+            if (module.exports.getAnyActiveCall()) {
+                payload.call.hangup("busy");
+                return; // don't allow >1 call to be received, hangup newer one.
+            }
+            var call = payload.call;
+            _setCallListeners(call);
+            _setCallState(call, call.roomId, "ringing");
+            break;
+        case 'hangup':
+            if (!calls[payload.room_id]) {
+                return; // no call to hangup
+            }
+            calls[payload.room_id].hangup();
+            _setCallState(null, payload.room_id, "ended");
+            break;
+        case 'answer':
+            if (!calls[payload.room_id]) {
+                return; // no call to answer
+            }
+            calls[payload.room_id].answer();
+            _setCallState(calls[payload.room_id], payload.room_id, "connected");
+            dis.dispatch({
+                action: "view_room",
+                room_id: payload.room_id
+            });
+            break;
+    }
+}
+// FIXME: Nasty way of making sure we only register
+// with the dispatcher once
+if (!global.mxCallHandler) {
+    dis.register(_onAction);
+}
+
+var callHandler = {
+    getCallForRoom: function(roomId) {
+        var call = module.exports.getCall(roomId);
+        if (call) return call;
+
+        if (Modulator.hasConferenceHandler()) {
+            var ConferenceHandler = Modulator.getConferenceHandler();
+            call = ConferenceHandler.getConferenceCallForRoom(roomId);
+        }
+        if (call) return call;
+
+        return null;
+    },
+
+    getCall: function(roomId) {
+        return calls[roomId] || null;
+    },
+
+    getAnyActiveCall: function() {
+        var roomsWithCalls = Object.keys(calls);
+        for (var i = 0; i < roomsWithCalls.length; i++) {
+            if (calls[roomsWithCalls[i]] &&
+                    calls[roomsWithCalls[i]].call_state !== "ended") {
+                return calls[roomsWithCalls[i]];
+            }
+        }
+        return null;
+    }
+};
+// Only things in here which actually need to be global are the
+// calls list (done separately) and making sure we only register
+// with the dispatcher once (which uses this mechanism but checks
+// separately). This could be tidied up.
+if (global.mxCallHandler === undefined) {
+    global.mxCallHandler = callHandler;
+}
+
+module.exports = global.mxCallHandler;
diff --git a/src/ComponentBroker.js b/src/ComponentBroker.js
deleted file mode 100644
index 6445e9472f..0000000000
--- a/src/ComponentBroker.js
+++ /dev/null
@@ -1,92 +0,0 @@
-/*
-Copyright 2015 OpenMarket Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-'use strict';
-
-function load(name) {
-    var module = require("../skins/base/views/"+name);
-    return module;
-};
-
-var ComponentBroker = function() {
-    this.components = {};
-};
-
-ComponentBroker.prototype = {
-    get: function(name) {
-        if (this.components[name]) {
-            return this.components[name];
-        }
-
-        this.components[name] = load(name);
-        return this.components[name];
-    },
-
-    set: function(name, module) {
-        this.components[name] = module;
-    }
-};
-
-// We define one Component Broker globally, because the intention is
-// very much that it is a singleton. Relying on there only being one
-// copy of the module can be dicey and not work as browserify's
-// behaviour with multiple copies of files etc. is erratic at best.
-// XXX: We can still end up with the same file twice in the resulting
-// JS bundle which is nonideal.
-if (global.componentBroker === undefined) {
-    global.componentBroker = new ComponentBroker();
-}
-module.exports = global.componentBroker;
-
-// We need to tell browserify to include all the components
-// by direct require syntax in here, but we don't want them
-// to be evaluated in this file because then we wouldn't be
-// able to override them. if (0) does this.
-// Must be in this file (because the require is file-specific) and
-// must be at the end because the components include this file.
-if (0) {
-require('../skins/base/views/atoms/LogoutButton');
-require('../skins/base/views/atoms/EnableNotificationsButton');
-require('../skins/base/views/atoms/MessageTimestamp');
-require('../skins/base/views/atoms/create_room/CreateRoomButton');
-require('../skins/base/views/atoms/create_room/RoomNameTextbox');
-require('../skins/base/views/atoms/create_room/Presets');
-require('../skins/base/views/atoms/EditableText');
-require('../skins/base/views/molecules/MatrixToolbar');
-require('../skins/base/views/molecules/RoomTile');
-require('../skins/base/views/molecules/MessageTile');
-require('../skins/base/views/molecules/SenderProfile');
-require('../skins/base/views/molecules/UnknownMessageTile');
-require('../skins/base/views/molecules/MTextTile');
-require('../skins/base/views/molecules/MNoticeTile');
-require('../skins/base/views/molecules/MEmoteTile');
-require('../skins/base/views/molecules/MImageTile');
-require('../skins/base/views/molecules/MFileTile');
-require('../skins/base/views/molecules/MRoomMemberTile');
-require('../skins/base/views/molecules/RoomHeader');
-require('../skins/base/views/molecules/MessageComposer');
-require('../skins/base/views/molecules/ProgressBar');
-require('../skins/base/views/molecules/ServerConfig');
-require('../skins/base/views/organisms/MemberList');
-require('../skins/base/views/molecules/MemberTile');
-require('../skins/base/views/organisms/RoomList');
-require('../skins/base/views/organisms/RoomView');
-require('../skins/base/views/templates/Login');
-require('../skins/base/views/templates/Register');
-require('../skins/base/views/organisms/Notifier');
-require('../skins/base/views/organisms/CreateRoom');
-require('../skins/base/views/molecules/UserSelector');
-}
diff --git a/src/ContentMessages.js b/src/ContentMessages.js
index fdd29fd58a..eba3011917 100644
--- a/src/ContentMessages.js
+++ b/src/ContentMessages.js
@@ -53,10 +53,14 @@ function sendContentToRoom(file, roomId, matrixClient) {
         body: file.name,
         info: {
             size: file.size,
-            mimetype: file.type
         }
     };
 
+    // if we have a mime type for the file, add it to the message metadata
+    if (file.type) {
+        content.info.mimetype = file.type;
+    }
+
     var def = q.defer();
     if (file.type.indexOf('image/') == 0) {
         content.msgtype = 'm.image';
diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js
index 6b36e67e6b..62c49a5f2d 100644
--- a/src/MatrixClientPeg.js
+++ b/src/MatrixClientPeg.js
@@ -23,6 +23,16 @@ var matrixClient = null;
 
 var localStorage = window.localStorage;
 
+function deviceId() {
+    var id = Math.floor(Math.random()*16777215).toString(16);
+    id = "W" + "000000".substring(id.length) + id;
+    if (localStorage) {
+        id = localStorage.getItem("mx_device_id") || id;
+        localStorage.setItem("mx_device_id", id);
+    }
+    return id;
+}
+
 function createClient(hs_url, is_url, user_id, access_token) {
     var opts = {
         baseUrl: hs_url,
@@ -31,6 +41,11 @@ function createClient(hs_url, is_url, user_id, access_token) {
         userId: user_id
     };
 
+    if (localStorage) {
+        opts.sessionStore = new Matrix.WebStorageSessionStore(localStorage);
+        opts.deviceId = deviceId();
+    }
+
     matrixClient = Matrix.createClient(opts);
 }
 
@@ -44,23 +59,33 @@ if (localStorage) {
     }
 }
 
-module.exports = {
-    get: function() {
+class MatrixClient {
+    get() {
         return matrixClient;
-    },
+    }
 
-    replaceUsingUrls: function(hs_url, is_url) {
+    unset() {
+        matrixClient = null;
+    }
+
+    replaceUsingUrls(hs_url, is_url) {
         matrixClient = Matrix.createClient({
             baseUrl: hs_url,
             idBaseUrl: is_url
         });
-    },
+    }
 
-    replaceUsingAccessToken: function(hs_url, is_url, user_id, access_token) {
-        createClient(hs_url, is_url, user_id, access_token);
+    replaceUsingAccessToken(hs_url, is_url, user_id, access_token) {
         if (localStorage) {
             try {
                 localStorage.clear();
+            } catch (e) {
+                console.warn("Error using local storage");
+            }
+        }
+        createClient(hs_url, is_url, user_id, access_token);
+        if (localStorage) {
+            try {
                 localStorage.setItem("mx_hs_url", hs_url);
                 localStorage.setItem("mx_is_url", is_url);
                 localStorage.setItem("mx_user_id", user_id);
@@ -72,5 +97,9 @@ module.exports = {
             console.warn("No local storage available: can't persist session!");
         }
     }
-};
+}
 
+if (!global.mxMatrixClient) {
+    global.mxMatrixClient = new MatrixClient();
+}
+module.exports = global.mxMatrixClient;
diff --git a/src/MatrixTools.js b/src/MatrixTools.js
new file mode 100644
index 0000000000..5c6dca8b33
--- /dev/null
+++ b/src/MatrixTools.js
@@ -0,0 +1,36 @@
+/*
+Copyright 2015 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.
+*/
+
+module.exports = {
+    /**
+     * Given a room object, return the canonical alias for it
+     * if there is one. Otherwise return null;
+     */
+    getCanonicalAliasForRoom: function(room) {
+        var aliasEvents = room.currentState.getStateEvents(
+            "m.room.aliases"
+        );
+        // Canonical aliases aren't implemented yet, so just return the first
+        for (var j = 0; j < aliasEvents.length; j++) {
+            var aliases = aliasEvents[j].getContent().aliases;
+            if (aliases && aliases.length) {
+                return aliases[0];
+            }
+        }
+        return null;
+    }
+}
+
diff --git a/src/Modal.js b/src/Modal.js
new file mode 100644
index 0000000000..f34ef65d59
--- /dev/null
+++ b/src/Modal.js
@@ -0,0 +1,61 @@
+/*
+Copyright 2015 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+
+'use strict';
+
+var React = require('react');
+
+module.exports = {
+    DialogContainerId: "mx_Dialog_Container",
+
+    getOrCreateContainer: function() {
+        var container = document.getElementById(this.DialogContainerId);
+
+        if (!container) {
+            container = document.createElement("div");
+            container.id = this.DialogContainerId;
+            document.body.appendChild(container);
+        }
+
+        return container;
+    },
+
+    createDialog: function (Element, props) {
+        var self = this;
+
+        var closeDialog = function() {
+            React.unmountComponentAtNode(self.getOrCreateContainer());
+
+            if (props && props.onFinished) props.onFinished.apply(null, arguments);
+        };
+
+        // FIXME: If a dialog uses getDefaultProps it clobbers the onFinished
+        // property set here so you can't close the dialog from a button click!
+        var dialog = (
+            <div className="mx_Dialog_wrapper">
+                <div className="mx_Dialog">
+                    <Element {...props} onFinished={closeDialog}/>
+                </div>
+                <div className="mx_Dialog_background" onClick={closeDialog}></div>
+            </div>
+        );
+
+        React.render(dialog, this.getOrCreateContainer());
+
+        return {close: closeDialog};
+    },
+};
diff --git a/src/Modulator.js b/src/Modulator.js
new file mode 100644
index 0000000000..72fcc14d89
--- /dev/null
+++ b/src/Modulator.js
@@ -0,0 +1,111 @@
+/*
+Copyright 2015 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.
+*/
+
+/**
+ * The modulator stores 'modules': classes that provide
+ * functionality and are not React UI components.
+ * Modules go into named slots, eg. a conference calling
+ * module goes into the 'conference' slot. If two modules
+ * that use the same slot are loaded, this is considered
+ * to be an error.
+ *
+ * There are some module slots that the react SDK knows
+ * about natively: these have explicit getters.
+ *
+ * A module must define:
+ *  - 'slot' (string): The name of the slot it goes into
+ * and may define:
+ *  - 'start' (function): Called on module load
+ *  - 'stop' (function): Called on module unload
+ */
+class Modulator {
+    constructor() {
+        this.modules = {};
+    }
+
+    getModule(name) {
+        var m = this.getModuleOrNull(name);
+        if (m === null) {
+            throw new Error("No such module: "+name);
+        }
+        return m;
+    }
+
+    getModuleOrNull(name) {
+        if (this.modules == {}) {
+            throw new Error(
+                "Attempted to get a module before a skin has been loaded."+
+                "This is probably because a component has called "+
+                "getModule at the root level."
+            );
+        }
+        var module = this.modules[name];
+        if (module) {
+            return module;
+        }
+        return null;
+    }
+
+    hasModule(name) {
+        var m = this.getModuleOrNull(name);
+        return m !== null;
+    }
+
+    loadModule(moduleObject) {
+        if (!moduleObject.slot) {
+            throw new Error(
+                "Attempted to load something that is not a module "+
+                "(does not have a slot name)"
+            );
+        }
+        if (this.modules[moduleObject.slot] !== undefined) {
+            throw new Error(
+                "Cannot load module: slot '"+moduleObject.slot+"' is occupied!"
+            );
+        }
+        this.modules[moduleObject.slot] = moduleObject;
+    }
+
+    reset() {
+        var keys = Object.keys(this.modules);
+        for (var i = 0; i < keys.length; ++i) {
+            var k = keys[i];
+            var m = this.modules[k];
+
+            if (m.stop) m.stop();
+        }
+        this.modules = {};
+    }
+
+    // ***********
+    // known slots
+    // ***********
+
+    getConferenceHandler() {
+        return this.getModule('conference');
+    }
+
+    hasConferenceHandler() {
+        return this.hasModule('conference');
+    }
+}
+
+// Define one Modulator globally (see Skinner.js)
+if (global.mxModulator === undefined) {
+    global.mxModulator = new Modulator();
+}
+module.exports = global.mxModulator;
+
diff --git a/src/Presence.js b/src/Presence.js
new file mode 100644
index 0000000000..d77058abd8
--- /dev/null
+++ b/src/Presence.js
@@ -0,0 +1,107 @@
+/*
+Copyright 2015 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.
+*/
+
+var MatrixClientPeg = require("./MatrixClientPeg");
+
+ // Time in ms after that a user is considered as unavailable/away
+var UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins
+var PRESENCE_STATES = ["online", "offline", "unavailable"];
+
+// The current presence state
+var state, timer;
+
+module.exports = {
+
+    /**
+     * Start listening the user activity to evaluate his presence state.
+     * Any state change will be sent to the Home Server.
+     */
+    start: function() {
+        var self = this;
+        this.running = true;
+        if (undefined === state) {
+            // The user is online if they move the mouse or press a key
+            document.onmousemove = function() { self._resetTimer(); };
+            document.onkeypress = function() { self._resetTimer(); };
+            this._resetTimer();
+        }
+    },
+
+    /**
+     * Stop tracking user activity
+     */
+    stop: function() {
+        this.running = false;
+        if (timer) {
+            clearTimeout(timer);
+            timer = undefined;
+        }
+        state = undefined;
+    },
+
+    /**
+     * Get the current presence state.
+     * @returns {string} the presence state (see PRESENCE enum)
+     */
+    getState: function() {
+        return state;
+    },
+
+    /**
+     * Set the presence state.
+     * If the state has changed, the Home Server will be notified.
+     * @param {string} newState the new presence state (see PRESENCE enum)
+     */
+    setState: function(newState) {
+        if (newState === state) {
+            return;
+        }
+        if (PRESENCE_STATES.indexOf(newState) === -1) {
+            throw new Error("Bad presence state: " + newState);
+        }
+        if (!this.running) {
+            return;
+        }
+        state = newState;
+        MatrixClientPeg.get().setPresence(state).done(function() {
+            console.log("Presence: %s", newState);
+        }, function(err) {
+            console.error("Failed to set presence: %s", err);
+        });
+    },
+
+    /**
+     * Callback called when the user made no action on the page for UNAVAILABLE_TIME ms.
+     * @private
+     */
+    _onUnavailableTimerFire: function() {
+        this.setState("unavailable");
+    },
+
+    /**
+     * Callback called when the user made an action on the page
+     * @private
+     */
+    _resetTimer: function() {
+        var self = this;
+        this.setState("online");
+        // Re-arm the timer
+        clearTimeout(timer);
+        timer = setTimeout(function() {
+            self._onUnavailableTimerFire();
+        }, UNAVAILABLE_TIME_MS);
+    } 
+};
diff --git a/src/RoomListSorter.js b/src/RoomListSorter.js
index bc7a001670..730a0de18b 100644
--- a/src/RoomListSorter.js
+++ b/src/RoomListSorter.js
@@ -17,7 +17,12 @@ limitations under the License.
 'use strict';
 
 function tsOfNewestEvent(room) {
-    return room.timeline[room.timeline.length - 1].getTs();
+    if (room.timeline.length) {
+        return room.timeline[room.timeline.length - 1].getTs();
+    }
+    else {
+        return Number.MAX_SAFE_INTEGER;
+    }
 }
 
 function mostRecentActivityFirst(roomList) {
diff --git a/src/Skinner.js b/src/Skinner.js
new file mode 100644
index 0000000000..ae48d85633
--- /dev/null
+++ b/src/Skinner.js
@@ -0,0 +1,63 @@
+/*
+Copyright 2015 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.
+*/
+
+class Skinner {
+    constructor() {
+        this.components = null;
+    }
+
+    getComponent(name) {
+        if (this.components === null) {
+            throw new Error(
+                "Attempted to get a component before a skin has been loaded."+
+                "This is probably because either:"+
+                " a) Your app has not called sdk.loadSkin(), or"+
+                " b) A component has called getComponent at the root level"
+            );
+        }
+        var comp = this.components[name];
+        if (comp) {
+            return comp;
+        }
+        throw new Error("No such component: "+name);
+    }
+
+    load(skinObject) {
+        if (this.components !== null) {
+            throw new Error(
+                "Attempted to load a skin while a skin is already loaded"+
+                "If you want to change the active skin, call resetSkin first"
+            );
+        }
+        this.components = skinObject;
+    }
+
+    reset() {
+        this.components = null;
+    }
+}
+
+// We define one Skinner globally, because the intention is
+// very much that it is a singleton. Relying on there only being one
+// copy of the module can be dicey and not work as browserify's
+// behaviour with multiple copies of files etc. is erratic at best.
+// XXX: We can still end up with the same file twice in the resulting
+// JS bundle which is nonideal.
+if (global.mxSkinner === undefined) {
+    global.mxSkinner = new Skinner();
+}
+module.exports = global.mxSkinner;
+
diff --git a/src/SlashCommands.js b/src/SlashCommands.js
new file mode 100644
index 0000000000..08d68331f8
--- /dev/null
+++ b/src/SlashCommands.js
@@ -0,0 +1,312 @@
+/*
+Copyright 2015 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.
+*/
+
+var MatrixClientPeg = require("./MatrixClientPeg");
+var dis = require("./dispatcher");
+var encryption = require("./encryption");
+
+var reject = function(msg) {
+    return {
+        error: msg
+    };
+};
+
+var success = function(promise) {
+    return {
+        promise: promise
+    };
+};
+
+var commands = {
+    // Change your nickname
+    nick: function(room_id, args) {
+        if (args) {
+            return success(
+                MatrixClientPeg.get().setDisplayName(args)
+            );
+        }
+        return reject("Usage: /nick <display_name>");
+    },
+
+    encrypt: function(room_id, args) {
+        if (args == "on") {
+            var client = MatrixClientPeg.get();
+            var members = client.getRoom(room_id).currentState.members;
+            var user_ids = Object.keys(members);
+            return success(
+                encryption.enableEncryption(client, room_id, user_ids)
+            );
+        }
+        if (args == "off") {
+            var client = MatrixClientPeg.get();
+            return success(
+                encryption.disableEncryption(client, room_id)
+            );
+
+        }
+        return reject("Usage: encrypt <on/off>");
+    },
+
+    // Change the room topic
+    topic: function(room_id, args) {
+        if (args) {
+            return success(
+                MatrixClientPeg.get().setRoomTopic(room_id, args)
+            );
+        }
+        return reject("Usage: /topic <topic>");
+    },
+
+    // Invite a user
+    invite: function(room_id, args) {
+        if (args) {
+            var matches = args.match(/^(\S+)$/);
+            if (matches) {
+                return success(
+                    MatrixClientPeg.get().invite(room_id, matches[1])
+                );
+            }
+        }
+        return reject("Usage: /invite <userId>");
+    },
+
+    // Join a room
+    join: function(room_id, args) {
+        if (args) {
+            var matches = args.match(/^(\S+)$/);
+            if (matches) {
+                var room_alias = matches[1];
+                if (room_alias[0] !== '#') {
+                    return reject("Usage: /join #alias:domain");
+                }
+                if (!room_alias.match(/:/)) {
+                    var domain = MatrixClientPeg.get().credentials.userId.replace(/^.*:/, '');
+                    room_alias += ':' + domain;
+                }
+
+                // Try to find a room with this alias
+                var rooms = MatrixClientPeg.get().getRooms();
+                var roomId;
+                for (var i = 0; i < rooms.length; i++) {
+                    var aliasEvents = rooms[i].currentState.getStateEvents(
+                        "m.room.aliases"
+                    );
+                    for (var j = 0; j < aliasEvents.length; j++) {
+                        var aliases = aliasEvents[j].getContent().aliases || [];
+                        for (var k = 0; k < aliases.length; k++) {
+                            if (aliases[k] === room_alias) {
+                                roomId = rooms[i].roomId;
+                                break;
+                            }
+                        }
+                        if (roomId) { break; }
+                    }
+                    if (roomId) { break; }
+                }
+                if (roomId) { // we've already joined this room, view it.
+                    dis.dispatch({
+                        action: 'view_room',
+                        room_id: roomId
+                    });
+                    return success();
+                }
+                else {
+                    // attempt to join this alias.
+                    return success(
+                        MatrixClientPeg.get().joinRoom(room_alias).then(
+                        function(room) {
+                            dis.dispatch({
+                                action: 'view_room',
+                                room_id: room.roomId
+                            });
+                        })
+                    );
+                }
+            }
+        }
+        return reject("Usage: /join <room_alias>");
+    },
+
+    part: function(room_id, args) {
+        var targetRoomId;
+        if (args) {
+            var matches = args.match(/^(\S+)$/);
+            if (matches) {
+                var room_alias = matches[1];
+                if (room_alias[0] !== '#') {
+                    return reject("Usage: /part [#alias:domain]");
+                }
+                if (!room_alias.match(/:/)) {
+                    var domain = MatrixClientPeg.get().credentials.userId.replace(/^.*:/, '');
+                    room_alias += ':' + domain;
+                }
+
+                // Try to find a room with this alias
+                var rooms = MatrixClientPeg.get().getRooms();
+                for (var i = 0; i < rooms.length; i++) {
+                    var aliasEvents = rooms[i].currentState.getStateEvents(
+                        "m.room.aliases"
+                    );
+                    for (var j = 0; j < aliasEvents.length; j++) {
+                        var aliases = aliasEvents[j].getContent().aliases || [];
+                        for (var k = 0; k < aliases.length; k++) {
+                            if (aliases[k] === room_alias) {
+                                targetRoomId = rooms[i].roomId;
+                                break;
+                            }
+                        }
+                        if (targetRoomId) { break; }
+                    }
+                    if (targetRoomId) { break; }
+                }
+            }
+            if (!targetRoomId) {
+                return reject("Unrecognised room alias: " + room_alias);
+            }
+        }
+        if (!targetRoomId) targetRoomId = room_id;
+        return success(
+            MatrixClientPeg.get().leave(targetRoomId).then(
+            function() {
+                dis.dispatch({action: 'view_next_room'});
+            })
+        );
+    },
+
+    // Kick a user from the room with an optional reason
+    kick: function(room_id, args) {
+        if (args) {
+            var matches = args.match(/^(\S+?)( +(.*))?$/);
+            if (matches) {
+                return success(
+                    MatrixClientPeg.get().kick(room_id, matches[1], matches[3])
+                );
+            }
+        }
+        return reject("Usage: /kick <userId> [<reason>]");
+    },
+
+    // Ban a user from the room with an optional reason
+    ban: function(room_id, args) {
+        if (args) {
+            var matches = args.match(/^(\S+?)( +(.*))?$/);
+            if (matches) {
+                return success(
+                    MatrixClientPeg.get().ban(room_id, matches[1], matches[3])
+                );
+            }
+        }
+        return reject("Usage: /ban <userId> [<reason>]");
+    },
+
+    // Unban a user from the room
+    unban: function(room_id, args) {
+        if (args) {
+            var matches = args.match(/^(\S+)$/);
+            if (matches) {
+                // Reset the user membership to "leave" to unban him
+                return success(
+                    MatrixClientPeg.get().unban(room_id, matches[1])
+                );
+            }
+        }
+        return reject("Usage: /unban <userId>");
+    },
+
+    // Define the power level of a user
+    op: function(room_id, args) {
+        if (args) {
+            var matches = args.match(/^(\S+?)( +(\d+))?$/);
+            var powerLevel = 50; // default power level for op
+            if (matches) {
+                var user_id = matches[1];
+                if (matches.length === 4 && undefined !== matches[3]) {
+                    powerLevel = parseInt(matches[3]);
+                }
+                if (powerLevel !== NaN) {
+                    var room = MatrixClientPeg.get().getRoom(room_id);
+                    if (!room) {
+                        return reject("Bad room ID: " + room_id);
+                    }
+                    var powerLevelEvent = room.currentState.getStateEvents(
+                        "m.room.power_levels", ""
+                    );
+                    return success(
+                        MatrixClientPeg.get().setPowerLevel(
+                            room_id, user_id, powerLevel, powerLevelEvent
+                        )
+                    );
+                }
+            }
+        }
+        return reject("Usage: /op <userId> [<power level>]");
+    },
+
+    // Reset the power level of a user
+    deop: function(room_id, args) {
+        if (args) {
+            var matches = args.match(/^(\S+)$/);
+            if (matches) {
+                var room = MatrixClientPeg.get().getRoom(room_id);
+                if (!room) {
+                    return reject("Bad room ID: " + room_id);
+                }
+
+                var powerLevelEvent = room.currentState.getStateEvents(
+                    "m.room.power_levels", ""
+                );
+                return success(
+                    MatrixClientPeg.get().setPowerLevel(
+                        room_id, args, undefined, powerLevelEvent
+                    )
+                );
+            }
+        }
+        return reject("Usage: /deop <userId>");
+    }
+};
+
+// helpful aliases
+commands.j = commands.join;
+
+module.exports = {
+    /**
+     * Process the given text for /commands and perform them.
+     * @param {string} roomId The room in which the command was performed.
+     * @param {string} input The raw text input by the user.
+     * @return {Object|null} An object with the property 'error' if there was an error
+     * processing the command, or 'promise' if a request was sent out.
+     * Returns null if the input didn't match a command.
+     */
+    processInput: function(roomId, input) {
+        // trim any trailing whitespace, as it can confuse the parser for 
+        // IRC-style commands
+        input = input.replace(/\s+$/, "");
+        if (input[0] === "/" && input[1] !== "/") {
+            var bits = input.match(/^(\S+?)( +(.*))?$/);
+            var cmd = bits[1].substring(1).toLowerCase();
+            var args = bits[3];
+            if (cmd === "me") return null;
+            if (commands[cmd]) {
+                return commands[cmd](roomId, args);
+            }
+            else {
+                return reject("Unrecognised command: " + input);
+            }
+        }
+        return null; // not a command
+    }
+};
diff --git a/src/TextForEvent.js b/src/TextForEvent.js
new file mode 100644
index 0000000000..3d6ba2cf64
--- /dev/null
+++ b/src/TextForEvent.js
@@ -0,0 +1,106 @@
+
+function textForMemberEvent(ev) {
+    // XXX: SYJS-16
+    var senderName = ev.sender ? ev.sender.name : ev.getSender();
+    var targetName = ev.target ? ev.target.name : ev.getStateKey();
+    var reason = ev.getContent().reason ? (
+        " Reason: " + ev.getContent().reason
+    ) : "";
+    switch (ev.getContent().membership) {
+        case 'invite':
+            return senderName + " invited " + targetName + ".";
+        case 'ban':
+            return senderName + " banned " + targetName + "." + reason;
+        case 'join':
+            if (ev.getPrevContent() && ev.getPrevContent().membership == 'join') {
+                if (ev.getPrevContent().displayname && ev.getContent().displayname && ev.getPrevContent().displayname != ev.getContent().displayname) {
+                    return ev.getSender() + " changed their display name from " +
+                        ev.getPrevContent().displayname + " to " +
+                        ev.getContent().displayname;
+                } else if (!ev.getPrevContent().displayname && ev.getContent().displayname) {
+                    return ev.getSender() + " set their display name to " + ev.getContent().displayname;
+                } else if (ev.getPrevContent().displayname && !ev.getContent().displayname) {
+                    return ev.getSender() + " removed their display name";
+                } else if (ev.getPrevContent().avatar_url && !ev.getContent().avatar_url) {
+                    return ev.getSender() + " removed their profile picture";
+                } else if (ev.getPrevContent().avatar_url && ev.getContent().avatar_url && ev.getPrevContent().avatar_url != ev.getContent().avatar_url) {
+                    return ev.getSender() + " changed their profile picture";
+                } else if (!ev.getPrevContent().avatar_url && ev.getContent().avatar_url) {
+                    return ev.getSender() + " set a profile picture";
+                }
+            } else {
+                if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key);
+                return targetName + " joined the room.";
+            }
+            return '';
+        case 'leave':
+            if (ev.getSender() === ev.getStateKey()) {
+                return targetName + " left the room.";
+            }
+            else if (ev.getPrevContent().membership === "ban") {
+                return senderName + " unbanned " + targetName + ".";
+            }
+            else if (ev.getPrevContent().membership === "join") {
+                return senderName + " kicked " + targetName + "." + reason;
+            }
+            else {
+                return targetName + " left the room.";
+            }
+    }
+};
+
+function textForTopicEvent(ev) {
+    var senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
+
+    return senderDisplayName + ' changed the topic to, "' + ev.getContent().topic + '"';
+};
+
+function textForMessageEvent(ev) {
+    var senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
+
+    var message = senderDisplayName + ': ' + ev.getContent().body;
+    if (ev.getContent().msgtype === "m.emote") {
+        message = "* " + senderDisplayName + " " + message;
+    } else if (ev.getContent().msgtype === "m.image") {
+        message = senderDisplayName + " sent an image.";
+    }
+    return message;
+};
+
+function textForCallAnswerEvent(event) {
+    var senderName = event.sender ? event.sender.name : "Someone";
+    return senderName + " answered the call.";
+};
+
+function textForCallHangupEvent(event) {
+    var senderName = event.sender ? event.sender.name : "Someone";
+    return senderName + " ended the call.";
+};
+
+function textForCallInviteEvent(event) {
+    var senderName = event.sender ? event.sender.name : "Someone";
+    // FIXME: Find a better way to determine this from the event?
+    var type = "voice";
+    if (event.getContent().offer && event.getContent().offer.sdp &&
+            event.getContent().offer.sdp.indexOf('m=video') !== -1) {
+        type = "video";
+    }
+    return senderName + " placed a " + type + " call.";
+};
+
+var handlers = {
+    'm.room.message': textForMessageEvent,
+    'm.room.topic': textForTopicEvent,
+    'm.room.member': textForMemberEvent,
+    'm.call.invite': textForCallInviteEvent,
+    'm.call.answer': textForCallAnswerEvent,
+    'm.call.hangup': textForCallHangupEvent,
+};
+
+module.exports = {
+    textForEvent: function(ev) {
+        var hdlr = handlers[ev.getType()];
+        if (!hdlr) return "";
+        return hdlr(ev);
+    }
+}
diff --git a/src/WhoIsTyping.js b/src/WhoIsTyping.js
new file mode 100644
index 0000000000..4fb5399027
--- /dev/null
+++ b/src/WhoIsTyping.js
@@ -0,0 +1,49 @@
+var MatrixClientPeg = require("./MatrixClientPeg");
+
+module.exports = {
+    usersTypingApartFromMe: function(room) {
+        return this.usersTyping(
+            room, [MatrixClientPeg.get().credentials.userId]
+        );
+    },
+
+    /**
+     * Given a Room object and, optionally, a list of userID strings
+     * to exclude, return a list of user objects who are typing.
+     */
+    usersTyping: function(room, exclude) {
+        var whoIsTyping = [];
+
+        if (exclude === undefined) {
+            exclude = [];
+        }
+
+        var memberKeys = Object.keys(room.currentState.members);
+        for (var i = 0; i < memberKeys.length; ++i) {
+            var userId = memberKeys[i];
+
+            if (room.currentState.members[userId].typing) {
+                if (exclude.indexOf(userId) == -1) {
+                    whoIsTyping.push(room.currentState.members[userId]);
+                }
+            }
+        }
+
+        return whoIsTyping;
+    },
+
+    whoIsTypingString: function(room) {
+        var whoIsTyping = this.usersTypingApartFromMe(room);
+        if (whoIsTyping.length == 0) {
+            return null;
+        } else if (whoIsTyping.length == 1) {
+            return whoIsTyping[0].name + ' is typing';
+        } else {
+            var names = whoIsTyping.map(function(m) {
+                return m.name;
+            });
+            var lastPerson = names.shift();
+            return names.join(', ') + ' and ' + lastPerson + ' are typing';
+        }
+    }
+}
diff --git a/src/controllers/atoms/EditableText.js b/src/controllers/atoms/EditableText.js
index ac46973613..5ea4ce8c4a 100644
--- a/src/controllers/atoms/EditableText.js
+++ b/src/controllers/atoms/EditableText.js
@@ -21,7 +21,9 @@ var React = require('react');
 module.exports = {
     propTypes: {
         onValueChanged: React.PropTypes.func,
-        initalValue: React.PropTypes.string,
+        initialValue: React.PropTypes.string,
+        label: React.PropTypes.string,
+        placeHolder: React.PropTypes.string,
     },
 
     Phases: {
@@ -32,37 +34,55 @@ module.exports = {
     getDefaultProps: function() {
         return {
             onValueChanged: function() {},
-            initalValue: '',
+            initialValue: '',
+            label: 'Click to set',
+            placeholder: '',
         };
     },
 
     getInitialState: function() {
         return {
-            value: this.props.initalValue,
+            value: this.props.initialValue,
             phase: this.Phases.Display,
         }
     },
 
+    componentWillReceiveProps: function(nextProps) {
+        this.setState({
+            value: nextProps.initialValue
+        });
+    },
+
     getValue: function() {
         return this.state.value;
     },
 
-    setValue: function(val) {
+    setValue: function(val, shouldSubmit, suppressListener) {
+        var self = this;
         this.setState({
             value: val,
             phase: this.Phases.Display,
+        }, function() {
+            if (!suppressListener) {
+                self.onValueChanged(shouldSubmit);
+            }
         });
+    },
 
-        this.onValueChanged();
+    edit: function() {
+        this.setState({
+            phase: this.Phases.Edit,
+        });
     },
 
     cancelEdit: function() {
         this.setState({
             phase: this.Phases.Display,
         });
+        this.onValueChanged(false);
     },
 
-    onValueChanged: function() {
-        this.props.onValueChanged(this.state.value);
+    onValueChanged: function(shouldSubmit) {
+        this.props.onValueChanged(this.state.value, shouldSubmit);
     },
 };
diff --git a/src/controllers/atoms/EnableNotificationsButton.js b/src/controllers/atoms/EnableNotificationsButton.js
index c600f33013..3c399484e8 100644
--- a/src/controllers/atoms/EnableNotificationsButton.js
+++ b/src/controllers/atoms/EnableNotificationsButton.js
@@ -15,53 +15,44 @@ limitations under the License.
 */
 
 'use strict';
+var sdk = require('../../index');
+var dis = require("../../dispatcher");
 
 module.exports = {
-    notificationsAvailable: function() {
-        return !!global.Notification;
+
+    componentDidMount: function() {
+        this.dispatcherRef = dis.register(this.onAction);
     },
 
-    havePermission: function() {
-        return global.Notification.permission == 'granted';
+    componentWillUnmount: function() {
+        dis.unregister(this.dispatcherRef);
+    },
+
+    onAction: function(payload) {
+        if (payload.action !== "notifier_enabled") {
+            return;
+        }
+        this.forceUpdate();
     },
 
     enabled: function() {
-        if (!this.havePermission()) return false;
-
-        if (!global.localStorage) return true;
-
-        var enabled = global.localStorage.getItem('notifications_enabled');
-        if (enabled === null) return true;
-        return enabled === 'true';
-    },
-
-    disable: function() {
-        if (!global.localStorage) return;
-        global.localStorage.setItem('notifications_enabled', 'false');
-        this.forceUpdate();
-    },
-
-    enable: function() {
-        if (!this.havePermission()) {
-            var that = this;
-            global.Notification.requestPermission(function() {
-                that.forceUpdate();
-            });
-        }
-
-        if (!global.localStorage) return;
-        global.localStorage.setItem('notifications_enabled', 'true');
-        this.forceUpdate();
+        var Notifier = sdk.getComponent('organisms.Notifier');
+        return Notifier.isEnabled();
     },
 
     onClick: function() {
-        if (!this.notificationsAvailable()) {
+        var Notifier = sdk.getComponent('organisms.Notifier');
+        var self = this;
+        if (!Notifier.supportsDesktopNotifications()) {
             return;
         }
-        if (!this.enabled()) {
-            this.enable();
+        if (!Notifier.isEnabled()) {
+            Notifier.setEnabled(true, function() {
+                self.forceUpdate();
+            });
         } else {
-            this.disable();
+            Notifier.setEnabled(false);
         }
+        this.forceUpdate();
     },
 };
diff --git a/src/controllers/atoms/MemberAvatar.js b/src/controllers/atoms/MemberAvatar.js
new file mode 100644
index 0000000000..5d93f99947
--- /dev/null
+++ b/src/controllers/atoms/MemberAvatar.js
@@ -0,0 +1,75 @@
+/*
+Copyright 2015 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+var React = require('react');
+var MatrixClientPeg = require('../../MatrixClientPeg');
+
+module.exports = {
+    propTypes: {
+        member: React.PropTypes.object.isRequired,
+        width: React.PropTypes.number,
+        height: React.PropTypes.number,
+        resizeMethod: React.PropTypes.string,
+    },
+
+    getDefaultProps: function() {
+        return {
+            width: 40,
+            height: 40,
+            resizeMethod: 'crop'
+        }
+    },
+
+    defaultAvatarUrl: function(member, width, height, resizeMethod) {
+        if (this.skinnedDefaultAvatarUrl) {
+            return this.skinnedDefaultAvatarUrl(member, width, height, resizeMethod);
+        }
+        return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAIAAAADnC86AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAADRJREFUeNrszQENADAIACB9QjNbxSKP4eagAFnTseHFErFYLBaLxWKxWCwWi8Vi8cX4CzAABSwCRWJw31gAAAAASUVORK5CYII=";
+    },
+
+    onError: function(ev) {
+        // don't tightloop if the browser can't load a data url
+        if (ev.target.src == this.defaultAvatarUrl(this.props.member)) {
+            return;
+        }
+        this.setState({
+            imageUrl: this.defaultAvatarUrl(this.props.member)
+        });
+    },
+
+    getInitialState: function() {
+        var url = MatrixClientPeg.get().getAvatarUrlForMember(
+            this.props.member,
+            this.props.width,
+            this.props.height,
+            this.props.resizeMethod,
+            false
+        );
+        if (!url) {
+            url = this.defaultAvatarUrl(
+                this.props.member,
+                this.props.width,
+                this.props.height,
+                this.props.resizeMethod
+            );
+        }
+        return {
+            imageUrl: url
+        };
+    }
+};
diff --git a/src/controllers/atoms/RoomAvatar.js b/src/controllers/atoms/RoomAvatar.js
new file mode 100644
index 0000000000..481483949a
--- /dev/null
+++ b/src/controllers/atoms/RoomAvatar.js
@@ -0,0 +1,61 @@
+/*
+Copyright 2015 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+var MatrixClientPeg = require('../../MatrixClientPeg');
+
+module.exports = {
+    getDefaultProps: function() {
+        return {
+            width: 40,
+            height: 40,
+            resizeMethod: 'crop'
+        }
+    },
+
+    avatarUrlForRoom: function(room) {
+        var url = MatrixClientPeg.get().getAvatarUrlForRoom(
+            room,
+            this.props.width, this.props.height, this.props.resizeMethod,
+            false
+        );
+        if (url === null) {
+            url = this.defaultAvatarUrl(room);
+        }
+        return url;
+    },
+
+    defaultAvatarUrl: function(member) {
+        return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAIAAAADnC86AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAADRJREFUeNrszQENADAIACB9QjNbxSKP4eagAFnTseHFErFYLBaLxWKxWCwWi8Vi8cX4CzAABSwCRWJw31gAAAAASUVORK5CYII=";
+    },
+
+    onError: function(ev) {
+        // don't tightloop if the browser can't load a data url
+        if (ev.target.src == this.defaultAvatarUrl(this.props.room)) {
+            return;
+        }
+        this.setState({
+            imageUrl: this.defaultAvatarUrl(this.props.room)
+        });
+    },
+
+    getInitialState: function() {
+        return {
+            imageUrl: this.avatarUrlForRoom(this.props.room)
+        };
+    }
+};
diff --git a/src/controllers/atoms/create_room/Presets.js b/src/controllers/atoms/create_room/Presets.js
index 5ff7327e5a..bcc2f51481 100644
--- a/src/controllers/atoms/create_room/Presets.js
+++ b/src/controllers/atoms/create_room/Presets.js
@@ -18,24 +18,23 @@ limitations under the License.
 
 var React = require('react');
 
+var Presets = {
+    PrivateChat: "private_chat",
+    PublicChat: "public_chat",
+    Custom: "custom",
+};
+
 module.exports = {
     propTypes: {
-        default_preset: React.PropTypes.string
+        onChange: React.PropTypes.func,
+        preset: React.PropTypes.string
     },
 
+    Presets: Presets,
+
     getDefaultProps: function() {
         return {
-            default_preset: 'private_chat',
+            onChange: function() {},
         };
     },
-
-    getInitialState: function() {
-        return {
-            preset: this.props.default_preset,
-        }
-    },
-
-    getPreset: function() {
-        return this.state.preset;
-    },
 };
diff --git a/src/controllers/atoms/create_room/RoomAlias.js b/src/controllers/atoms/create_room/RoomAlias.js
new file mode 100644
index 0000000000..b1176a2ab5
--- /dev/null
+++ b/src/controllers/atoms/create_room/RoomAlias.js
@@ -0,0 +1,47 @@
+/*
+Copyright 2015 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.
+*/
+
+var React = require('react');
+
+module.exports = {
+    propTypes: {
+        // Specifying a homeserver will make magical things happen when you,
+        // e.g. start typing in the room alias box.
+        homeserver: React.PropTypes.string,
+        alias: React.PropTypes.string,
+        onChange: React.PropTypes.func,
+    },
+
+    getDefaultProps: function() {
+        return {
+            onChange: function() {},
+            alias: '',
+        };
+    },
+
+    getAliasLocalpart: function() {
+        var room_alias = this.props.alias;
+
+        if (room_alias && this.props.homeserver) {
+            var suffix = ":" + this.props.homeserver;
+            if (room_alias.startsWith("#") && room_alias.endsWith(suffix)) {
+                room_alias = room_alias.slice(1, -suffix.length);
+            }
+        }
+
+        return room_alias;
+    },
+};
diff --git a/skins/base/css/molecules/MImageTile.css b/src/controllers/atoms/voip/VideoFeed.js
similarity index 96%
rename from skins/base/css/molecules/MImageTile.css
rename to src/controllers/atoms/voip/VideoFeed.js
index 775ebca925..3d34134daa 100644
--- a/skins/base/css/molecules/MImageTile.css
+++ b/src/controllers/atoms/voip/VideoFeed.js
@@ -14,6 +14,6 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_MImageTile {
-}
+module.exports = {
+};
 
diff --git a/src/controllers/molecules/ChangeAvatar.js b/src/controllers/molecules/ChangeAvatar.js
new file mode 100644
index 0000000000..0df93b02fd
--- /dev/null
+++ b/src/controllers/molecules/ChangeAvatar.js
@@ -0,0 +1,67 @@
+/*
+Copyright 2015 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.
+*/
+
+var React = require('react');
+var MatrixClientPeg = require("../../MatrixClientPeg");
+
+module.exports = {
+    propTypes: {
+        onFinished: React.PropTypes.func,
+        initialAvatarUrl: React.PropTypes.string.isRequired,
+    },
+
+    Phases: {
+        Display: "display",
+        Uploading: "uploading",
+        Error: "error",
+    },
+
+    getDefaultProps: function() {
+        return {
+            onFinished: function() {},
+        };
+    },
+
+    getInitialState: function() {
+        return {
+            avatarUrl: this.props.initialAvatarUrl,
+            phase: this.Phases.Display,
+        }
+    },
+
+    setAvatarFromFile: function(file) {
+        var newUrl = null;
+
+        this.setState({
+            phase: this.Phases.Uploading
+        });
+        var self = this;
+        MatrixClientPeg.get().uploadContent(file).then(function(url) {
+            newUrl = url;
+            return MatrixClientPeg.get().setAvatarUrl(url);
+        }).done(function() {
+            self.setState({
+                phase: self.Phases.Display,
+                avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(newUrl)
+            });
+        }, function(error) {
+            self.setState({
+                phase: this.Phases.Error
+            });
+            self.onError(error);
+        });
+    },
+}
diff --git a/src/controllers/molecules/ChangePassword.js b/src/controllers/molecules/ChangePassword.js
new file mode 100644
index 0000000000..637e133a79
--- /dev/null
+++ b/src/controllers/molecules/ChangePassword.js
@@ -0,0 +1,76 @@
+/*
+Copyright 2015 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+var React = require('react');
+var MatrixClientPeg = require("../../MatrixClientPeg");
+
+module.exports = {
+    propTypes: {
+        onFinished: React.PropTypes.func,
+    },
+
+    Phases: {
+        Edit: "edit",
+        Uploading: "uploading",
+        Error: "error",
+        Success: "Success"
+    },
+
+    getDefaultProps: function() {
+        return {
+            onFinished: function() {},
+        };
+    },
+
+    getInitialState: function() {
+        return {
+            phase: this.Phases.Edit,
+            errorString: ''
+        }
+    },
+
+    changePassword: function(old_password, new_password) {
+        var cli = MatrixClientPeg.get();
+
+        var authDict = {
+            type: 'm.login.password',
+            user: cli.credentials.userId,
+            password: old_password
+        };
+
+        this.setState({
+            phase: this.Phases.Uploading,
+            errorString: '',
+        })
+
+        var d = cli.setPassword(authDict, new_password);
+
+        var self = this;
+        d.then(function() {
+            self.setState({
+                phase: self.Phases.Success,
+                errorString: '',
+            })
+        }, function(err) {
+            self.setState({
+                phase: self.Phases.Error,
+                errorString: err.toString()
+            })
+        });
+    },
+}
diff --git a/skins/base/css/organisms/RoomList.css b/src/controllers/molecules/EventAsTextTile.js
similarity index 96%
rename from skins/base/css/organisms/RoomList.css
rename to src/controllers/molecules/EventAsTextTile.js
index e2dec3c9fd..3d34134daa 100644
--- a/skins/base/css/organisms/RoomList.css
+++ b/src/controllers/molecules/EventAsTextTile.js
@@ -14,5 +14,6 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_RoomList {
-}
+module.exports = {
+};
+
diff --git a/src/controllers/molecules/MEmoteTile.js b/src/controllers/molecules/MEmoteTile.js
index 8aa688b21e..1fb117ceef 100644
--- a/src/controllers/molecules/MEmoteTile.js
+++ b/src/controllers/molecules/MEmoteTile.js
@@ -16,6 +16,15 @@ limitations under the License.
 
 'use strict';
 
+var linkify = require('linkifyjs');
+var linkifyElement = require('linkifyjs/element');
+var linkifyMatrix = require('../../linkify-matrix');
+
+linkifyMatrix(linkify);
+
 module.exports = {
+    componentDidMount: function() {
+        linkifyElement(this.refs.content.getDOMNode(), linkifyMatrix.options);
+    }
 };
 
diff --git a/src/controllers/molecules/MemberInfo.js b/src/controllers/molecules/MemberInfo.js
new file mode 100644
index 0000000000..24e4afe5fd
--- /dev/null
+++ b/src/controllers/molecules/MemberInfo.js
@@ -0,0 +1,317 @@
+/*
+Copyright 2015 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.
+*/
+
+/*
+ * State vars:
+ * 'can': {
+ *   kick: boolean,
+ *   ban: boolean,
+ *   mute: boolean,
+ *   modifyLevel: boolean
+ * },
+ * 'muted': boolean,
+ * 'isTargetMod': boolean
+ */
+
+var MatrixClientPeg = require("../../MatrixClientPeg");
+var dis = require("../../dispatcher");
+var Modal = require("../../Modal");
+var sdk = require('../../index');
+var Loader = require("react-loader");
+
+module.exports = {
+    componentDidMount: function() {
+        // work out the current state
+        if (this.props.member) {
+            var memberState = this._calculateOpsPermissions();
+            this.setState(memberState);
+        }
+    },
+
+    onKick: function() {
+        var ErrorDialog = sdk.getComponent("organisms.ErrorDialog");
+        var roomId = this.props.member.roomId;
+        var target = this.props.member.userId;
+        MatrixClientPeg.get().kick(roomId, target).done(function() {
+            // NO-OP; rely on the m.room.member event coming down else we could
+            // get out of sync if we force setState here!
+            console.log("Kick success");
+        }, function(err) {
+            Modal.createDialog(ErrorDialog, {
+                title: "Kick error",
+                description: err.message
+            });
+        });
+        this.props.onFinished();
+    },
+
+    onBan: function() {
+        var ErrorDialog = sdk.getComponent("organisms.ErrorDialog");
+        var roomId = this.props.member.roomId;
+        var target = this.props.member.userId;
+        MatrixClientPeg.get().ban(roomId, target).done(function() {
+            // NO-OP; rely on the m.room.member event coming down else we could
+            // get out of sync if we force setState here!
+            console.log("Ban success");
+        }, function(err) {
+            Modal.createDialog(ErrorDialog, {
+                title: "Ban error",
+                description: err.message
+            });
+        });
+        this.props.onFinished();
+    },
+
+    onMuteToggle: function() {
+        var ErrorDialog = sdk.getComponent("organisms.ErrorDialog");
+        var roomId = this.props.member.roomId;
+        var target = this.props.member.userId;
+        var room = MatrixClientPeg.get().getRoom(roomId);
+        if (!room) {
+            this.props.onFinished();
+            return;
+        }
+        var powerLevelEvent = room.currentState.getStateEvents(
+            "m.room.power_levels", ""
+        );
+        if (!powerLevelEvent) {
+            this.props.onFinished();
+            return;
+        }
+        var isMuted = this.state.muted;
+        var powerLevels = powerLevelEvent.getContent();
+        var levelToSend = (
+            (powerLevels.events ? powerLevels.events["m.room.message"] : null) ||
+            powerLevels.events_default
+        );
+        var level;
+        if (isMuted) { // unmute
+            level = levelToSend;
+        }
+        else { // mute
+            level = levelToSend - 1;
+        }
+
+        MatrixClientPeg.get().setPowerLevel(roomId, target, level, powerLevelEvent).done(
+        function() {
+            // NO-OP; rely on the m.room.member event coming down else we could
+            // get out of sync if we force setState here!
+            console.log("Mute toggle success");
+        }, function(err) {
+            Modal.createDialog(ErrorDialog, {
+                title: "Mute error",
+                description: err.message
+            });
+        });
+        this.props.onFinished();        
+    },
+
+    onModToggle: function() {
+        var ErrorDialog = sdk.getComponent("organisms.ErrorDialog");
+        var roomId = this.props.member.roomId;
+        var target = this.props.member.userId;
+        var room = MatrixClientPeg.get().getRoom(roomId);
+        if (!room) {
+            this.props.onFinished();
+            return;
+        }
+        var powerLevelEvent = room.currentState.getStateEvents(
+            "m.room.power_levels", ""
+        );
+        if (!powerLevelEvent) {
+            this.props.onFinished();
+            return;
+        }
+        var me = room.getMember(MatrixClientPeg.get().credentials.userId);
+        if (!me) {
+            this.props.onFinished();
+            return;
+        }
+        var defaultLevel = powerLevelEvent.getContent().users_default;
+        var modLevel = me.powerLevel - 1;
+        // toggle the level
+        var newLevel = this.state.isTargetMod ? defaultLevel : modLevel;
+        MatrixClientPeg.get().setPowerLevel(roomId, target, newLevel, powerLevelEvent).done(
+        function() {
+            // NO-OP; rely on the m.room.member event coming down else we could
+            // get out of sync if we force setState here!
+            console.log("Mod toggle success");
+        }, function(err) {
+            Modal.createDialog(ErrorDialog, {
+                title: "Mod error",
+                description: err.message
+            });
+        });
+        this.props.onFinished();        
+    },
+
+    onChatClick: function() {
+        // check if there are any existing rooms with just us and them (1:1)
+        // If so, just view that room. If not, create a private room with them.
+        var rooms = MatrixClientPeg.get().getRooms();
+        var userIds = [
+            this.props.member.userId,
+            MatrixClientPeg.get().credentials.userId
+        ];
+        var existingRoomId = null;
+        for (var i = 0; i < rooms.length; i++) {
+            var members = rooms[i].getJoinedMembers();
+            if (members.length === 2) {
+                var hasTargetUsers = true;
+                for (var j = 0; j < members.length; j++) {
+                    if (userIds.indexOf(members[j].userId) === -1) {
+                        hasTargetUsers = false;
+                        break;
+                    }
+                }
+                if (hasTargetUsers) {
+                    existingRoomId = rooms[i].roomId;
+                    break;
+                }
+            }
+        }
+
+        if (existingRoomId) {
+            dis.dispatch({
+                action: 'view_room',
+                room_id: existingRoomId
+            });
+        }
+        else {
+            MatrixClientPeg.get().createRoom({
+                invite: [this.props.member.userId],
+                preset: "private_chat"
+            }).done(function(res) {
+                dis.dispatch({
+                    action: 'view_room',
+                    room_id: res.room_id
+                });
+            }, function(err) {
+                console.error(
+                    "Failed to create room: %s", JSON.stringify(err)
+                );
+            });
+        }
+        this.props.onFinished();                
+    },
+
+    // FIXME: this is horribly duplicated with MemberTile's onLeaveClick.
+    // Not sure what the right solution to this is.
+    onLeaveClick: function() {
+        var ErrorDialog = sdk.getComponent("organisms.ErrorDialog");
+        var QuestionDialog = sdk.getComponent("organisms.QuestionDialog");
+
+        var roomId = this.props.member.roomId;
+        Modal.createDialog(QuestionDialog, {
+            title: "Leave room",
+            description: "Are you sure you want to leave the room?",
+            onFinished: function(should_leave) {
+                if (should_leave) {
+                    var d = MatrixClientPeg.get().leave(roomId);
+
+                    var modal = Modal.createDialog(Loader);
+
+                    d.then(function() {
+                        modal.close();
+                        dis.dispatch({action: 'view_next_room'});
+                    }, function(err) {
+                        modal.close();
+                        Modal.createDialog(ErrorDialog, {
+                            title: "Failed to leave room",
+                            description: err.toString()
+                        });
+                    });
+                }
+            }
+        });
+        this.props.onFinished();        
+    },
+
+    getInitialState: function() {
+        return {
+            can: {
+                kick: false,
+                ban: false,
+                mute: false,
+                modifyLevel: false
+            },
+            muted: false,
+            isTargetMod: false
+        }
+    },
+
+    _calculateOpsPermissions: function() {
+        var defaultPerms = {
+            can: {},
+            muted: false,
+            modifyLevel: false
+        };
+        var room = MatrixClientPeg.get().getRoom(this.props.member.roomId);
+        if (!room) {
+            return defaultPerms;
+        }
+        var powerLevels = room.currentState.getStateEvents(
+            "m.room.power_levels", ""
+        );
+        if (!powerLevels) {
+            return defaultPerms;
+        }
+        var me = room.getMember(MatrixClientPeg.get().credentials.userId);
+        var them = this.props.member;
+        return {
+            can: this._calculateCanPermissions(
+                me, them, powerLevels.getContent()
+            ),
+            muted: this._isMuted(them, powerLevels.getContent()),
+            isTargetMod: them.powerLevel > powerLevels.getContent().users_default
+        };
+    },
+
+    _calculateCanPermissions: function(me, them, powerLevels) {
+        var can = {
+            kick: false,
+            ban: false,
+            mute: false,
+            modifyLevel: false
+        };
+        var canAffectUser = them.powerLevel < me.powerLevel;
+        if (!canAffectUser) {
+            //console.log("Cannot affect user: %s >= %s", them.powerLevel, me.powerLevel);
+            return can;
+        }
+        var editPowerLevel = (
+            (powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) ||
+            powerLevels.state_default
+        );
+        can.kick = me.powerLevel >= powerLevels.kick;
+        can.ban = me.powerLevel >= powerLevels.ban;
+        can.mute = me.powerLevel >= editPowerLevel;
+        can.modifyLevel = me.powerLevel > them.powerLevel;
+        return can;
+    },
+
+    _isMuted: function(member, powerLevelContent) {
+        if (!powerLevelContent || !member) {
+            return false;
+        }
+        var levelToSend = (
+            (powerLevelContent.events ? powerLevelContent.events["m.room.message"] : null) ||
+            powerLevelContent.events_default
+        );
+        return member.powerLevel < levelToSend;
+    }
+};
+
diff --git a/src/controllers/molecules/MemberTile.js b/src/controllers/molecules/MemberTile.js
index 811d2a78b1..53cae43ccd 100644
--- a/src/controllers/molecules/MemberTile.js
+++ b/src/controllers/molecules/MemberTile.js
@@ -17,14 +17,42 @@ limitations under the License.
 'use strict';
 
 var dis = require("../../dispatcher");
+var Modal = require("../../Modal");
+var sdk = require('../../index.js');
+var Loader = require("react-loader");
 
 var MatrixClientPeg = require("../../MatrixClientPeg");
 
 module.exports = {
-    onClick: function() {
-        dis.dispatch({
-            action: 'view_user',
-            user_id: this.props.member.userId
-        });
+    getInitialState: function() {
+        return {};
     },
+
+    onLeaveClick: function() {
+        var QuestionDialog = sdk.getComponent("organisms.QuestionDialog");
+
+        var roomId = this.props.member.roomId;
+        Modal.createDialog(QuestionDialog, {
+            title: "Leave room",
+            description: "Are you sure you want to leave the room?",
+            onFinished: function(should_leave) {
+                if (should_leave) {
+                    var d = MatrixClientPeg.get().leave(roomId);
+
+                    var modal = Modal.createDialog(Loader);
+
+                    d.then(function() {
+                        modal.close();
+                        dis.dispatch({action: 'view_next_room'});
+                    }, function(err) {
+                        modal.close();
+                        Modal.createDialog(ErrorDialog, {
+                            title: "Failed to leave room",
+                            description: err.toString()
+                        });
+                    });
+                }
+            }
+        });
+    }
 };
diff --git a/src/controllers/molecules/MessageComposer.js b/src/controllers/molecules/MessageComposer.js
index f55546ae87..c2b67c7898 100644
--- a/src/controllers/molecules/MessageComposer.js
+++ b/src/controllers/molecules/MessageComposer.js
@@ -14,19 +14,130 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-'use strict';
-
 var MatrixClientPeg = require("../../MatrixClientPeg");
+var SlashCommands = require("../../SlashCommands");
+var Modal = require("../../Modal");
+var sdk = require('../../index');
 
 var dis = require("../../dispatcher");
+var KeyCode = {
+    ENTER: 13,
+    TAB: 9,
+    SHIFT: 16,
+    UP: 38,
+    DOWN: 40
+};
+
+var TYPING_USER_TIMEOUT = 10000;
+var TYPING_SERVER_TIMEOUT = 30000;
 
 module.exports = {
+    componentWillMount: function() {
+        this.tabStruct = {
+            completing: false,
+            original: null,
+            index: 0
+        };
+        this.sentHistory = {
+            // The list of typed messages. Index 0 is more recent
+            data: [],
+            // The position in data currently displayed
+            position: -1,
+            // The room the history is for.
+            roomId: null,
+            // The original text before they hit UP
+            originalText: null,
+            // The textarea element to set text to.
+            element: null,
+
+            init: function(element, roomId) {
+                this.roomId = roomId;
+                this.element = element;
+                this.position = -1;
+                var storedData = window.sessionStorage.getItem(
+                    "history_" + roomId
+                );
+                if (storedData) {
+                    this.data = JSON.parse(storedData);
+                }
+                if (this.roomId) {
+                    this.setLastTextEntry();
+                }
+            },
+
+            push: function(text) {
+                // store a message in the sent history
+                this.data.unshift(text);
+                window.sessionStorage.setItem(
+                    "history_" + this.roomId,
+                    JSON.stringify(this.data)
+                );
+                // reset history position
+                this.position = -1;
+                this.originalText = null;
+            },
+
+            // move in the history. Returns true if we managed to move.
+            next: function(offset) {
+                if (this.position === -1) {
+                    // user is going into the history, save the current line.
+                    this.originalText = this.element.value;
+                }
+                else {
+                    // user may have modified this line in the history; remember it.
+                    this.data[this.position] = this.element.value;
+                }
+
+                if (offset > 0 && this.position === (this.data.length - 1)) {
+                    // we've run out of history
+                    return false;
+                }
+
+                // retrieve the next item (bounded).
+                var newPosition = this.position + offset;
+                newPosition = Math.max(-1, newPosition);
+                newPosition = Math.min(newPosition, this.data.length - 1);
+                this.position = newPosition;
+
+                if (this.position !== -1) {
+                    // show the message
+                    this.element.value = this.data[this.position];
+                }
+                else if (this.originalText !== undefined) {
+                    // restore the original text the user was typing.
+                    this.element.value = this.originalText;
+                }
+                return true;
+            },
+
+            saveLastTextEntry: function() {
+                // save the currently entered text in order to restore it later.
+                // NB: This isn't 'originalText' because we want to restore
+                // sent history items too!
+                var text = this.element.value;
+                window.sessionStorage.setItem("input_" + this.roomId, text);
+            },
+
+            setLastTextEntry: function() {
+                var text = window.sessionStorage.getItem("input_" + this.roomId);
+                if (text) {
+                    this.element.value = text;
+                }
+            }
+        };
+    },
+
     componentDidMount: function() {
         this.dispatcherRef = dis.register(this.onAction);
+        this.sentHistory.init(
+            this.refs.textarea.getDOMNode(),
+            this.props.room.roomId
+        );
     },
 
     componentWillUnmount: function() {
         dis.unregister(this.dispatcherRef);
+        this.sentHistory.saveLastTextEntry();
     },
 
     onAction: function(payload) {
@@ -38,30 +149,265 @@ module.exports = {
     },
 
     onKeyDown: function (ev) {
-        if (ev.keyCode == 13) {
-            var contentText = this.refs.textarea.getDOMNode().value;
-
-            var content = null;
-            if (/^\/me /i.test(contentText)) {
-                content = {
-                    msgtype: 'm.emote',
-                    body: contentText.substring(4)
-                };
-            } else {
-                content = {
-                    msgtype: 'm.text',
-                    body: contentText
-                };
+        if (ev.keyCode === KeyCode.ENTER) {
+            var input = this.refs.textarea.getDOMNode().value;
+            if (input.length === 0) {
+                ev.preventDefault();
+                return;
             }
-
-            MatrixClientPeg.get().sendMessage(this.props.roomId, content).then(function() {
-                dis.dispatch({
-                    action: 'message_sent'
-                });
-            });
-            this.refs.textarea.getDOMNode().value = '';
+            this.sentHistory.push(input);
+            this.onEnter(ev);
+        }
+        else if (ev.keyCode === KeyCode.TAB) {
+            var members = [];
+            if (this.props.room) {
+                members = this.props.room.getJoinedMembers();
+            }
+            this.onTab(ev, members);
+        }
+        else if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) {
+            this.sentHistory.next(
+                ev.keyCode === KeyCode.UP ? 1 : -1
+            );
             ev.preventDefault();
         }
+        else if (ev.keyCode !== KeyCode.SHIFT && this.tabStruct.completing) {
+            // they're resuming typing; reset tab complete state vars.
+            this.tabStruct.completing = false;
+            this.tabStruct.index = 0;
+        }
+
+        var self = this;
+        setTimeout(function() {
+            if (self.refs.textarea && self.refs.textarea.getDOMNode().value != '') {
+                self.onTypingActivity();
+            } else {
+                self.onFinishedTyping();
+            }
+        }, 10); // XXX: what is this 10ms setTimeout doing?  Looks hacky :(
     },
+
+    onEnter: function(ev) {
+        var contentText = this.refs.textarea.getDOMNode().value;
+
+        var cmd = SlashCommands.processInput(this.props.room.roomId, contentText);
+        if (cmd) {
+            ev.preventDefault();
+            if (!cmd.error) {
+                this.refs.textarea.getDOMNode().value = '';
+            }
+            if (cmd.promise) {
+                cmd.promise.done(function() {
+                    console.log("Command success.");
+                }, function(err) {
+                    console.error("Command failure: %s", err);
+                    var ErrorDialog = sdk.getComponent("organisms.ErrorDialog");
+                    Modal.createDialog(ErrorDialog, {
+                        title: "Server error",
+                        description: err.message
+                    });
+                });
+            }
+            else if (cmd.error) {
+                console.error(cmd.error);
+                var ErrorDialog = sdk.getComponent("organisms.ErrorDialog");
+                Modal.createDialog(ErrorDialog, {
+                    title: "Command error",
+                    description: cmd.error
+                });
+            }
+            return;
+        }
+
+        var content = null;
+        if (/^\/me /i.test(contentText)) {
+            content = {
+                msgtype: 'm.emote',
+                body: contentText.substring(4)
+            };
+        } else {
+            content = {
+                msgtype: 'm.text',
+                body: contentText
+            };
+        }
+
+        MatrixClientPeg.get().sendMessage(this.props.room.roomId, content).then(function() {
+            dis.dispatch({
+                action: 'message_sent'
+            });
+        }, function() {
+            dis.dispatch({
+                action: 'message_send_failed'
+            });
+        });
+        this.refs.textarea.getDOMNode().value = '';
+        ev.preventDefault();
+    },
+
+    onTab: function(ev, sortedMembers) {
+        var textArea = this.refs.textarea.getDOMNode();
+        if (!this.tabStruct.completing) {
+            this.tabStruct.completing = true;
+            this.tabStruct.index = 0;
+            // cache starting text
+            this.tabStruct.original = textArea.value;
+        }
+
+        // loop in the right direction
+        if (ev.shiftKey) {
+            this.tabStruct.index --;
+            if (this.tabStruct.index < 0) {
+                // wrap to the last search match, and fix up to a real index
+                // value after we've matched.
+                this.tabStruct.index = Number.MAX_VALUE;
+            }
+        }
+        else {
+            this.tabStruct.index++;
+        }
+
+        var searchIndex = 0;
+        var targetIndex = this.tabStruct.index;
+        var text = this.tabStruct.original;
+
+        var search = /@?([a-zA-Z0-9_\-:\.]+)$/.exec(text);
+        // console.log("Searched in '%s' - got %s", text, search);
+        if (targetIndex === 0) { // 0 is always the original text
+            textArea.value = text;
+        }
+        else if (search && search[1]) {
+            // console.log("search found: " + search+" from "+text);
+            var expansion;
+
+            // FIXME: could do better than linear search here
+            for (var i=0; i<sortedMembers.length; i++) {
+                var member = sortedMembers[i];
+                if (member.name && searchIndex < targetIndex) {
+                    if (member.name.toLowerCase().indexOf(search[1].toLowerCase()) === 0) {
+                        expansion = member.name;
+                        searchIndex++;
+                    }
+                }
+            }
+
+            if (searchIndex < targetIndex) { // then search raw mxids
+                for (var i=0; i<sortedMembers.length; i++) {
+                    if (searchIndex >= targetIndex) {
+                        break;
+                    }
+                    var userId = sortedMembers[i].userId;
+                    // === 1 because mxids are @username
+                    if (userId.toLowerCase().indexOf(search[1].toLowerCase()) === 1) {
+                        expansion = userId;
+                        searchIndex++;
+                    }
+                }
+            }
+
+            if (searchIndex === targetIndex ||
+                    targetIndex === Number.MAX_VALUE) {
+                // xchat-style tab complete, add a colon if tab
+                // completing at the start of the text
+                if (search[0].length === text.length) {
+                    expansion += ": ";
+                }
+                else {
+                    expansion += " ";
+                }
+                textArea.value = text.replace(
+                    /@?([a-zA-Z0-9_\-:\.]+)$/, expansion
+                );
+                // cancel blink
+                textArea.style["background-color"] = "";
+                if (targetIndex === Number.MAX_VALUE) {
+                    // wrap the index around to the last index found
+                    this.tabStruct.index = searchIndex;
+                    targetIndex = searchIndex;
+                }
+            }
+            else {
+                // console.log("wrapped!");
+                textArea.style["background-color"] = "#faa";
+                setTimeout(function() {
+                     textArea.style["background-color"] = "";
+                }, 150);
+                textArea.value = text;
+                this.tabStruct.index = 0;
+            }
+        }
+        else {
+            this.tabStruct.index = 0;
+        }
+        // prevent the default TAB operation (typically focus shifting)
+        ev.preventDefault();
+    },
+
+    onTypingActivity: function() {
+        this.isTyping = true;
+        if (!this.userTypingTimer) {
+            this.sendTyping(true);
+        }
+        this.startUserTypingTimer();
+        this.startServerTypingTimer();
+    },
+
+    onFinishedTyping: function() {
+        this.isTyping = false;
+        this.sendTyping(false);
+        this.stopUserTypingTimer();
+        this.stopServerTypingTimer();
+    },
+
+    startUserTypingTimer: function() {
+        this.stopUserTypingTimer();
+        var self = this;
+        this.userTypingTimer = setTimeout(function() {
+            self.isTyping = false;
+            self.sendTyping(self.isTyping);
+            self.userTypingTimer = null;
+        }, TYPING_USER_TIMEOUT);
+    },
+
+    stopUserTypingTimer: function() {
+        if (this.userTypingTimer) {
+            clearTimeout(this.userTypingTimer);
+            this.userTypingTimer = null;
+        }
+    },
+
+    startServerTypingTimer: function() {
+        if (!this.serverTypingTimer) {
+            var self = this;
+            this.serverTypingTimer = setTimeout(function() {
+                if (self.isTyping) {
+                    self.sendTyping(self.isTyping);
+                    self.startServerTypingTimer();
+                }
+            }, TYPING_SERVER_TIMEOUT / 2);
+        }
+    },
+
+    stopServerTypingTimer: function() {
+        if (this.serverTypingTimer) {
+            clearTimeout(this.servrTypingTimer);
+            this.serverTypingTimer = null;
+        }
+    },
+
+    sendTyping: function(isTyping) {
+        MatrixClientPeg.get().sendTyping(
+            this.props.room.roomId,
+            this.isTyping, TYPING_SERVER_TIMEOUT
+        ).done();
+    },
+
+    refreshTyping: function() {
+        if (this.typingTimeout) {
+            clearTimeout(this.typingTimeout);
+            this.typingTimeout = null;
+        }
+
+    }
 };
 
diff --git a/src/controllers/molecules/MessageTile.js b/src/controllers/molecules/MessageTile.js
index 953e33b516..47b616e724 100644
--- a/src/controllers/molecules/MessageTile.js
+++ b/src/controllers/molecules/MessageTile.js
@@ -23,6 +23,28 @@ module.exports = {
         var actions = MatrixClientPeg.get().getPushActionsForEvent(this.props.mxEvent);
         if (!actions || !actions.tweaks) { return false; }
         return actions.tweaks.highlight;
+    },
+
+    getInitialState: function() {
+        return {
+            resending: false
+        };
+    },
+
+    onResend: function() {
+        var self = this;
+        self.setState({
+            resending: true
+        });
+        MatrixClientPeg.get().resendEvent(
+            this.props.mxEvent, MatrixClientPeg.get().getRoom(
+                this.props.mxEvent.getRoomId()
+            )
+        ).finally(function() {
+            self.setState({
+                resending: false
+            });
+        })
     }
 };
 
diff --git a/src/controllers/molecules/RoomHeader.js b/src/controllers/molecules/RoomHeader.js
index 8aa688b21e..d3afce1e49 100644
--- a/src/controllers/molecules/RoomHeader.js
+++ b/src/controllers/molecules/RoomHeader.js
@@ -16,6 +16,81 @@ limitations under the License.
 
 'use strict';
 
-module.exports = {
-};
+/*
+ * State vars:
+ * this.state.call_state = the UI state of the call (see CallHandler)
+ */
 
+var React = require('react');
+var dis = require("../../dispatcher");
+var CallHandler = require("../../CallHandler");
+
+module.exports = {
+    propTypes: {
+        room: React.PropTypes.object.isRequired,
+        editing: React.PropTypes.bool,
+        onSettingsClick: React.PropTypes.func,
+        onSaveClick: React.PropTypes.func,
+    },
+
+    getDefaultProps: function() {
+        return {
+            editing: false,
+            onSettingsClick: function() {},
+            onSaveClick: function() {},
+        };
+    },
+
+    componentDidMount: function() {
+        this.dispatcherRef = dis.register(this.onAction);
+        if (this.props.room) {
+            var call = CallHandler.getCallForRoom(this.props.room.roomId);
+            var callState = call ? call.call_state : "ended";
+            this.setState({
+                call_state: callState
+            });
+        }
+    },
+
+    componentWillUnmount: function() {
+        dis.unregister(this.dispatcherRef);
+    },
+
+    onAction: function(payload) {
+        // don't filter out payloads for room IDs other than props.room because
+        // we may be interested in the conf 1:1 room
+        if (payload.action !== 'call_state' || !payload.room_id) {
+            return;
+        }
+        var call = CallHandler.getCallForRoom(payload.room_id);
+        var callState = call ? call.call_state : "ended";
+        this.setState({
+            call_state: callState
+        });
+    },
+
+    onVideoClick: function() {
+        dis.dispatch({
+            action: 'place_call',
+            type: "video",
+            room_id: this.props.room.roomId
+        });
+    },
+    onVoiceClick: function() {
+        dis.dispatch({
+            action: 'place_call',
+            type: "voice",
+            room_id: this.props.room.roomId
+        });
+    },
+    onHangupClick: function() {
+        var call = CallHandler.getCallForRoom(this.props.room.roomId);
+        if (!call) { return; }
+        dis.dispatch({
+            action: 'hangup',
+            // hangup the call for this room, which may not be the room in props
+            // (e.g. conferences which will hangup the 1:1 room instead)
+            room_id: call.roomId
+        });
+    }
+};
diff --git a/skins/base/css/molecules/MessageTile.css b/src/controllers/molecules/RoomSettings.js
similarity index 70%
rename from skins/base/css/molecules/MessageTile.css
rename to src/controllers/molecules/RoomSettings.js
index dae12e1a2b..3c0682d09a 100644
--- a/skins/base/css/molecules/MessageTile.css
+++ b/src/controllers/molecules/RoomSettings.js
@@ -14,22 +14,16 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_MessageTile {
-	display: table-row;
-}
+var React = require('react');
 
-.mx_MessageTile_content {
-	display: table-cell;
-}
+module.exports = {
+    propTypes: {
+        room: React.PropTypes.object.isRequired,
+    },
 
-.mx_MessageTile_sending {
-    color: #ddd;
-}
-
-.mx_MessageTile_notSent {
-    color: #f11;
-}
-
-.mx_MessageTile_highlight {
-    color: #00f;
-}
+    getInitialState: function() {
+        return {
+            power_levels_changed: false
+        };
+    }
+};
diff --git a/src/controllers/molecules/ServerConfig.js b/src/controllers/molecules/ServerConfig.js
index 3cd5156ba8..3f5dd99bb5 100644
--- a/src/controllers/molecules/ServerConfig.js
+++ b/src/controllers/molecules/ServerConfig.js
@@ -30,26 +30,28 @@ module.exports = {
         return {
             onHsUrlChanged: function() {},
             onIsUrlChanged: function() {},
-            default_hs_url: 'https://matrix.org/',
-            default_is_url: 'https://matrix.org/'
+            defaultHsUrl: 'https://matrix.org/',
+            defaultIsUrl: 'https://matrix.org/'
         };
     },
 
     getInitialState: function() {
         return {
-            hs_url: this.props.default_hs_url,
-            is_url: this.props.default_is_url,
+            hs_url: this.props.defaultHsUrl,
+            is_url: this.props.defaultIsUrl,
         }
     },
 
     hsChanged: function(ev) {
-        this.setState({hs_url: ev.target.value});
-        this.props.onHsUrlChanged(this.state.hs_url);
+        this.setState({hs_url: ev.target.value}, function() {
+            this.props.onHsUrlChanged(this.state.hs_url);
+        });
     },
 
     isChanged: function(ev) {
-        this.setState({is_url: ev.target.value});
-        this.props.onIsUrlChanged(this.state.is_url);
+        this.setState({is_url: ev.target.value}, function() {
+            this.props.onIsUrlChanged(this.state.is_url);
+        });
     },
 
     getHsUrl: function() {
diff --git a/src/controllers/molecules/UserSelector.js b/src/controllers/molecules/UserSelector.js
index e7e0509690..67a56163fa 100644
--- a/src/controllers/molecules/UserSelector.js
+++ b/src/controllers/molecules/UserSelector.js
@@ -20,38 +20,26 @@ var React = require('react');
 
 module.exports = {
     propTypes: {
-        initially_selected: React.PropTypes.arrayOf(React.PropTypes.string),
+        onChange: React.PropTypes.func,
+        selected_users: React.PropTypes.arrayOf(React.PropTypes.string),
     },
 
     getDefaultProps: function() {
         return {
-            initially_selected: [],
+            onChange: function() {},
+            selected: [],
         };
     },
 
-    getInitialState: function() {
-        return {
-            selected_users: this.props.initially_selected,
-        }
-    },
-
     addUser: function(user_id) {
-        if (this.state.selected_users.indexOf(user_id == -1)) {
-            this.setState({
-                selected_users: this.state.selected_users.concat([user_id]),
-            });
+        if (this.props.selected_users.indexOf(user_id == -1)) {
+            this.props.onChange(this.props.selected_users.concat([user_id]));
         }
     },
 
     removeUser: function(user_id) {
-        this.setState({
-            selected_users: this.state.selected_users.filter(function(e) {
-                return e != user_id;
-            }),
-        });
+        this.props.onChange(this.props.selected_users.filter(function(e) {
+            return e != user_id;
+        }));
     },
-
-    getUserIds: function() {
-        return this.state.selected_users;
-    }
 };
diff --git a/src/controllers/molecules/voip/CallView.js b/src/controllers/molecules/voip/CallView.js
new file mode 100644
index 0000000000..c8fab1fba4
--- /dev/null
+++ b/src/controllers/molecules/voip/CallView.js
@@ -0,0 +1,70 @@
+/*
+Copyright 2015 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.
+*/
+
+var dis = require("../../../dispatcher");
+var CallHandler = require("../../../CallHandler");
+
+/*
+ * State vars:
+ * this.state.call = MatrixCall|null
+ *
+ * Props:
+ * this.props.room = Room (JS SDK)
+ */
+
+module.exports = {
+
+    componentDidMount: function() {
+        this.dispatcherRef = dis.register(this.onAction);
+        if (this.props.room) {
+            this.showCall(this.props.room.roomId);
+        }
+    },
+
+    componentWillUnmount: function() {
+        dis.unregister(this.dispatcherRef);
+    },
+
+    onAction: function(payload) {
+        // if we were given a room_id to track, don't handle anything else.
+        if (payload.room_id && this.props.room && 
+                this.props.room.roomId !== payload.room_id) {
+            return;
+        }
+        if (payload.action !== 'call_state') {
+            return;
+        }
+        this.showCall(payload.room_id);
+    },
+
+    showCall: function(roomId) {
+        var call = CallHandler.getCall(roomId);
+        if (call) {
+            call.setLocalVideoElement(this.getVideoView().getLocalVideoElement());
+            // N.B. the remote video element is used for playback for audio for voice calls
+            call.setRemoteVideoElement(this.getVideoView().getRemoteVideoElement());
+        }
+        if (call && call.type === "video" && call.state !== 'ended') {
+            this.getVideoView().getLocalVideoElement().style.display = "initial";
+            this.getVideoView().getRemoteVideoElement().style.display = "initial";
+        }
+        else {
+            this.getVideoView().getLocalVideoElement().style.display = "none";
+            this.getVideoView().getRemoteVideoElement().style.display = "none";
+        }
+    }
+};
+
diff --git a/src/controllers/molecules/voip/IncomingCallBox.js b/src/controllers/molecules/voip/IncomingCallBox.js
new file mode 100644
index 0000000000..9ecced56c5
--- /dev/null
+++ b/src/controllers/molecules/voip/IncomingCallBox.js
@@ -0,0 +1,73 @@
+/*
+Copyright 2015 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.
+*/
+
+var dis = require("../../../dispatcher");
+var CallHandler = require("../../../CallHandler");
+
+module.exports = {
+    componentDidMount: function() {
+        this.dispatcherRef = dis.register(this.onAction);
+    },
+
+    componentWillUnmount: function() {
+        dis.unregister(this.dispatcherRef);
+    },
+
+    getInitialState: function() {
+        return {
+            incomingCall: null
+        }
+    },
+
+    onAction: function(payload) {
+        if (payload.action !== 'call_state') {
+            return;
+        }
+        var call = CallHandler.getCall(payload.room_id);
+        if (!call || call.call_state !== 'ringing') {
+            this.setState({
+                incomingCall: null,
+            });
+            this.getRingAudio().pause();
+            return;
+        }
+        if (call.call_state === "ringing") {
+            this.getRingAudio().load();
+            this.getRingAudio().play();
+        }
+        else {
+            this.getRingAudio().pause();
+        }
+
+        this.setState({
+            incomingCall: call
+        });
+    },
+
+    onAnswerClick: function() {
+        dis.dispatch({
+            action: 'answer',
+            room_id: this.state.incomingCall.roomId
+        });
+    },
+    onRejectClick: function() {
+        dis.dispatch({
+            action: 'hangup',
+            room_id: this.state.incomingCall.roomId
+        });
+    }
+};
+
diff --git a/skins/base/css/molecules/MNoticeTile.css b/src/controllers/molecules/voip/VideoView.js
similarity index 96%
rename from skins/base/css/molecules/MNoticeTile.css
rename to src/controllers/molecules/voip/VideoView.js
index cac13e9b89..3d34134daa 100644
--- a/skins/base/css/molecules/MNoticeTile.css
+++ b/src/controllers/molecules/voip/VideoView.js
@@ -14,5 +14,6 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_MNoticeTile {
-}
+module.exports = {
+};
+
diff --git a/src/controllers/organisms/CreateRoom.js b/src/controllers/organisms/CreateRoom.js
index c2112ce58f..f6404eb231 100644
--- a/src/controllers/organisms/CreateRoom.js
+++ b/src/controllers/organisms/CreateRoom.js
@@ -18,6 +18,9 @@ limitations under the License.
 
 var React = require("react");
 var MatrixClientPeg = require("../../MatrixClientPeg");
+var PresetValues = require('../atoms/create_room/Presets').Presets;
+var q = require('q');
+var encryption = require("../../encryption");
 
 module.exports = {
     propTypes: {
@@ -41,25 +44,52 @@ module.exports = {
         return {
             phase: this.phases.CONFIG,
             error_string: "",
+            is_private: true,
+            share_history: false,
+            default_preset: PresetValues.PrivateChat,
+            topic: '',
+            room_name: '',
+            invited_users: [],
         };
     },
 
     onCreateRoom: function() {
         var options = {};
 
-        var room_name = this.getName();
-        if (room_name) {
-            options.name = room_name;
+        if (this.state.room_name) {
+            options.name = this.state.room_name;
         }
 
-        var preset = this.getPreset();
-        if (preset) {
-            options.preset = preset;
+        if (this.state.topic) {
+            options.topic = this.state.topic;
         }
 
-        var invited_users = this.getInvitedUsers();
-        if (invited_users) {
-            options.invite = invited_users;
+        if (this.state.preset) {
+            if (this.state.preset != PresetValues.Custom) {
+                options.preset = this.state.preset;
+            } else {
+                options.initial_state = [
+                    {
+                        type: "m.room.join_rules",
+                        content: {
+                            "join_rules": this.state.is_private ? "invite" : "public"
+                        }
+                    },
+                    {
+                        type: "m.room.history_visibility",
+                        content: {
+                            "history_visibility": this.state.share_history ? "shared" : "invited"
+                        }
+                    },
+                ];
+            }
+        }
+
+        options.invite = this.state.invited_users;
+
+        var alias = this.getAliasLocalpart();
+        if (alias) {
+            options.room_alias_name = alias;
         }
 
         var cli = MatrixClientPeg.get();
@@ -69,7 +99,20 @@ module.exports = {
             return;
         }
 
-        var deferred = MatrixClientPeg.get().createRoom(options);
+        var deferred = cli.createRoom(options);
+
+        var response;
+
+        if (this.state.encrypt) {
+            deferred = deferred.then(function(res) {
+                response = res;
+                return encryption.enableEncryption(
+                    cli, response.roomId, options.invite
+                );
+            }).then(function() {
+                return q(response) }
+            );
+        }
 
         this.setState({
             phase: this.phases.CREATING,
@@ -77,11 +120,11 @@ module.exports = {
 
         var self = this;
 
-        deferred.then(function () {
+        deferred.then(function (resp) {
             self.setState({
                 phase: self.phases.CREATED,
             });
-            self.props.onRoomCreated();
+            self.props.onRoomCreated(resp.room_id);
         }, function(err) {
             self.setState({
                 phase: self.phases.ERROR,
diff --git a/skins/base/views/molecules/RoomHeader.js b/src/controllers/organisms/ErrorDialog.js
similarity index 54%
rename from skins/base/views/molecules/RoomHeader.js
rename to src/controllers/organisms/ErrorDialog.js
index b5296f4e82..6b7c35b3f2 100644
--- a/skins/base/views/molecules/RoomHeader.js
+++ b/src/controllers/organisms/ErrorDialog.js
@@ -14,22 +14,23 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-'use strict';
+var React = require("react");
 
-var React = require('react');
-
-var RoomHeaderController = require("../../../../src/controllers/molecules/RoomHeader");
-
-module.exports = React.createClass({
-    displayName: 'RoomHeader',
-    mixins: [RoomHeaderController],
-
-    render: function() {
-        return (
-            <div className="mx_RoomHeader">
-                {this.props.room.name}
-            </div>
-        );
+module.exports = {
+    propTypes: {
+        title: React.PropTypes.string,
+        description: React.PropTypes.string,
+        button: React.PropTypes.string,
+        focus: React.PropTypes.bool,
+        onFinished: React.PropTypes.func.isRequired,
     },
-});
 
+    getDefaultProps: function() {
+        return {
+            title: "Error",
+            description: "An error has occurred.",
+            button: "OK",
+            focus: true,
+        };
+    },
+};
diff --git a/skins/base/css/molecules/MTextTile.css b/src/controllers/organisms/LogoutPrompt.js
similarity index 62%
rename from skins/base/css/molecules/MTextTile.css
rename to src/controllers/organisms/LogoutPrompt.js
index 5b117e41b8..5e5011ea97 100644
--- a/skins/base/css/molecules/MTextTile.css
+++ b/src/controllers/organisms/LogoutPrompt.js
@@ -14,7 +14,20 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_MTextTile {
-    white-space: pre-wrap;
-}
+var dis = require("../../dispatcher");
+
+module.exports = {
+    logOut: function() {
+        dis.dispatch({action: 'logout'});
+        if (this.props.onFinished) {
+            this.props.onFinished();
+        }
+    },
+
+    cancelPrompt: function() {
+        if (this.props.onFinished) {
+            this.props.onFinished();
+        }
+    }
+};
 
diff --git a/src/controllers/organisms/MemberList.js b/src/controllers/organisms/MemberList.js
index a511816d53..bf61a80048 100644
--- a/src/controllers/organisms/MemberList.js
+++ b/src/controllers/organisms/MemberList.js
@@ -14,10 +14,9 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-'use strict';
-
-var React = require("react");
 var MatrixClientPeg = require("../../MatrixClientPeg");
+var Modal = require("../../Modal");
+var sdk = require('../../index');
 
 var INITIAL_LOAD_NUM_MEMBERS = 50;
 
@@ -32,39 +31,137 @@ module.exports = {
     componentWillMount: function() {
         var cli = MatrixClientPeg.get();
         cli.on("RoomState.members", this.onRoomStateMember);
+        cli.on("Room", this.onRoom); // invites
     },
 
     componentWillUnmount: function() {
         if (MatrixClientPeg.get()) {
+            MatrixClientPeg.get().removeListener("Room", this.onRoom);
             MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
+            MatrixClientPeg.get().removeListener("User.presence", this.userPresenceFn);
         }
     },
 
     componentDidMount: function() {
-        var that = this;
+        var self = this;
+
+        // Lazy-load in more than the first N members
         setTimeout(function() {
-            if (!that.isMounted()) return;
-            that.setState({
-                memberDict: that.roomMembers()
+            if (!self.isMounted()) return;
+            self.setState({
+                memberDict: self.roomMembers()
             });
         }, 50);
-    },
 
+        // Attach a SINGLE listener for global presence changes then locate the
+        // member tile and re-render it. This is more efficient than every tile
+        // evar attaching their own listener.
+        function updateUserState(event, user) {
+            // XXX: evil hack to track the age of this presence info.
+            // this should be removed once syjs-28 is resolved in the JS SDK itself.
+            user.lastPresenceTs = Date.now();
+
+            var tile = self.refs[user.userId];
+
+            console.log("presence event " + JSON.stringify(event) + " user = " + user + " tile = " + tile);
+
+            if (tile) {
+                self._updateList(); // reorder the membership list
+                self.forceUpdate(); // FIXME: is the a more efficient way of reordering with react?
+                // XXX: do we even need to do this, or is it done by the main list?
+                tile.forceUpdate();
+            }
+        }
+        // FIXME: we should probably also reset 'lastActiveAgo' to zero whenever
+        // we see a typing notif from a user, as we don't get presence updates for those.
+        MatrixClientPeg.get().on("User.presence", updateUserState);
+        this.userPresenceFn = updateUserState;
+    },
     // Remember to set 'key' on a MemberList to the ID of the room it's for
     /*componentWillReceiveProps: function(newProps) {
     },*/
 
+    onRoom: function(room) {
+        if (room.roomId !== this.props.roomId) {
+            return;
+        }
+        // We listen for room events because when we accept an invite
+        // we need to wait till the room is fully populated with state
+        // before refreshing the member list else we get a stale list.
+        this._updateList();
+    },
+
     onRoomStateMember: function(ev, state, member) {
+        this._updateList();
+    },
+
+    _updateList: function() {
         var members = this.roomMembers();
         this.setState({
             memberDict: members
         });
     },
 
+    onInvite: function(inputText) {
+        var ErrorDialog = sdk.getComponent("organisms.ErrorDialog");
+        var self = this;
+        // sanity check the input
+        inputText = inputText.trim(); // react requires es5-shim so we know trim() exists
+        if (inputText[0] !== '@' || inputText.indexOf(":") === -1) {
+            console.error("Bad user ID to invite: %s", inputText);
+            Modal.createDialog(ErrorDialog, {
+                title: "Invite Error",
+                description: "Malformed user ID. Should look like '@localpart:domain'"
+            });
+            return;
+        }
+        self.setState({
+            inviting: true
+        });
+        console.log("Invite %s to %s", inputText, this.props.roomId);
+        MatrixClientPeg.get().invite(this.props.roomId, inputText).done(
+        function(res) {
+            console.log("Invited");
+            self.setState({
+                inviting: false
+            });
+        }, function(err) {
+            console.error("Failed to invite: %s", JSON.stringify(err));
+            Modal.createDialog(ErrorDialog, {
+                title: "Server error whilst inviting",
+                description: err.message
+            });
+            self.setState({
+                inviting: false
+            });
+        });
+    },
+
     roomMembers: function(limit) {
+        if (!this.props.roomId) return {};
         var cli = MatrixClientPeg.get();
-        var all_members = cli.getRoom(this.props.roomId).currentState.members;
+        var room = cli.getRoom(this.props.roomId);
+        if (!room) return {};
+        var all_members = room.currentState.members;
         var all_user_ids = Object.keys(all_members);
+
+        // XXX: evil hack until SYJS-28 is fixed
+        all_user_ids.map(function(userId) {
+            if (all_members[userId].user && !all_members[userId].user.lastPresenceTs) {
+                all_members[userId].user.lastPresenceTs = Date.now();
+            }
+        });
+
+        all_user_ids.sort(function(userIdA, userIdB) {
+            var userA = all_members[userIdA].user;
+            var userB = all_members[userIdB].user;
+
+            var latA = userA ? (userA.lastPresenceTs - (userA.lastActiveAgo || userA.lastPresenceTs)) : 0;
+            var latB = userB ? (userB.lastPresenceTs - (userB.lastActiveAgo || userB.lastPresenceTs)) : 0;
+
+            return latB - latA;
+        });
+
         var to_display = {};
         var count = 0;
         for (var i = 0; i < all_user_ids.length && (limit === undefined || count < limit); ++i) {
@@ -72,6 +169,8 @@ module.exports = {
             var m = all_members[user_id];
 
             if (m.membership == 'join' || m.membership == 'invite') {
+                // XXX: this is evil, and relies on the fact that Object.keys() iterates
+                // over the keys of a dict in insertion order (if those keys are strings)
                 to_display[user_id] = m;
                 ++count;
             }
diff --git a/src/controllers/organisms/Notifier.js b/src/controllers/organisms/Notifier.js
index 63e937780d..8fb62abe40 100644
--- a/src/controllers/organisms/Notifier.js
+++ b/src/controllers/organisms/Notifier.js
@@ -17,11 +17,21 @@ limitations under the License.
 'use strict';
 
 var MatrixClientPeg = require("../../MatrixClientPeg");
+var dis = require("../../dispatcher");
+
+/*
+ * Dispatches:
+ * {
+ *   action: "notifier_enabled",
+ *   value: boolean
+ * }
+ */
 
 module.exports = {
     start: function() {
         this.boundOnRoomTimeline = this.onRoomTimeline.bind(this);
         MatrixClientPeg.get().on('Room.timeline', this.boundOnRoomTimeline);
+        this.state = { 'toolbarHidden' : false };
     },
 
     stop: function() {
@@ -30,12 +40,80 @@ module.exports = {
         }
     },
 
+    supportsDesktopNotifications: function() {
+        return !!global.Notification;
+    },
+
+    havePermission: function() {
+        if (!this.supportsDesktopNotifications()) return false;
+        return global.Notification.permission == 'granted';
+    },
+
+    setEnabled: function(enable, callback) {
+        if(enable) {
+            if (!this.havePermission()) {
+                global.Notification.requestPermission(function() {
+                    if (callback) {
+                        callback();
+                        dis.dispatch({
+                            action: "notifier_enabled",
+                            value: true
+                        });
+                    }
+                });
+            }
+
+            if (!global.localStorage) return;
+            global.localStorage.setItem('notifications_enabled', 'true');
+
+            if (this.havePermission) {
+                dis.dispatch({
+                    action: "notifier_enabled",
+                    value: true
+                });
+            }
+        }
+        else {
+            if (!global.localStorage) return;
+            global.localStorage.setItem('notifications_enabled', 'false');
+            dis.dispatch({
+                action: "notifier_enabled",
+                value: false
+            });
+        }
+
+        this.setToolbarHidden(false);
+    },
+
+    isEnabled: function() {
+        if (!this.havePermission()) return false;
+
+        if (!global.localStorage) return true;
+
+        var enabled = global.localStorage.getItem('notifications_enabled');
+        if (enabled === null) return true;
+        return enabled === 'true';
+    },
+
+    setToolbarHidden: function(hidden) {
+        this.state.toolbarHidden = hidden;
+        dis.dispatch({
+            action: "notifier_enabled",
+            value: this.isEnabled()
+        });
+    },
+
+    isToolbarHidden: function() {
+        return this.state.toolbarHidden;
+    },
+
     onRoomTimeline: function(ev, room, toStartOfTimeline) {
         if (toStartOfTimeline) return;
         if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) return;
 
-        var enabled = global.localStorage.getItem('notifications_enabled');
-        if (enabled === 'false') return;
+        if (!this.isEnabled()) {
+            return;
+        }
 
         var actions = MatrixClientPeg.get().getPushActionsForEvent(ev);
         if (actions && actions.notify) {
diff --git a/skins/base/views/molecules/MessageComposer.js b/src/controllers/organisms/QuestionDialog.js
similarity index 55%
rename from skins/base/views/molecules/MessageComposer.js
rename to src/controllers/organisms/QuestionDialog.js
index 89c426cb2b..30891b839d 100644
--- a/skins/base/views/molecules/MessageComposer.js
+++ b/src/controllers/organisms/QuestionDialog.js
@@ -14,22 +14,23 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-'use strict';
+var React = require("react");
 
-var React = require('react');
-
-var MessageComposerController = require("../../../../src/controllers/molecules/MessageComposer");
-
-module.exports = React.createClass({
-    displayName: 'MessageComposer',
-    mixins: [MessageComposerController],
-
-    render: function() {
-        return (
-            <div className="mx_MessageComposer">
-                <textarea ref="textarea" onKeyDown={this.onKeyDown} />
-            </div>
-        );
+module.exports = {
+    propTypes: {
+        title: React.PropTypes.string,
+        description: React.PropTypes.string,
+        button: React.PropTypes.string,
+        focus: React.PropTypes.bool,
+        onFinished: React.PropTypes.func.isRequired,
     },
-});
 
+    getDefaultProps: function() {
+        return {
+            title: "",
+            description: "",
+            button: "OK",
+            focus: true,
+        };
+    },
+};
diff --git a/src/controllers/organisms/RoomList.js b/src/controllers/organisms/RoomList.js
index 03e18547b6..be502e3b88 100644
--- a/src/controllers/organisms/RoomList.js
+++ b/src/controllers/organisms/RoomList.js
@@ -20,9 +20,7 @@ var React = require("react");
 var MatrixClientPeg = require("../../MatrixClientPeg");
 var RoomListSorter = require("../../RoomListSorter");
 
-var ComponentBroker = require('../../ComponentBroker');
-
-var RoomTile = ComponentBroker.get("molecules/RoomTile");
+var sdk = require('../../index');
 
 module.exports = {
     componentWillMount: function() {
@@ -73,13 +71,11 @@ module.exports = {
             if (actions && actions.tweaks && actions.tweaks.highlight) {
                 hl = 2;
             }
-            if (actions.notify) {
-                // obviously this won't deep copy but this shouldn't be necessary
-                var amap = this.state.activityMap;
-                amap[room.roomId] = Math.max(amap[room.roomId] || 0, hl);
+            // obviously this won't deep copy but this shouldn't be necessary
+            var amap = this.state.activityMap;
+            amap[room.roomId] = Math.max(amap[room.roomId] || 0, hl);
 
-                newState.activityMap = amap;
-            }
+            newState.activityMap = amap;
         }
         this.setState(newState);
     },
@@ -96,23 +92,28 @@ module.exports = {
     },
 
     getRoomList: function() {
-        return RoomListSorter.mostRecentActivityFirst(MatrixClientPeg.get().getRooms());
+        return RoomListSorter.mostRecentActivityFirst(
+            MatrixClientPeg.get().getRooms().filter(function(room) {
+                var member = room.getMember(MatrixClientPeg.get().credentials.userId);
+                return member && (member.membership == "join" || member.membership == "invite");
+            })
+        );
     },
 
     makeRoomTiles: function() {
-        var that = this;
+        var RoomTile = sdk.getComponent('molecules.RoomTile');
+        var self = this;
         return this.state.roomList.map(function(room) {
-            var selected = room.roomId == that.props.selectedRoom;
+            var selected = room.roomId == self.props.selectedRoom;
             return (
                 <RoomTile
                     room={room}
                     key={room.roomId}
                     selected={selected}
-                    unread={that.state.activityMap[room.roomId] === 1}
-                    highlight={that.state.activityMap[room.roomId] === 2}
+                    unread={self.state.activityMap[room.roomId] === 1}
+                    highlight={self.state.activityMap[room.roomId] === 2}
                 />
             );
         });
     },
 };
-
diff --git a/src/controllers/organisms/RoomView.js b/src/controllers/organisms/RoomView.js
index 10b375cdad..ea84746bab 100644
--- a/src/controllers/organisms/RoomView.js
+++ b/src/controllers/organisms/RoomView.js
@@ -14,36 +14,36 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-'use strict';
-
 var MatrixClientPeg = require("../../MatrixClientPeg");
 var React = require("react");
 var q = require("q");
 var ContentMessages = require("../../ContentMessages");
+var WhoIsTyping = require("../../WhoIsTyping");
+var Modal = require("../../Modal");
+var sdk = require('../../index');
 
 var dis = require("../../dispatcher");
 
 var PAGINATE_SIZE = 20;
-var INITIAL_SIZE = 100;
-
-var ComponentBroker = require('../../ComponentBroker');
-
-var tileTypes = {
-    'm.room.message': ComponentBroker.get('molecules/MessageTile'),
-    'm.room.member': ComponentBroker.get('molecules/MRoomMemberTile')
-};
+var INITIAL_SIZE = 20;
 
 module.exports = {
     getInitialState: function() {
         return {
             room: this.props.roomId ? MatrixClientPeg.get().getRoom(this.props.roomId) : null,
-            messageCap: INITIAL_SIZE
+            messageCap: INITIAL_SIZE,
+            editingRoomSettings: false,
+            uploadingRoomSettings: false,
+            numUnreadMessages: 0,
+            draggingFile: false,
         }
     },
 
     componentWillMount: function() {
         this.dispatcherRef = dis.register(this.onAction);
         MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
+        MatrixClientPeg.get().on("Room.name", this.onRoomName);
+        MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
         this.atBottom = true;
     },
 
@@ -51,19 +51,40 @@ module.exports = {
         if (this.refs.messageWrapper) {
             var messageWrapper = this.refs.messageWrapper.getDOMNode();
             messageWrapper.removeEventListener('drop', this.onDrop);
+            messageWrapper.removeEventListener('dragover', this.onDragOver);
+            messageWrapper.removeEventListener('dragleave', this.onDragLeaveOrEnd);
+            messageWrapper.removeEventListener('dragend', this.onDragLeaveOrEnd);
         }
         dis.unregister(this.dispatcherRef);
         if (MatrixClientPeg.get()) {
             MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
+            MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
+            MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping);
         }
     },
 
     onAction: function(payload) {
         switch (payload.action) {
+            case 'message_send_failed':
             case 'message_sent':
                 this.setState({
                     room: MatrixClientPeg.get().getRoom(this.props.roomId)
                 });
+                this.forceUpdate();
+                break;
+            case 'notifier_enabled':
+                this.forceUpdate();
+                break;
+            case 'call_state':
+                if (this.props.roomId !== payload.room_id) {
+                    break;
+                }
+                // scroll to bottom
+                var messageWrapper = this.refs.messageWrapper;
+                if (messageWrapper) {
+                    messageWrapper = messageWrapper.getDOMNode();
+                    messageWrapper.scrollTop = messageWrapper.scrollHeight;
+                }
                 break;
         }
     },
@@ -87,13 +108,31 @@ module.exports = {
         // we'll only be showing a spinner.
         if (this.state.joining) return;
         if (room.roomId != this.props.roomId) return;
-        
+
         if (this.refs.messageWrapper) {
             var messageWrapper = this.refs.messageWrapper.getDOMNode();
-            this.atBottom = messageWrapper.scrollHeight - messageWrapper.scrollTop <= messageWrapper.clientHeight;
+            this.atBottom = (
+                messageWrapper.scrollHeight - messageWrapper.scrollTop <=
+                (messageWrapper.clientHeight + 150)
+            );
         }
+
+        var currentUnread = this.state.numUnreadMessages;
+        if (!toStartOfTimeline &&
+                (ev.getSender() !== MatrixClientPeg.get().credentials.userId)) {
+            // update unread count when scrolled up
+            if (this.atBottom) {
+                currentUnread = 0;
+            }
+            else {
+                currentUnread += 1;
+            }
+        }
+
+
         this.setState({
-            room: MatrixClientPeg.get().getRoom(this.props.roomId)
+            room: MatrixClientPeg.get().getRoom(this.props.roomId),
+            numUnreadMessages: currentUnread
         });
 
         if (toStartOfTimeline && !this.state.paginating) {
@@ -101,12 +140,26 @@ module.exports = {
         }
     },
 
+    onRoomName: function(room) {
+        if (room.roomId == this.props.roomId) {
+            this.setState({
+                room: room
+            });
+        }
+    },
+
+    onRoomMemberTyping: function(ev, member) {
+        this.forceUpdate();
+    },
+
     componentDidMount: function() {
         if (this.refs.messageWrapper) {
             var messageWrapper = this.refs.messageWrapper.getDOMNode();
 
             messageWrapper.addEventListener('drop', this.onDrop);
             messageWrapper.addEventListener('dragover', this.onDragOver);
+            messageWrapper.addEventListener('dragleave', this.onDragLeaveOrEnd);
+            messageWrapper.addEventListener('dragend', this.onDragLeaveOrEnd);
 
             messageWrapper.scrollTop = messageWrapper.scrollHeight;
 
@@ -128,10 +181,14 @@ module.exports = {
             }
         } else if (this.atBottom) {
             messageWrapper.scrollTop = messageWrapper.scrollHeight;
+            if (this.state.numUnreadMessages !== 0) {
+                this.setState({numUnreadMessages: 0});
+            }
         }
     },
 
     fillSpace: function() {
+        if (!this.refs.messageWrapper) return;
         var messageWrapper = this.refs.messageWrapper.getDOMNode();
         if (messageWrapper.scrollTop < messageWrapper.clientHeight && this.state.room.oldState.paginationToken) {
             this.setState({paginating: true});
@@ -146,12 +203,12 @@ module.exports = {
                 this.waiting_for_paginate = true;
                 var cap = this.state.messageCap + PAGINATE_SIZE;
                 this.setState({messageCap: cap, paginating: true});
-                var that = this;
+                var self = this;
                 MatrixClientPeg.get().scrollback(this.state.room, PAGINATE_SIZE).finally(function() {
-                    that.waiting_for_paginate = false;
-                    if (that.isMounted()) {
-                        that.setState({
-                            room: MatrixClientPeg.get().getRoom(that.props.roomId)
+                    self.waiting_for_paginate = false;
+                    if (self.isMounted()) {
+                        self.setState({
+                            room: MatrixClientPeg.get().getRoom(self.props.roomId)
                         });
                     }
                     // wait and set paginating to false when the component updates
@@ -164,14 +221,14 @@ module.exports = {
     },
 
     onJoinButtonClicked: function(ev) {
-        var that = this;
+        var self = this;
         MatrixClientPeg.get().joinRoom(this.props.roomId).then(function() {
-            that.setState({
+            self.setState({
                 joining: false,
-                room: MatrixClientPeg.get().getRoom(that.props.roomId)
+                room: MatrixClientPeg.get().getRoom(self.props.roomId)
             });
         }, function(error) {
-            that.setState({
+            self.setState({
                 joining: false,
                 joinError: error
             });
@@ -184,7 +241,11 @@ module.exports = {
     onMessageListScroll: function(ev) {
         if (this.refs.messageWrapper) {
             var messageWrapper = this.refs.messageWrapper.getDOMNode();
+            var wasAtBottom = this.atBottom;
             this.atBottom = messageWrapper.scrollHeight - messageWrapper.scrollTop <= messageWrapper.clientHeight;
+            if (this.atBottom && !wasAtBottom) {
+                this.forceUpdate(); // remove unread msg count
+            }
         }
         if (!this.state.paginating) this.fillSpace();
     },
@@ -198,6 +259,7 @@ module.exports = {
         var items = ev.dataTransfer.items;
         if (items.length == 1) {
             if (items[0].kind == 'file') {
+                this.setState({ draggingFile : true });
                 ev.dataTransfer.dropEffect = 'copy';
             }
         }
@@ -206,33 +268,178 @@ module.exports = {
     onDrop: function(ev) {
         ev.stopPropagation();
         ev.preventDefault();
+        this.setState({ draggingFile : false });
         var files = ev.dataTransfer.files;
-
         if (files.length == 1) {
-            ContentMessages.sendContentToRoom(
-                files[0], this.props.roomId, MatrixClientPeg.get()
-            ).progress(function(ev) {
-                //console.log("Upload: "+ev.loaded+" / "+ev.total);
-            }).done(undefined, function() {
-                // display error message
-            });
+            this.uploadFile(files[0]);
         }
     },
 
+    onDragLeaveOrEnd: function(ev) {
+        ev.stopPropagation();
+        ev.preventDefault();
+        this.setState({ draggingFile : false });
+    },
+
+    uploadFile: function(file) {
+        this.setState({
+            upload: {
+                fileName: file.name,
+                uploadedBytes: 0,
+                totalBytes: file.size
+            }
+        });
+        var self = this;
+        ContentMessages.sendContentToRoom(
+            file, this.props.roomId, MatrixClientPeg.get()
+        ).progress(function(ev) {
+            //console.log("Upload: "+ev.loaded+" / "+ev.total);
+            self.setState({
+                upload: {
+                    fileName: file.name,
+                    uploadedBytes: ev.loaded,
+                    totalBytes: ev.total
+                }
+            });
+        }).finally(function() {
+            self.setState({
+                upload: undefined
+            });
+        }).done(undefined, function() {
+            // display error message
+        });
+    },
+
+    getWhoIsTypingString: function() {
+        return WhoIsTyping.whoIsTypingString(this.state.room);
+    },
+
     getEventTiles: function() {
+        var tileTypes = {
+            'm.room.message': sdk.getComponent('molecules.MessageTile'),
+            'm.room.member' : sdk.getComponent('molecules.EventAsTextTile'),
+            'm.call.invite' : sdk.getComponent('molecules.EventAsTextTile'),
+            'm.call.answer' : sdk.getComponent('molecules.EventAsTextTile'),
+            'm.call.hangup' : sdk.getComponent('molecules.EventAsTextTile'),
+            'm.room.topic'  : sdk.getComponent('molecules.EventAsTextTile'),
+        };
+
         var ret = [];
         var count = 0;
 
         for (var i = this.state.room.timeline.length-1; i >= 0 && count < this.state.messageCap; --i) {
             var mxEv = this.state.room.timeline[i];
             var TileType = tileTypes[mxEv.getType()];
+            var continuation = false;
+            var last = false;
+            if (i == this.state.room.timeline.length - 1) {
+                last = true;
+            }
+            if (i > 0 && count < this.state.messageCap - 1) {
+                if (this.state.room.timeline[i].sender &&
+                    this.state.room.timeline[i - 1].sender &&
+                    (this.state.room.timeline[i].sender.userId ===
+                        this.state.room.timeline[i - 1].sender.userId) &&
+                    (this.state.room.timeline[i].getType() ==
+                        this.state.room.timeline[i - 1].getType())
+                    )
+                {
+                    continuation = true;
+                }
+            }
             if (!TileType) continue;
             ret.unshift(
-                <TileType key={mxEv.getId()} mxEvent={mxEv} />
+                <li key={mxEv.getId()}><TileType mxEvent={mxEv} continuation={continuation} last={last}/></li>
             );
             ++count;
         }
         return ret;
+    },
+
+    uploadNewState: function(new_name, new_topic, new_join_rule, new_history_visibility, new_power_levels) {
+        var old_name = this.state.room.name;
+
+        var old_topic = this.state.room.currentState.getStateEvents('m.room.topic', '');
+        if (old_topic) {
+            old_topic = old_topic.getContent().topic;
+        } else {
+            old_topic = "";
+        }
+
+        var old_join_rule = this.state.room.currentState.getStateEvents('m.room.join_rules', '');
+        if (old_join_rule) {
+            old_join_rule = old_join_rule.getContent().join_rule;
+        } else {
+            old_join_rule = "invite";
+        }
+
+        var old_history_visibility = this.state.room.currentState.getStateEvents('m.room.history_visibility', '');
+        if (old_history_visibility) {
+            old_history_visibility = old_history_visibility.getContent().history_visibility;
+        } else {
+            old_history_visibility = "shared";
+        }
+
+        var deferreds = [];
+
+        if (old_name != new_name && new_name != undefined && new_name) {
+            deferreds.push(
+                MatrixClientPeg.get().setRoomName(this.state.room.roomId, new_name)
+            );
+        }
+
+        if (old_topic != new_topic && new_topic != undefined) {
+            deferreds.push(
+                MatrixClientPeg.get().setRoomTopic(this.state.room.roomId, new_topic)
+            );
+        }
+
+        if (old_join_rule != new_join_rule && new_join_rule != undefined) {
+            deferreds.push(
+                MatrixClientPeg.get().sendStateEvent(
+                    this.state.room.roomId, "m.room.join_rules", {
+                        join_rule: new_join_rule,
+                    }, ""
+                )
+            );
+        }
+
+        if (old_history_visibility != new_history_visibility && new_history_visibility != undefined) {
+            deferreds.push(
+                MatrixClientPeg.get().sendStateEvent(
+                    this.state.room.roomId, "m.room.history_visibility", {
+                        history_visibility: new_history_visibility,
+                    }, ""
+                )
+            );
+        }
+
+        if (new_power_levels) {
+            deferreds.push(
+                MatrixClientPeg.get().sendStateEvent(
+                    this.state.room.roomId, "m.room.power_levels", new_power_levels, ""
+                )
+            );
+        }
+
+        if (deferreds.length) {
+            var self = this;
+            q.all(deferreds).fail(function(err) {
+                var ErrorDialog = sdk.getComponent("organisms.ErrorDialog");
+                Modal.createDialog(ErrorDialog, {
+                    title: "Failed to set state",
+                    description: err.toString()
+                });
+            }).finally(function() {
+                self.setState({
+                    uploadingRoomSettings: false,
+                });
+            });
+        } else {
+            this.setState({
+                editingRoomSettings: false,
+                uploadingRoomSettings: false,
+            });
+        }
     }
 };
-
diff --git a/src/controllers/organisms/UserSettings.js b/src/controllers/organisms/UserSettings.js
new file mode 100644
index 0000000000..60d643c22b
--- /dev/null
+++ b/src/controllers/organisms/UserSettings.js
@@ -0,0 +1,66 @@
+/*
+Copyright 2015 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.
+*/
+
+var MatrixClientPeg = require("../../MatrixClientPeg");
+var q = require('q');
+var version = require('../../../package.json').version;
+
+module.exports = {
+    Phases: {
+        Loading: "loading",
+        Display: "display",
+    },
+
+    getInitialState: function() {
+        return {
+            displayName: null,
+            avatarUrl: null,
+            threePids: [],
+            clientVersion: version,
+            phase: this.Phases.Loading,
+        };
+    },
+
+    changeDisplayname: function(new_displayname) {
+        if (this.state.displayName == new_displayname) return;
+
+        var self = this;
+        return MatrixClientPeg.get().setDisplayName(new_displayname).then(
+            function() { self.setState({displayName: new_displayname}); },
+            function(err) { console.err(err); }
+        );
+    },
+
+    componentWillMount: function() {
+        var self = this;
+        var cli = MatrixClientPeg.get();
+
+        var profile_d = cli.getProfileInfo(cli.credentials.userId);
+        var threepid_d = cli.getThreePids();
+
+        q.all([profile_d, threepid_d]).then(
+            function(resps) {
+                self.setState({
+                    displayName: resps[0].displayname,
+                    avatarUrl: resps[0].avatar_url,
+                    threepids: resps[1].threepids,
+                    phase: self.Phases.Display,
+                });
+            },
+            function(err) { console.err(err); }
+        );
+    }
+}
diff --git a/src/controllers/pages/MatrixChat.js b/src/controllers/pages/MatrixChat.js
index 7c4e35c552..5c40d93ce4 100644
--- a/src/controllers/pages/MatrixChat.js
+++ b/src/controllers/pages/MatrixChat.js
@@ -14,26 +14,40 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-'use strict';
-
-// should be atomised
-var Loader = require("react-loader");
-
 var MatrixClientPeg = require("../../MatrixClientPeg");
 var RoomListSorter = require("../../RoomListSorter");
-
+var Presence = require("../../Presence");
 var dis = require("../../dispatcher");
+var q = require("q");
 
-var ComponentBroker = require('../../ComponentBroker');
-
-var Notifier = ComponentBroker.get('organisms/Notifier');
+var sdk = require('../../index');
+var MatrixTools = require('../../MatrixTools');
 
 module.exports = {
+    PageTypes: {
+        RoomView: "room_view",
+        UserSettings: "user_settings",
+        CreateRoom: "create_room",
+        RoomDirectory: "room_directory",
+    },
+
+    AuxPanel: {
+        RoomSettings: "room_settings",
+    },
+
     getInitialState: function() {
-        return {
+        var s = {
             logged_in: !!(MatrixClientPeg.get() && MatrixClientPeg.get().credentials),
-            ready: false
+            ready: false,
         };
+        if (s.logged_in) {
+            if (MatrixClientPeg.get().getRooms().length) {
+                s.page_type = this.PageTypes.RoomView;
+            } else {
+                s.page_type = this.PageTypes.RoomDirectory;
+            }
+        }
+        return s;
     },
 
     componentDidMount: function() {
@@ -54,6 +68,7 @@ module.exports = {
     componentWillUnmount: function() {
         dis.unregister(this.dispatcherRef);
         document.removeEventListener("keydown", this.onKeyDown);
+        window.removeEventListener("focus", this.onFocus);
     },
 
     componentDidUpdate: function() {
@@ -65,6 +80,7 @@ module.exports = {
 
     onAction: function(payload) {
         var roomIndexDelta = 1;
+        var Notifier = sdk.getComponent('organisms.Notifier');
 
         switch (payload.action) {
             case 'logout':
@@ -76,8 +92,11 @@ module.exports = {
                     window.localStorage.clear();
                 }
                 Notifier.stop();
+                Presence.stop();
+                MatrixClientPeg.get().stopClient();
                 MatrixClientPeg.get().removeAllListeners();
-                MatrixClientPeg.replace(null);
+                MatrixClientPeg.unset();
+                this.notifyNewScreen('');
                 break;
             case 'start_registration':
                 if (this.state.logged_in) return;
@@ -110,8 +129,23 @@ module.exports = {
             case 'view_room':
                 this.focusComposer = true;
                 this.setState({
-                    currentRoom: payload.room_id
+                    currentRoom: payload.room_id,
+                    page_type: this.PageTypes.RoomView,
                 });
+                if (this.sdkReady) {
+                    // if the SDK is not ready yet, remember what room
+                    // we're supposed to be on but don't notify about
+                    // the new screen yet (we won't be showing it yet)
+                    // The normal case where this happens is navigating
+                    // to the room in the URL bar on page load.
+                    var presentedId = payload.room_id;
+                    var room = MatrixClientPeg.get().getRoom(payload.room_id);
+                    if (room) {
+                        var theAlias = MatrixTools.getCanonicalAliasForRoom(room);
+                        if (theAlias) presentedId = theAlias;
+                    }
+                    this.notifyNewScreen('room/'+presentedId);
+                }
                 break;
             case 'view_prev_room':
                 roomIndexDelta = -1;
@@ -127,9 +161,43 @@ module.exports = {
                     }
                 }
                 roomIndex = (roomIndex + roomIndexDelta) % allRooms.length;
+                if (roomIndex < 0) roomIndex = allRooms.length - 1;
+                this.focusComposer = true;
                 this.setState({
                     currentRoom: allRooms[roomIndex].roomId
                 });
+                this.notifyNewScreen('room/'+allRooms[roomIndex].roomId);
+                break;
+            case 'view_indexed_room':
+                var allRooms = RoomListSorter.mostRecentActivityFirst(
+                    MatrixClientPeg.get().getRooms()
+                );
+                var roomIndex = payload.roomIndex;
+                if (allRooms[roomIndex]) {
+                    this.focusComposer = true;
+                    this.setState({
+                        currentRoom: allRooms[roomIndex].roomId
+                    });
+                    this.notifyNewScreen('room/'+allRooms[roomIndex].roomId);
+                }
+                break;
+            case 'view_user_settings':
+                this.setState({
+                    page_type: this.PageTypes.UserSettings,
+                });
+                break;
+            case 'view_create_room':
+                this.setState({
+                    page_type: this.PageTypes.CreateRoom,
+                });
+                break;
+            case 'view_room_directory':
+                this.setState({
+                    page_type: this.PageTypes.RoomDirectory,
+                });
+                break;
+            case 'notifier_enabled':
+                this.forceUpdate();
                 break;
         }
     },
@@ -144,32 +212,83 @@ module.exports = {
     },
 
     startMatrixClient: function() {
+        var Notifier = sdk.getComponent('organisms.Notifier');
         var cli = MatrixClientPeg.get();
-        var that = this;
+        var self = this;
         cli.on('syncComplete', function() {
-            var firstRoom = null;
-            if (cli.getRooms() && cli.getRooms().length) {
-                firstRoom = RoomListSorter.mostRecentActivityFirst(
-                    cli.getRooms()
-                )[0].roomId;
+            self.sdkReady = true;
+
+            var defer = q.defer();
+            if (self.starting_room_alias) {
+                MatrixClientPeg.get().getRoomIdForAlias(self.starting_room_alias).done(function(result) {
+                    self.setState({currentRoom: result.room_id});
+                    defer.resolve();
+                }, function(error) {
+                    defer.resolve();
+                });
+            } else {
+                defer.resolve();
             }
-            that.setState({ready: true, currentRoom: firstRoom});
-            dis.dispatch({action: 'focus_composer'});
+
+            defer.promise.done(function() {
+                if (!self.state.currentRoom) {
+                    var firstRoom = null;
+                    if (cli.getRooms() && cli.getRooms().length) {
+                        firstRoom = RoomListSorter.mostRecentActivityFirst(
+                            cli.getRooms()
+                        )[0].roomId;
+                        self.setState({ready: true, currentRoom: firstRoom, page_type: self.PageTypes.RoomView});
+                    } else {
+                        self.setState({ready: true, page_type: self.PageTypes.RoomDirectory});
+                    }
+                } else {
+                    self.setState({ready: true, page_type: self.PageTypes.RoomView});
+                }
+
+                // we notifyNewScreen now because now the room will actually be displayed,
+                // and (mostly) now we can get the correct alias.
+                var presentedId = self.state.currentRoom;
+                var room = MatrixClientPeg.get().getRoom(self.state.currentRoom);
+                if (room) {
+                    var theAlias = MatrixTools.getCanonicalAliasForRoom(room);
+                    if (theAlias) presentedId = theAlias;
+                }
+                self.notifyNewScreen('room/'+presentedId);
+                dis.dispatch({action: 'focus_composer'});
+            });
+        });
+        cli.on('Call.incoming', function(call) {
+            dis.dispatch({
+                action: 'incoming_call',
+                call: call
+            });
         });
         Notifier.start();
+        Presence.start();
         cli.startClient();
     },
 
     onKeyDown: function(ev) {
         if (ev.altKey) {
+            if (ev.ctrlKey && ev.keyCode > 48 && ev.keyCode < 58) {
+                dis.dispatch({
+                    action: 'view_indexed_room',
+                    roomIndex: ev.keyCode - 49,
+                });
+                ev.stopPropagation();
+                ev.preventDefault();
+                return;
+            }
             switch (ev.keyCode) {
                 case 38:
                     dis.dispatch({action: 'view_prev_room'});
                     ev.stopPropagation();
+                    ev.preventDefault();
                     break;
                 case 40:
                     dis.dispatch({action: 'view_next_room'});
                     ev.stopPropagation();
+                    ev.preventDefault();
                     break;
             }
         }
@@ -190,6 +309,16 @@ module.exports = {
                 action: 'start_login',
                 params: params
             });
+        } else if (screen.indexOf('room/') == 0) {
+            var roomString = screen.split('/')[1];
+            if (roomString[0] == '#') {
+                this.starting_room_alias = roomString;
+            } else {
+                dis.dispatch({
+                    action: 'view_room',
+                    room_id: roomString
+                });
+            }
         }
     },
 
@@ -199,4 +328,3 @@ module.exports = {
         }
     }
 };
-
diff --git a/src/controllers/templates/Login.js b/src/controllers/templates/Login.js
index 51c2543b8d..c14ed28c4a 100644
--- a/src/controllers/templates/Login.js
+++ b/src/controllers/templates/Login.js
@@ -16,14 +16,9 @@ limitations under the License.
 
 'use strict';
 
-var React = require('react');
-
 var MatrixClientPeg = require("../../MatrixClientPeg");
-var Matrix = require("matrix-js-sdk");
 var dis = require("../../dispatcher");
 
-var ComponentBroker = require("../../ComponentBroker");
-
 module.exports = {
     getInitialState: function() {
         return {
@@ -35,57 +30,73 @@ module.exports = {
     },
 
     setStep: function(step) {
-        this.setState({ step: step, errorText: '', busy: false });
+        this.setState({ step: step, busy: false });
     },
 
-    onHSChosen: function(ev) {
-        ev.preventDefault();
+    onHSChosen: function() {
         MatrixClientPeg.replaceUsingUrls(
             this.getHsUrl(),
             this.getIsUrl()
         );
         this.setState({
             hs_url: this.getHsUrl(),
-            is_url: this.getIsUrl()
+            is_url: this.getIsUrl(),
         });
         this.setStep("fetch_stages");
         var cli = MatrixClientPeg.get();
-        this.setState({busy: true});
-        var that = this;
+        this.setState({
+            busy: true,
+            errorText: "",
+        });
+        var self = this;
         cli.loginFlows().done(function(result) {
-            that.setState({
+            self.setState({
                 flows: result.flows,
                 currentStep: 1,
                 totalSteps: result.flows.length+1
             });
-            that.setStep('stage_'+result.flows[0].type);
+            self.setStep('stage_'+result.flows[0].type);
         }, function(error) {
-            that.setStep("choose_hs");
-            that.setState({errorText: 'Unable to contact the given Home Server'});
+            self.setStep("choose_hs");
+            self.setState({errorText: 'Unable to contact the given Home Server'});
         });
     },
 
     onUserPassEntered: function(ev) {
         ev.preventDefault();
-        this.setState({busy: true});
-        var that = this;
+        this.setState({
+            busy: true,
+            errorText: "",
+        });
+        var self = this;
 
         var formVals = this.getFormVals();
 
-        MatrixClientPeg.get().login('m.login.password', {
-            'user': formVals.username,
-            'password': formVals.password
-        }).done(function(data) {
+        var loginParams = {
+            password: formVals.password
+        };
+        if (formVals.username.indexOf('@') > 0) {
+            loginParams.medium = 'email';
+            loginParams.address = formVals.username;
+        } else {
+            loginParams.user = formVals.username;
+        }
+
+        MatrixClientPeg.get().login('m.login.password', loginParams).done(function(data) {
             MatrixClientPeg.replaceUsingAccessToken(
-                that.state.hs_url, that.state.is_url,
+                self.state.hs_url, self.state.is_url,
                 data.user_id, data.access_token
             );
-            if (that.props.onLoggedIn) {
-                that.props.onLoggedIn();
+            if (self.props.onLoggedIn) {
+                self.props.onLoggedIn();
             }
         }, function(error) {
-            that.setStep("stage_m.login.password");
-            that.setState({errorText: 'Login failed.'});
+            self.setStep("stage_m.login.password");
+            if (error.httpStatus == 400 && loginParams.medium) {
+                self.setState({errorText: 'This Home Server does not support login using email address.'});
+            } else {
+                self.setState({errorText: 'Login failed.'});
+            }
         });
     },
 
diff --git a/src/controllers/templates/Register.js b/src/controllers/templates/Register.js
index 29063fb61e..294329e42e 100644
--- a/src/controllers/templates/Register.js
+++ b/src/controllers/templates/Register.js
@@ -14,16 +14,9 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-'use strict';
-
-var React = require('react');
-
 var MatrixClientPeg = require("../../MatrixClientPeg");
-var Matrix = require("matrix-js-sdk");
 var dis = require("../../dispatcher");
 
-var ComponentBroker = require("../../ComponentBroker");
-
 module.exports = {
     FieldErrors: {
         PasswordMismatch: 'PasswordMismatch',
@@ -92,7 +85,7 @@ module.exports = {
         if (this.refs.recaptchaContainer) {
             var scriptTag = document.createElement('script');
             window.mx_on_recaptcha_loaded = this.onCaptchaLoaded;
-            scriptTag.setAttribute('src', "https://www.google.com/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit");
+            scriptTag.setAttribute('src', global.location.protocol+"//www.google.com/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit");
             this.refs.recaptchaContainer.getDOMNode().appendChild(scriptTag);
         }
     },
@@ -196,15 +189,7 @@ module.exports = {
             hs_url: this.getHsUrl(),
             is_url: this.getIsUrl()
         });
-        var cli = MatrixClientPeg.get();
         this.setState({busy: true});
-        var self = this;
-
-        this.savedParams = {
-            email: formVals.email,
-            username: formVals.username,
-            password: formVals.password
-        };
 
         this.tryRegister();
     },
@@ -242,10 +227,14 @@ module.exports = {
                     });
                     self.setStep('stage_m.login.email.identity');
                 }, function(error) {
-                    self.setState({
-                        busy: false,
-                        errorText: 'Unable to contact the given Home Server'
-                    });
+                    self.setStep('initial');
+                    var newState = {busy: false};
+                    if (error.errcode == 'THREEPID_IN_USE') {
+                        self.onBadFields({email: self.FieldErrors.InUse});
+                    } else {
+                        newState.errorText = 'Unable to contact the given Home Server';
+                    }
+                    self.setState(newState);
                 });
                 break;
             case 'm.login.recaptcha':
@@ -326,6 +315,14 @@ module.exports = {
                     });
                 } else if (error.httpStatus == 401) {
                     newState.errorText = "Authorisation failed!";
+                } else if (error.httpStatus >= 400 && error.httpStatus < 500) {
+                    newState.errorText = "Registration failed!";
+                } else if (error.httpStatus >= 500 && error.httpStatus < 600) {
+                    newState.errorText = "Server error during registration!";
+                } else if (error.name == "M_MISSING_PARAM") {
+                    // The HS hasn't remembered the login params from
+                    // the first try when the login email was sent.
+                    newState.errorText = "This home server does not support resuming registration.";
                 }
                 self.setState(newState);
             }
diff --git a/src/dispatcher.js b/src/dispatcher.js
index 3edb9c6947..67d2944cf8 100644
--- a/src/dispatcher.js
+++ b/src/dispatcher.js
@@ -17,21 +17,20 @@ limitations under the License.
 'use strict';
 
 var flux = require("flux");
-var extend = require("./extend");
 
-var MatrixDispatcher = function() {
-    flux.Dispatcher.call(this);
+class MatrixDispatcher extends flux.Dispatcher {
+    dispatch(payload) {
+        if (this.dispatching) {
+            setTimeout(super.dispatch.bind(this, payload), 0);
+        } else {
+            this.dispatching = true;
+            super.dispatch(payload);
+            this.dispatching = false;
+        }
+    }
 };
 
-extend(MatrixDispatcher.prototype, flux.Dispatcher.prototype);
-MatrixDispatcher.prototype.dispatch = function(payload) {
-    if (this.dispatching) {
-        setTimeout(flux.Dispatcher.prototype.dispatch.bind(this, payload), 0);
-    } else {
-        this.dispatching = true;
-        flux.Dispatcher.prototype.dispatch.call(this, payload);
-        this.dispatching = false;
-    }
+if (global.mxDispatcher === undefined) {
+    global.mxDispatcher = new MatrixDispatcher();
 }
-
-module.exports = new MatrixDispatcher();
+module.exports = global.mxDispatcher;
diff --git a/src/encryption.js b/src/encryption.js
new file mode 100644
index 0000000000..954bc030db
--- /dev/null
+++ b/src/encryption.js
@@ -0,0 +1,38 @@
+/*
+Copyright 2015 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.
+*/
+
+function enableEncyption(client, roomId, members) {
+    members = members.slice(0);
+    members.push(client.credentials.userId);
+    // TODO: Check the keys actually match what keys the user has.
+    // TODO: Don't redownload keys each time.
+    return client.downloadKeys(members, "forceDownload").then(function(res) {
+        return client.setRoomEncryption(roomId, {
+            algorithm: "m.olm.v1.curve25519-aes-sha2",
+            members: members,
+        });
+    })
+}
+
+function disableEncryption(client, roomId) {
+    return client.disableRoomEncryption(roomId);
+}
+
+
+module.exports = {
+    enableEncryption: enableEncyption,
+    disableEncryption: disableEncryption,
+}
diff --git a/src/index.js b/src/index.js
index febf8d0dc8..5412c051d6 100644
--- a/src/index.js
+++ b/src/index.js
@@ -14,6 +14,22 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-'use strict';
+var Skinner = require('./Skinner');
+var Modulator = require('./Modulator');
+
+module.exports.loadSkin = function(skinObject) {
+    Skinner.load(skinObject);
+};
+
+module.exports.loadModule = function(moduleObject) {
+    Modulator.loadModule(moduleObject);
+};
+
+module.exports.resetSkin = function() {
+    Skinner.reset();
+};
+
+module.exports.getComponent = function(componentName) {
+    return Skinner.getComponent(componentName);
+};
 
-module.exports.MatrixChat = require("../skins/base/views/pages/MatrixChat");
diff --git a/src/linkify-matrix.js b/src/linkify-matrix.js
index 273fe123a9..e92a3efc91 100644
--- a/src/linkify-matrix.js
+++ b/src/linkify-matrix.js
@@ -14,14 +14,9 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-'use strict';
-
-var extend = require('./extend');
-
 function matrixLinkify(linkify) {
     // Text tokens
     var TT = linkify.scanner.TOKENS;
-    var TextToken = TT.Base;
     // Multi tokens
     var MT = linkify.parser.TOKENS;
     var MultiToken = MT.Base;