diff --git a/src/CallHandler.js b/src/CallHandler.js
new file mode 100644
index 0000000000000000000000000000000000000000..b56137dee9cea21887ab6eff3922650411850ba1
--- /dev/null
+++ b/src/CallHandler.js
@@ -0,0 +1,230 @@
+/*
+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 calls = {
+    //room_id: MatrixCall
+};
+
+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(call, 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
+    });
+}
+
+dis.register(function(payload) {
+    switch (payload.action) {
+        case 'place_call':
+            if (calls[payload.room_id]) {
+                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 !== 2) {
+                var text = members.length === 1 ? "yourself." : "more than 2 people.";
+                var ErrorDialog = sdk.getComponent("organisms.ErrorDialog");
+                Modal.createDialog(ErrorDialog, {
+                    description: "You cannot place a call with " + text
+                });
+                console.error(
+                    "Fail: There are %s joined members in this room, not 2.",
+                    room.getJoinedMembers().length
+                );
+                return;
+            }
+            console.log("Place %s call in %s", payload.type, payload.room_id);
+            var call = Matrix.createNewMatrixCall(
+                MatrixClientPeg.get(), payload.room_id
+            );
+            _setCallListeners(call);
+            _setCallState(call, call.roomId, "ringback");
+            if (payload.type === 'voice') {
+                call.placeVoiceCall();
+            }
+            else if (payload.type === 'video') {
+                call.placeVideoCall(
+                    payload.remote_element,
+                    payload.local_element
+                );
+            }
+            else {
+                console.error("Unknown call type: %s", payload.type);
+            }
+            
+            break;
+        case 'incoming_call':
+            if (calls[payload.call.roomId]) {
+                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;
+    }
+});
+
+module.exports = {
+    getCall: function(roomId) {
+        return calls[roomId] || null;
+    }
+};
diff --git a/src/WhoIsTyping.js b/src/WhoIsTyping.js
new file mode 100644
index 0000000000000000000000000000000000000000..4fb53990270054598e0b3c7dbd7279085d087955
--- /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/EnableNotificationsButton.js b/src/controllers/atoms/EnableNotificationsButton.js
index e116cd9671145264943ed8c03decbf1679d09614..3c399484e880252bc4c32d6d45833ba75fe1d4ba 100644
--- a/src/controllers/atoms/EnableNotificationsButton.js
+++ b/src/controllers/atoms/EnableNotificationsButton.js
@@ -16,7 +16,6 @@ limitations under the License.
 
 'use strict';
 var sdk = require('../../index');
-var Notifier = sdk.getComponent('organisms/Notifier');
 var dis = require("../../dispatcher");
 
 module.exports = {
@@ -37,10 +36,12 @@ module.exports = {
     },
 
     enabled: function() {
+        var Notifier = sdk.getComponent('organisms.Notifier');
         return Notifier.isEnabled();
     },
 
     onClick: function() {
+        var Notifier = sdk.getComponent('organisms.Notifier');
         var self = this;
         if (!Notifier.supportsDesktopNotifications()) {
             return;
diff --git a/src/controllers/atoms/MemberAvatar.js b/src/controllers/atoms/MemberAvatar.js
new file mode 100644
index 0000000000000000000000000000000000000000..6ad08911b9936b3a1d666b5172afb5e2af9c3a3a
--- /dev/null
+++ b/src/controllers/atoms/MemberAvatar.js
@@ -0,0 +1,62 @@
+/*
+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) {
+        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() {
+        return {
+            imageUrl: MatrixClientPeg.get().getAvatarUrlForMember(
+                this.props.member,
+                this.props.width,
+                this.props.height,
+                this.props.resizeMethod
+            )
+        };
+    }
+};
diff --git a/src/controllers/atoms/voip/VideoFeed.js b/src/controllers/atoms/voip/VideoFeed.js
new file mode 100644
index 0000000000000000000000000000000000000000..3d34134daa757ce45b661451633d3cd370f517dc
--- /dev/null
+++ b/src/controllers/atoms/voip/VideoFeed.js
@@ -0,0 +1,19 @@
+/*
+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 = {
+};
+
diff --git a/src/controllers/molecules/EventAsTextTile.js b/src/controllers/molecules/EventAsTextTile.js
new file mode 100644
index 0000000000000000000000000000000000000000..3d34134daa757ce45b661451633d3cd370f517dc
--- /dev/null
+++ b/src/controllers/molecules/EventAsTextTile.js
@@ -0,0 +1,19 @@
+/*
+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 = {
+};
+
diff --git a/src/controllers/molecules/MemberTile.js b/src/controllers/molecules/MemberTile.js
index ad3ba5ea0e63d1374dac52b3139bca9735857c24..a914fc6279147c41958261281655e01e9b33bd59 100644
--- a/src/controllers/molecules/MemberTile.js
+++ b/src/controllers/molecules/MemberTile.js
@@ -19,7 +19,6 @@ limitations under the License.
 var dis = require("../../dispatcher");
 var Modal = require("../../Modal");
 var sdk = require('../../index.js');
-var QuestionDialog = ComponentBroker.get("organisms/QuestionDialog");
 var Loader = require("react-loader");
 
 var MatrixClientPeg = require("../../MatrixClientPeg");
@@ -33,6 +32,8 @@ module.exports = {
     },
 
     onLeaveClick: function() {
+        var QuestionDialog = sdk.getComponent("organisms.QuestionDialog");
+
         var roomId = this.props.member.roomId;
         Modal.createDialog(QuestionDialog, {
             title: "Leave room",
diff --git a/src/controllers/molecules/RoomSettings.js b/src/controllers/molecules/RoomSettings.js
new file mode 100644
index 0000000000000000000000000000000000000000..3c0682d09a0146f5e61c7a6336bdca74f0057885
--- /dev/null
+++ b/src/controllers/molecules/RoomSettings.js
@@ -0,0 +1,29 @@
+/*
+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: {
+        room: React.PropTypes.object.isRequired,
+    },
+
+    getInitialState: function() {
+        return {
+            power_levels_changed: false
+        };
+    }
+};
diff --git a/src/controllers/molecules/voip/CallView.js b/src/controllers/molecules/voip/CallView.js
new file mode 100644
index 0000000000000000000000000000000000000000..c8fab1fba4e085ad061d61b5817e2852619eb3c6
--- /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 0000000000000000000000000000000000000000..9ecced56c57fc939041c4ba9ef8bdee4a4a414a4
--- /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/src/controllers/molecules/voip/VideoView.js b/src/controllers/molecules/voip/VideoView.js
new file mode 100644
index 0000000000000000000000000000000000000000..3d34134daa757ce45b661451633d3cd370f517dc
--- /dev/null
+++ b/src/controllers/molecules/voip/VideoView.js
@@ -0,0 +1,19 @@
+/*
+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 = {
+};
+
diff --git a/src/controllers/organisms/RoomView.js b/src/controllers/organisms/RoomView.js
index a40c1e633a791994c121d97130b97cb628c5c166..6b3955d41e6407fa1193d502bf9c6ecfe99519d2 100644
--- a/src/controllers/organisms/RoomView.js
+++ b/src/controllers/organisms/RoomView.js
@@ -14,31 +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 sdk = require('../../index');
-
 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;
     },
 
@@ -46,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;
         }
     },
@@ -82,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) {
@@ -96,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;
 
@@ -123,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});
@@ -141,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
@@ -159,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
             });
@@ -179,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();
     },
@@ -193,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';
             }
         }
@@ -201,23 +268,60 @@ 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.MRoomMemberTile')
+            '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 = [];
@@ -226,13 +330,119 @@ module.exports = {
         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;
+                }
+
+                var ts0 = this.state.room.timeline[i - 1].getTs();
+                var ts1 = this.state.room.timeline[i].getTs();
+            }
             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,
+            });
+        }
     }
 };
-