summary refs log tree commit diff
path: root/syweb/webclient/components
diff options
context:
space:
mode:
Diffstat (limited to 'syweb/webclient/components')
-rw-r--r--syweb/webclient/components/fileInput/file-input-directive.js56
-rw-r--r--syweb/webclient/components/fileUpload/file-upload-service.js180
-rw-r--r--syweb/webclient/components/matrix/event-handler-service.js603
-rw-r--r--syweb/webclient/components/matrix/event-stream-service.js160
-rw-r--r--syweb/webclient/components/matrix/matrix-call.js607
-rw-r--r--syweb/webclient/components/matrix/matrix-filter.js108
-rw-r--r--syweb/webclient/components/matrix/matrix-phone-service.js155
-rw-r--r--syweb/webclient/components/matrix/matrix-service.js759
-rw-r--r--syweb/webclient/components/matrix/model-service.js170
-rw-r--r--syweb/webclient/components/matrix/notification-service.js104
-rw-r--r--syweb/webclient/components/matrix/presence-service.js113
-rw-r--r--syweb/webclient/components/utilities/utilities-service.js151
12 files changed, 3166 insertions, 0 deletions
diff --git a/syweb/webclient/components/fileInput/file-input-directive.js b/syweb/webclient/components/fileInput/file-input-directive.js
new file mode 100644
index 0000000000..9c849a140f
--- /dev/null
+++ b/syweb/webclient/components/fileInput/file-input-directive.js
@@ -0,0 +1,56 @@
+/*
+ Copyright 2014 OpenMarket Ltd
+ 
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+ 
+ http://www.apache.org/licenses/LICENSE-2.0
+ 
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+'use strict';
+
+/*
+ * Transform an element into an image file input button.
+ * Watch to the passed variable change. It will contain the selected HTML5 file object.
+ */
+angular.module('mFileInput', [])
+.directive('mFileInput', function() {
+    return {
+        restrict: 'A',
+        transclude: 'true',
+        template: '<div ng-transclude></div><input ng-hide="true" type="file" accept="image/*"/>',
+        scope: {
+            selectedFile: '=mFileInput'
+        },
+
+        link: function(scope, element, attrs, ctrl) {
+            
+            // Check if HTML5 file selection is supported
+            if (window.FileList) {
+                element.bind("click", function() {
+                    element.find("input")[0].click();
+                    element.find("input").bind("change", function(e) {
+                        scope.selectedFile = this.files[0];
+                        scope.$apply();
+                    });
+                });
+            }
+            else {
+                setTimeout(function() {
+                    element.attr("disabled", true);
+                    element.attr("title", "The app uses the HTML5 File API to send files. Your browser does not support it.");
+                }, 1);
+            }
+
+            // Change the mouse icon on mouseover on this element
+            element.css("cursor", "pointer");
+      }
+    };
+});
\ No newline at end of file
diff --git a/syweb/webclient/components/fileUpload/file-upload-service.js b/syweb/webclient/components/fileUpload/file-upload-service.js
new file mode 100644
index 0000000000..b544e29509
--- /dev/null
+++ b/syweb/webclient/components/fileUpload/file-upload-service.js
@@ -0,0 +1,180 @@
+/*
+ Copyright 2014 OpenMarket Ltd
+ 
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+ 
+ http://www.apache.org/licenses/LICENSE-2.0
+ 
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+'use strict';
+
+// TODO determine if this is really required as a separate service to matrixService.
+/*
+ * Upload an HTML5 file to a server
+ */
+angular.module('mFileUpload', ['matrixService', 'mUtilities'])
+.service('mFileUpload', ['$q', 'matrixService', 'mUtilities', function ($q, matrixService, mUtilities) {
+        
+    /*
+     * Upload an HTML5 file or blob to a server and returned a promise
+     * that will provide the URL of the uploaded file.
+     * @param {File|Blob} file the file data to send
+     */
+    this.uploadFile = function(file) {
+        var deferred = $q.defer();
+        console.log("Uploading " + file.name + "... to /_matrix/content");
+        matrixService.uploadContent(file).then(
+            function(response) {
+                var content_url = response.data.content_token;
+                console.log("   -> Successfully uploaded! Available at " + content_url);
+                deferred.resolve(content_url);
+            },
+            function(error) {
+                console.log("   -> Failed to upload "  + file.name);
+                deferred.reject(error);
+            }
+        );
+        
+        return deferred.promise;
+    };
+    
+    /*
+     * Upload an image file plus generate a thumbnail of it and upload it so that
+     * we will have all information to fulfill an image message request data.
+     * @param {File} imageFile the imageFile to send
+     * @param {Integer} thumbnailSize the max side size of the thumbnail to create
+     * @returns {promise} A promise that will be resolved by a image message object
+     *   ready to be send with the Matrix API
+     */
+    this.uploadImageAndThumbnail = function(imageFile, thumbnailSize) {
+        var self = this;
+        var deferred = $q.defer();
+
+        console.log("uploadImageAndThumbnail " + imageFile.name + " - thumbnailSize: " + thumbnailSize);
+
+        // The message structure that will be returned in the promise
+        var imageMessage = {
+            msgtype: "m.image",
+            url: undefined,
+            body: "Image",
+            info: {
+                size: undefined,
+                w: undefined,
+                h: undefined,
+                mimetype: undefined
+            },
+            thumbnail_url: undefined,
+            thumbnail_info: {
+                size: undefined,
+                w: undefined,
+                h: undefined,
+                mimetype: undefined
+            }
+        };
+
+        // First, get the image size
+        mUtilities.getImageSize(imageFile).then(
+            function(size) {
+                console.log("image size: " + JSON.stringify(size));
+
+                // The final operation: send imageFile
+                var uploadImage = function() {
+                    self.uploadFile(imageFile).then(
+                        function(url) {
+                            // Update message metadata
+                            imageMessage.url = url;
+                            imageMessage.info = {
+                                size: imageFile.size,
+                                w: size.width,
+                                h: size.height,
+                                mimetype: imageFile.type
+                            };
+
+                            // If there is no thumbnail (because the original image is smaller than thumbnailSize),
+                            // reuse the original image info for thumbnail data
+                            if (!imageMessage.thumbnail_url) {
+                                imageMessage.thumbnail_url = imageMessage.url;
+                                imageMessage.thumbnail_info = imageMessage.info;
+                            }
+
+                            // We are done
+                            deferred.resolve(imageMessage);
+                        },
+                        function(error) {
+                            console.log("      -> Can't upload image");
+                            deferred.reject(error); 
+                        }
+                    );
+                };
+
+                // Create a thumbnail if the image size exceeds thumbnailSize
+                if (Math.max(size.width, size.height) > thumbnailSize) {
+                    console.log("    Creating thumbnail...");
+                    mUtilities.resizeImage(imageFile, thumbnailSize).then(
+                        function(thumbnailBlob) {
+
+                            // Get its size
+                            mUtilities.getImageSize(thumbnailBlob).then(
+                                function(thumbnailSize) {
+                                    console.log("      -> Thumbnail size: " + JSON.stringify(thumbnailSize));
+
+                                    // Upload it to the server
+                                    self.uploadFile(thumbnailBlob).then(
+                                        function(thumbnailUrl) {
+
+                                            // Update image message data
+                                            imageMessage.thumbnail_url = thumbnailUrl;
+                                            imageMessage.thumbnail_info = {
+                                                size: thumbnailBlob.size,
+                                                w: thumbnailSize.width,
+                                                h: thumbnailSize.height,
+                                                mimetype: thumbnailBlob.type
+                                            };
+
+                                            // Then, upload the original image
+                                            uploadImage();
+                                        },
+                                        function(error) {
+                                            console.log("      -> Can't upload thumbnail");
+                                            deferred.reject(error); 
+                                        }
+                                    );
+                                },
+                                function(error) {
+                                    console.log("      -> Failed to get thumbnail size");
+                                    deferred.reject(error); 
+                                }
+                            );
+
+                        },
+                        function(error) {
+                            console.log("      -> Failed to create thumbnail: " + error);
+                            deferred.reject(error); 
+                        }
+                    );
+                }
+                else {
+                    // No need of thumbnail
+                    console.log("   Thumbnail is not required");
+                    uploadImage();
+                }
+
+            },
+            function(error) {
+                console.log("   -> Failed to get image size");
+                deferred.reject(error); 
+            }
+        );
+
+        return deferred.promise;
+    };
+
+}]);
diff --git a/syweb/webclient/components/matrix/event-handler-service.js b/syweb/webclient/components/matrix/event-handler-service.js
new file mode 100644
index 0000000000..027c80a1b6
--- /dev/null
+++ b/syweb/webclient/components/matrix/event-handler-service.js
@@ -0,0 +1,603 @@
+/*
+Copyright 2014 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+/*
+This service handles what should happen when you get an event. This service does
+not care where the event came from, it only needs enough context to be able to 
+process them. Events may be coming from the event stream, the REST API (via 
+direct GETs or via a pagination stream API), etc.
+
+Typically, this service will store events and broadcast them to any listeners
+(e.g. controllers) via $broadcast. 
+*/
+angular.module('eventHandlerService', [])
+.factory('eventHandlerService', ['matrixService', '$rootScope', '$q', '$timeout', 'mPresence', 'notificationService', 'modelService',
+function(matrixService, $rootScope, $q, $timeout, mPresence, notificationService, modelService) {
+    var ROOM_CREATE_EVENT = "ROOM_CREATE_EVENT";
+    var MSG_EVENT = "MSG_EVENT";
+    var MEMBER_EVENT = "MEMBER_EVENT";
+    var PRESENCE_EVENT = "PRESENCE_EVENT";
+    var POWERLEVEL_EVENT = "POWERLEVEL_EVENT";
+    var CALL_EVENT = "CALL_EVENT";
+    var NAME_EVENT = "NAME_EVENT";
+    var TOPIC_EVENT = "TOPIC_EVENT";
+    var RESET_EVENT = "RESET_EVENT";    // eventHandlerService has been resetted
+
+    // used for dedupping events - could be expanded in future...
+    // FIXME: means that we leak memory over time (along with lots of the rest
+    // of the app, given we never try to reap memory yet)
+    var eventMap = {};
+
+    // TODO: Remove this and replace with modelService.User objects.
+    $rootScope.presence = {};
+
+    var initialSyncDeferred;
+
+    var reset = function() {
+        initialSyncDeferred = $q.defer();
+        
+        $rootScope.presence = {};
+
+        eventMap = {};
+    };
+    reset();
+
+    var resetRoomMessages = function(room_id) {
+        var room = modelService.getRoom(room_id);
+        room.events = [];
+    };
+    
+    // Generic method to handle events data
+    var handleRoomStateEvent = function(event, isLiveEvent, addToRoomMessages) {
+        var room = modelService.getRoom(event.room_id);
+        if (addToRoomMessages) {
+            // some state events are displayed as messages, so add them.
+            room.addMessageEvent(event, !isLiveEvent);
+        }
+        
+        if (isLiveEvent) {
+            // update the current room state with the latest state
+            room.current_room_state.storeStateEvent(event);
+        }
+        else {
+            var eventTs = event.origin_server_ts;
+            var storedEvent = room.current_room_state.getStateEvent(event.type, event.state_key);
+            if (storedEvent) {
+                if (storedEvent.origin_server_ts < eventTs) {
+                    // the incoming event is newer, use it.
+                    room.current_room_state.storeStateEvent(event);
+                }
+            }
+        }
+        // TODO: handle old_room_state
+    };
+    
+    var handleRoomCreate = function(event, isLiveEvent) {
+        $rootScope.$broadcast(ROOM_CREATE_EVENT, event, isLiveEvent);
+    };
+
+    var handleRoomAliases = function(event, isLiveEvent) {
+        matrixService.createRoomIdToAliasMapping(event.room_id, event.content.aliases[0]);
+    };
+    
+    var displayNotification = function(event) {
+        if (window.Notification && event.user_id != matrixService.config().user_id) {
+            var shouldBing = notificationService.containsBingWord(
+                matrixService.config().user_id,
+                matrixService.config().display_name,
+                matrixService.config().bingWords,
+                event.content.body
+            );
+
+            // Ideally we would notify only when the window is hidden (i.e. document.hidden = true).
+            //
+            // However, Chrome on Linux and OSX currently returns document.hidden = false unless the window is
+            // explicitly showing a different tab.  So we need another metric to determine hiddenness - we
+            // simply use idle time.  If the user has been idle enough that their presence goes to idle, then
+            // we also display notifs when things happen.
+            //
+            // This is far far better than notifying whenever anything happens anyway, otherwise you get spammed
+            // to death with notifications when the window is in the foreground, which is horrible UX (especially
+            // if you have not defined any bingers and so get notified for everything).
+            var isIdle = (document.hidden || matrixService.presence.unavailable === mPresence.getState());
+            
+            // We need a way to let people get notifications for everything, if they so desire.  The way to do this
+            // is to specify zero bingwords.
+            var bingWords = matrixService.config().bingWords;
+            if (bingWords === undefined || bingWords.length === 0) {
+                shouldBing = true;
+            }
+            
+            if (shouldBing && isIdle) {
+                console.log("Displaying notification for "+JSON.stringify(event));
+                var member = modelService.getMember(event.room_id, event.user_id);
+                var displayname = getUserDisplayName(event.room_id, event.user_id);
+
+                var message = event.content.body;
+                if (event.content.msgtype === "m.emote") {
+                    message = "* " + displayname + " " + message;
+                }
+                else if (event.content.msgtype === "m.image") {
+                    message = displayname + " sent an image.";
+                }
+
+                var roomTitle = matrixService.getRoomIdToAliasMapping(event.room_id);
+                var theRoom = modelService.getRoom(event.room_id);
+                if (!roomTitle && theRoom.current_room_state.state("m.room.name") && theRoom.current_room_state.state("m.room.name").content) {
+                    roomTitle = theRoom.current_room_state.state("m.room.name").content.name;
+                }
+
+                if (!roomTitle) {
+                    roomTitle = event.room_id;
+                }
+                
+                notificationService.showNotification(
+                    displayname + " (" + roomTitle + ")",
+                    message,
+                    member ? member.avatar_url : undefined,
+                    function() {
+                        console.log("notification.onclick() room=" + event.room_id);
+                        $rootScope.goToPage('room/' + event.room_id); 
+                    }
+                );
+            }
+        }
+    };
+
+    var handleMessage = function(event, isLiveEvent) {
+        // Check for empty event content
+        var hasContent = false;
+        for (var prop in event.content) {
+            hasContent = true;
+            break;
+        }
+        if (!hasContent) {
+            // empty json object is a redacted event, so ignore.
+            return;
+        }
+        
+        // =======================
+        
+        var room = modelService.getRoom(event.room_id);
+        
+        if (event.user_id !== matrixService.config().user_id) {
+            room.addMessageEvent(event, !isLiveEvent);
+            displayNotification(event);
+        }
+        else {
+            // we may have locally echoed this, so we should replace the event
+            // instead of just adding.
+            room.addOrReplaceMessageEvent(event, !isLiveEvent);
+        }
+        
+        // TODO send delivery receipt if isLiveEvent
+        
+        $rootScope.$broadcast(MSG_EVENT, event, isLiveEvent);
+    };
+    
+    var handleRoomMember = function(event, isLiveEvent, isStateEvent) {
+        var room = modelService.getRoom(event.room_id);
+        
+        // did something change?
+        var memberChanges = undefined;
+        if (!isStateEvent) {
+            // could be a membership change, display name change, etc.
+            // Find out which one.
+            if ((event.prev_content === undefined && event.content.membership) || (event.prev_content && (event.prev_content.membership !== event.content.membership))) {
+                memberChanges = "membership";
+            }
+            else if (event.prev_content && (event.prev_content.displayname !== event.content.displayname)) {
+                memberChanges = "displayname";
+            }
+            // mark the key which changed
+            event.changedKey = memberChanges;
+        }
+        
+        
+        // modify state before adding the message so it points to the right thing.
+        // The events are copied to avoid referencing the same event when adding
+        // the message (circular json structures)
+        if (isStateEvent || isLiveEvent) {
+            var newEvent = angular.copy(event);
+            newEvent.cnt = event.content;
+            room.current_room_state.storeStateEvent(newEvent);
+        }
+        else if (!isLiveEvent) {
+            // mutate the old room state
+            var oldEvent = angular.copy(event);
+            oldEvent.cnt = event.content;
+            if (event.prev_content) {
+                // the m.room.member event we are handling is the NEW event. When
+                // we keep going back in time, we want the PREVIOUS value for displaying
+                // names/etc, hence the clobber here.
+                oldEvent.cnt = event.prev_content;
+            }
+            
+            if (event.changedKey === "membership" && event.content.membership === "join") {
+                // join has a prev_content but it doesn't contain all the info unlike the join, so use that.
+                oldEvent.cnt = event.content;
+            }
+            
+            room.old_room_state.storeStateEvent(oldEvent);
+        }
+        
+        // If there was a change we want to display, dump it in the message
+        // list. This has to be done after room state is updated.
+        if (memberChanges) {
+            room.addMessageEvent(event, !isLiveEvent);
+        }
+        
+        
+        
+        $rootScope.$broadcast(MEMBER_EVENT, event, isLiveEvent, isStateEvent);
+    };
+    
+    var handlePresence = function(event, isLiveEvent) {
+        $rootScope.presence[event.content.user_id] = event;
+        $rootScope.$broadcast(PRESENCE_EVENT, event, isLiveEvent);
+    };
+    
+    var handlePowerLevels = function(event, isLiveEvent) {
+        handleRoomStateEvent(event, isLiveEvent);
+        $rootScope.$broadcast(POWERLEVEL_EVENT, event, isLiveEvent);   
+    };
+
+    var handleRoomName = function(event, isLiveEvent, isStateEvent) {
+        console.log("handleRoomName room_id: " + event.room_id + " - isLiveEvent: " + isLiveEvent + " - name: " + event.content.name);
+        handleRoomStateEvent(event, isLiveEvent, !isStateEvent);
+        $rootScope.$broadcast(NAME_EVENT, event, isLiveEvent);
+    };
+    
+
+    var handleRoomTopic = function(event, isLiveEvent, isStateEvent) {
+        console.log("handleRoomTopic room_id: " + event.room_id + " - isLiveEvent: " + isLiveEvent + " - topic: " + event.content.topic);
+        handleRoomStateEvent(event, isLiveEvent, !isStateEvent);
+        $rootScope.$broadcast(TOPIC_EVENT, event, isLiveEvent);
+    };
+
+    var handleCallEvent = function(event, isLiveEvent) {
+        $rootScope.$broadcast(CALL_EVENT, event, isLiveEvent);
+        if (event.type === 'm.call.invite') {
+            var room = modelService.getRoom(event.room_id);
+            room.addMessageEvent(event, !isLiveEvent);
+        }
+    };
+
+    var handleRedaction = function(event, isLiveEvent) {
+        if (!isLiveEvent) {
+            // we have nothing to remove, so just ignore it.
+            console.log("Received redacted event: "+JSON.stringify(event));
+            return;
+        }
+
+        // we need to remove something possibly: do we know the redacted
+        // event ID?
+        if (eventMap[event.redacts]) {
+            var room = modelService.getRoom(event.room_id);
+            // remove event from list of messages in this room.
+            var eventList = room.events;
+            for (var i=0; i<eventList.length; i++) {
+                if (eventList[i].event_id === event.redacts) {
+                    console.log("Removing event " + event.redacts);
+                    eventList.splice(i, 1);
+                    break;
+                }
+            }
+
+            console.log("Redacted an event.");
+        }
+    }
+
+    /**
+     * Return the display name of an user acccording to data already downloaded
+     * @param {String} room_id the room id
+     * @param {String} user_id the id of the user
+     * @returns {String} the user displayname or user_id if not available
+     */
+    var getUserDisplayName = function(room_id, user_id) {
+        var displayName;
+
+        // Get the user display name from the member list of the room
+        var member = modelService.getMember(room_id, user_id);
+        if (member && member.content.displayname) { // Do not consider null displayname
+            displayName = member.content.displayname;
+
+            // Disambiguate users who have the same displayname in the room
+            if (user_id !== matrixService.config().user_id) {
+                var room = modelService.getRoom(room_id);
+
+                for (var member_id in room.current_room_state.members) {
+                    if (room.current_room_state.members.hasOwnProperty(member_id) && member_id !== user_id) {
+                        var member2 = room.current_room_state.members[member_id];
+                        if (member2.content.displayname && member2.content.displayname === displayName) {
+                            displayName = displayName + " (" + user_id + ")";
+                            break;
+                        }
+                    }
+                }
+            }
+        }
+
+        // The user may not have joined the room yet. So try to resolve display name from presence data
+        // Note: This data may not be available
+        if (undefined === displayName && user_id in $rootScope.presence) {
+            displayName = $rootScope.presence[user_id].content.displayname;
+        }
+
+        if (undefined === displayName) {
+            // By default, use the user ID
+            displayName = user_id;
+        }
+        return displayName;
+    };
+
+    return {
+        ROOM_CREATE_EVENT: ROOM_CREATE_EVENT,
+        MSG_EVENT: MSG_EVENT,
+        MEMBER_EVENT: MEMBER_EVENT,
+        PRESENCE_EVENT: PRESENCE_EVENT,
+        POWERLEVEL_EVENT: POWERLEVEL_EVENT,
+        CALL_EVENT: CALL_EVENT,
+        NAME_EVENT: NAME_EVENT,
+        TOPIC_EVENT: TOPIC_EVENT,
+        RESET_EVENT: RESET_EVENT,
+        
+        reset: function() {
+            reset();
+            $rootScope.$broadcast(RESET_EVENT);
+        },
+    
+        handleEvent: function(event, isLiveEvent, isStateEvent) {
+
+            // Avoid duplicated events
+            // Needed for rooms where initialSync has not been done. 
+            // In this case, we do not know where to start pagination. So, it starts from the END
+            // and we can have the same event (ex: joined, invitation) coming from the pagination
+            // AND from the event stream.
+            // FIXME: This workaround should be no more required when /initialSync on a particular room
+            // will be available (as opposite to the global /initialSync done at startup)
+            if (!isStateEvent) {    // Do not consider state events
+                if (event.event_id && eventMap[event.event_id]) {
+                    console.log("discarding duplicate event: " + JSON.stringify(event, undefined, 4));
+                    return;
+                }
+                else {
+                    eventMap[event.event_id] = 1;
+                }
+            }
+
+            if (event.type.indexOf('m.call.') === 0) {
+                handleCallEvent(event, isLiveEvent);
+            }
+            else {            
+                switch(event.type) {
+                    case "m.room.create":
+                        handleRoomCreate(event, isLiveEvent);
+                        break;
+                    case "m.room.aliases":
+                        handleRoomAliases(event, isLiveEvent);
+                        break;
+                    case "m.room.message":
+                        handleMessage(event, isLiveEvent);
+                        break;
+                    case "m.room.member":
+                        handleRoomMember(event, isLiveEvent, isStateEvent);
+                        break;
+                    case "m.presence":
+                        handlePresence(event, isLiveEvent);
+                        break;
+                    case 'm.room.ops_levels':
+                    case 'm.room.send_event_level':
+                    case 'm.room.add_state_level':
+                    case 'm.room.join_rules':
+                    case 'm.room.power_levels':
+                        handlePowerLevels(event, isLiveEvent);
+                        break;
+                    case 'm.room.name':
+                        handleRoomName(event, isLiveEvent, isStateEvent);
+                        break;
+                    case 'm.room.topic':
+                        handleRoomTopic(event, isLiveEvent, isStateEvent);
+                        break;
+                    case 'm.room.redaction':
+                        handleRedaction(event, isLiveEvent);
+                        break;
+                    default:
+                        // if it is a state event, then just add it in so it
+                        // displays on the Room Info screen.
+                        if (typeof(event.state_key) === "string") { // incls. 0-len strings
+                            if (event.room_id) {
+                                handleRoomStateEvent(event, isLiveEvent, false);
+                            }
+                        }
+                        console.log("Unable to handle event type " + event.type);
+                        // console.log(JSON.stringify(event, undefined, 4));
+                        break;
+                }
+            }
+        },
+        
+        // isLiveEvents determines whether notifications should be shown, whether
+        // messages get appended to the start/end of lists, etc.
+        handleEvents: function(events, isLiveEvents, isStateEvents) {
+            for (var i=0; i<events.length; i++) {
+                this.handleEvent(events[i], isLiveEvents, isStateEvents);
+            }
+        },
+
+        // Handle messages from /initialSync or /messages
+        handleRoomMessages: function(room_id, messages, isLiveEvents, dir) {
+            var events = messages.chunk;
+
+            // Handles messages according to their time order
+            if (dir && 'b' === dir) {
+                // paginateBackMessages requests messages to be in reverse chronological order
+                for (var i=0; i<events.length; i++) {
+                    this.handleEvent(events[i], isLiveEvents, isLiveEvents);
+                }
+                
+                // Store how far back we've paginated
+                var room = modelService.getRoom(room_id);
+                room.old_room_state.pagination_token = messages.end;
+
+            }
+            else {
+                // InitialSync returns messages in chronological order, so invert
+                // it to get most recent > oldest
+                for (var i=events.length - 1; i>=0; i--) {
+                    this.handleEvent(events[i], isLiveEvents, isLiveEvents);
+                }
+                // Store where to start pagination
+                var room = modelService.getRoom(room_id);
+                room.old_room_state.pagination_token = messages.start;
+            }
+        },
+
+        handleInitialSyncDone: function(response) {
+            console.log("# handleInitialSyncDone");
+
+            var rooms = response.data.rooms;
+            for (var i = 0; i < rooms.length; ++i) {
+                var room = rooms[i];
+                
+                // FIXME: This is ming: the HS should be sending down the m.room.member
+                // event for the invite in .state but it isn't, so fudge it for now.
+                if (room.inviter && room.membership === "invite") {
+                    var me = matrixService.config().user_id;
+                    var fakeEvent = {
+                        event_id: "__FAKE__" + room.room_id,
+                        user_id: room.inviter,
+                        origin_server_ts: 0,
+                        room_id: room.room_id,
+                        state_key: me,
+                        type: "m.room.member",
+                        content: {
+                            membership: "invite"
+                        }
+                    };
+                    if (!room.state) {
+                        room.state = [];
+                    }
+                    room.state.push(fakeEvent);
+                    console.log("RECV /initialSync invite >> "+room.room_id);
+                }
+                
+                var newRoom = modelService.getRoom(room.room_id);
+                newRoom.current_room_state.storeStateEvents(room.state);
+                newRoom.old_room_state.storeStateEvents(room.state);
+
+                // this should be done AFTER storing state events since these
+                // messages may make the old_room_state diverge.
+                if ("messages" in room) {
+                    this.handleRoomMessages(room.room_id, room.messages, false);
+                    newRoom.current_room_state.pagination_token = room.messages.end;
+                    newRoom.old_room_state.pagination_token = room.messages.start;
+                }
+            }
+            var presence = response.data.presence;
+            this.handleEvents(presence, false);
+
+            initialSyncDeferred.resolve(response);
+        },
+
+        // Returns a promise that resolves when the initialSync request has been processed
+        waitForInitialSyncCompletion: function() {
+            return initialSyncDeferred.promise;
+        },
+
+        resetRoomMessages: function(room_id) {
+            resetRoomMessages(room_id);
+        },
+        
+        /**
+         * Return the last message event of a room
+         * @param {String} room_id the room id
+         * @param {Boolean} filterFake true to not take into account fake messages
+         * @returns {undefined | Event} the last message event if available
+         */
+        getLastMessage: function(room_id, filterEcho) {
+            var lastMessage;
+
+            var events = modelService.getRoom(room_id).events;
+            for (var i = events.length - 1; i >= 0; i--) {
+                var message = events[i];
+
+                if (!filterEcho || undefined === message.echo_msg_state) {
+                    lastMessage = message;
+                    break;
+                }
+            }
+
+            return lastMessage;
+        },
+        
+        /**
+         * Compute the room users number, ie the number of members who has joined the room.
+         * @param {String} room_id the room id
+         * @returns {undefined | Number} the room users number if available
+         */
+        getUsersCountInRoom: function(room_id) {
+            var memberCount;
+
+            var room = modelService.getRoom(room_id);
+            memberCount = 0;
+            for (var i in room.current_room_state.members) {
+                if (!room.current_room_state.members.hasOwnProperty(i)) continue;
+
+                var member = room.current_room_state.members[i];
+
+                if ("join" === member.content.membership) {
+                    memberCount = memberCount + 1;
+                }
+            }
+
+            return memberCount;
+        },
+        
+        /**
+         * Return the power level of an user in a particular room
+         * @param {String} room_id the room id
+         * @param {String} user_id the user id
+         * @returns {Number} a value between 0 and 10
+         */
+        getUserPowerLevel: function(room_id, user_id) {
+            var powerLevel = 0;
+            var room = modelService.getRoom(room_id).current_room_state;
+            if (room.state("m.room.power_levels")) {
+                if (user_id in room.state("m.room.power_levels").content) {
+                    powerLevel = room.state("m.room.power_levels").content[user_id];
+                }
+                else {
+                    // Use the room default user power
+                    powerLevel = room.state("m.room.power_levels").content["default"];
+                }
+            }
+            return powerLevel;
+        },
+        
+        /**
+         * Return the display name of an user acccording to data already downloaded
+         * @param {String} room_id the room id
+         * @param {String} user_id the id of the user
+         * @returns {String} the user displayname or user_id if not available
+         */
+        getUserDisplayName: function(room_id, user_id) {
+            return getUserDisplayName(room_id, user_id);
+        }
+    };
+}]);
diff --git a/syweb/webclient/components/matrix/event-stream-service.js b/syweb/webclient/components/matrix/event-stream-service.js
new file mode 100644
index 0000000000..c03f0b953b
--- /dev/null
+++ b/syweb/webclient/components/matrix/event-stream-service.js
@@ -0,0 +1,160 @@
+/*
+Copyright 2014 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+/*
+This service manages where in the event stream the web client currently is,
+repolling the event stream, and provides methods to resume/pause/stop the event 
+stream. This service is not responsible for parsing event data. For that, see 
+the eventHandlerService.
+*/
+angular.module('eventStreamService', [])
+.factory('eventStreamService', ['$q', '$timeout', 'matrixService', 'eventHandlerService', function($q, $timeout, matrixService, eventHandlerService) {
+    var END = "END";
+    var SERVER_TIMEOUT_MS = 30000;
+    var CLIENT_TIMEOUT_MS = 40000;
+    var ERR_TIMEOUT_MS = 5000;
+    
+    var settings = {
+        from: "END",
+        to: undefined,
+        limit: undefined,
+        shouldPoll: true,
+        isActive: false
+    };
+    
+    // interrupts the stream. Only valid if there is a stream conneciton 
+    // open.
+    var interrupt = function(shouldPoll) {
+        console.log("[EventStream] interrupt("+shouldPoll+") "+
+                    JSON.stringify(settings));
+        settings.shouldPoll = shouldPoll;
+        settings.isActive = false;
+    };
+    
+    var saveStreamSettings = function() {
+        localStorage.setItem("streamSettings", JSON.stringify(settings));
+    };
+
+    var doEventStream = function(deferred) {
+        settings.shouldPoll = true;
+        settings.isActive = true;
+        deferred = deferred || $q.defer();
+
+        // run the stream from the latest token
+        matrixService.getEventStream(settings.from, SERVER_TIMEOUT_MS, CLIENT_TIMEOUT_MS).then(
+            function(response) {
+                if (!settings.isActive) {
+                    console.log("[EventStream] Got response but now inactive. Dropping data.");
+                    return;
+                }
+                
+                settings.from = response.data.end;
+                
+                console.log(
+                    "[EventStream] Got response from "+settings.from+
+                    " to "+response.data.end
+                );
+                eventHandlerService.handleEvents(response.data.chunk, true);
+                
+                deferred.resolve(response);
+                
+                if (settings.shouldPoll) {
+                    $timeout(doEventStream, 0);
+                }
+                else {
+                    console.log("[EventStream] Stopping poll.");
+                }
+            },
+            function(error) {
+                if (error.status === 403) {
+                    settings.shouldPoll = false;
+                }
+                
+                deferred.reject(error);
+                
+                if (settings.shouldPoll) {
+                    $timeout(doEventStream, ERR_TIMEOUT_MS);
+                }
+                else {
+                    console.log("[EventStream] Stopping polling.");
+                }
+            }
+        );
+
+        return deferred.promise;
+    }; 
+
+    var startEventStream = function() {
+        settings.shouldPoll = true;
+        settings.isActive = true;
+        var deferred = $q.defer();
+
+        // Initial sync: get all information and the last 30 messages of all rooms of the user
+        // 30 messages should be enough to display a full page of messages in a room
+        // without requiring to make an additional request
+        matrixService.initialSync(30, false).then(
+            function(response) {
+                eventHandlerService.handleInitialSyncDone(response);
+
+                // Start event streaming from that point
+                settings.from = response.data.end;
+                doEventStream(deferred);        
+            },
+            function(error) {
+                $scope.feedback = "Failure: " + error.data;
+            }
+        );
+
+        return deferred.promise;
+    };
+    
+    return {
+        // resume the stream from whereever it last got up to. Typically used
+        // when the page is opened.
+        resume: function() {
+            if (settings.isActive) {
+                console.log("[EventStream] Already active, ignoring resume()");
+                return;
+            }
+        
+            console.log("[EventStream] resume "+JSON.stringify(settings));
+            return startEventStream();
+        },
+        
+        // pause the stream. Resuming it will continue from the current position
+        pause: function() {
+            console.log("[EventStream] pause "+JSON.stringify(settings));
+            // kill any running stream
+            interrupt(false);
+            // save the latest token
+            saveStreamSettings();
+        },
+        
+        // stop the stream and wipe the position in the stream. Typically used
+        // when logging out / logged out.
+        stop: function() {
+            console.log("[EventStream] stop "+JSON.stringify(settings));
+            // kill any running stream
+            interrupt(false);
+            // clear the latest token
+            settings.from = END;
+            saveStreamSettings();
+        }
+    };
+
+}]);
diff --git a/syweb/webclient/components/matrix/matrix-call.js b/syweb/webclient/components/matrix/matrix-call.js
new file mode 100644
index 0000000000..5a2807c755
--- /dev/null
+++ b/syweb/webclient/components/matrix/matrix-call.js
@@ -0,0 +1,607 @@
+/*
+Copyright 2014 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+var forAllVideoTracksOnStream = function(s, f) {
+    var tracks = s.getVideoTracks();
+    for (var i = 0; i < tracks.length; i++) {
+        f(tracks[i]);
+    }
+}
+
+var forAllAudioTracksOnStream = function(s, f) {
+    var tracks = s.getAudioTracks();
+    for (var i = 0; i < tracks.length; i++) {
+        f(tracks[i]);
+    }
+}
+
+var forAllTracksOnStream = function(s, f) {
+    forAllVideoTracksOnStream(s, f);
+    forAllAudioTracksOnStream(s, f);
+}
+
+navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
+window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection; // but not mozRTCPeerConnection because its interface is not compatible
+window.RTCSessionDescription = window.RTCSessionDescription || window.webkitRTCSessionDescription || window.mozRTCSessionDescription;
+window.RTCIceCandidate = window.RTCIceCandidate || window.webkitRTCIceCandidate || window.mozRTCIceCandidate;
+
+// Returns true if the browser supports all required features to make WebRTC call
+var isWebRTCSupported = function () {
+    return !!(navigator.getUserMedia || window.RTCPeerConnection || window.RTCSessionDescription || window.RTCIceCandidate);
+};
+
+angular.module('MatrixCall', [])
+.factory('MatrixCall', ['matrixService', 'matrixPhoneService', 'modelService', '$rootScope', '$timeout', function MatrixCallFactory(matrixService, matrixPhoneService, modelService, $rootScope, $timeout) {
+    $rootScope.isWebRTCSupported = isWebRTCSupported();
+
+    var MatrixCall = function(room_id) {
+        this.room_id = room_id;
+        this.call_id = "c" + new Date().getTime();
+        this.state = 'fledgling';
+        this.didConnect = false;
+
+        // a queue for candidates waiting to go out. We try to amalgamate candidates into a single candidate message where possible
+        this.candidateSendQueue = [];
+        this.candidateSendTries = 0;
+
+        var self = this;
+        $rootScope.$watch(this.remoteVideoElement, function (oldValue, newValue) {
+            self.tryPlayRemoteStream();
+        });
+
+    }
+
+    MatrixCall.getTurnServer = function() {
+        matrixService.getTurnServer().then(function(response) {
+            if (response.data.uris) {
+                console.log("Got TURN URIs: "+response.data.uris);
+                MatrixCall.turnServer = response.data;
+                $rootScope.haveTurn = true;
+                // re-fetch when we're about to reach the TTL
+                $timeout(MatrixCall.getTurnServer, MatrixCall.turnServer.ttl * 1000 * 0.9);
+            } else {
+                console.log("Got no TURN URIs from HS");
+                $rootScope.haveTurn = false;
+            }
+        }, function(error) {
+            console.log("Failed to get TURN URIs");
+            MatrixCall.turnServer = {};
+            $timeout(MatrixCall.getTurnServer, 60000);
+        });
+    }
+
+    // FIXME: we should prevent any class from being placed or accepted before this has finished
+    MatrixCall.getTurnServer();
+
+    MatrixCall.CALL_TIMEOUT = 60000;
+    MatrixCall.FALLBACK_STUN_SERVER = 'stun:stun.l.google.com:19302';
+
+    MatrixCall.prototype.createPeerConnection = function() {
+        var pc;
+        if (window.mozRTCPeerConnection) {
+            var iceServers = [];
+            if (MatrixCall.turnServer) {
+                if (MatrixCall.turnServer.uris) {
+                    for (var i = 0; i < MatrixCall.turnServer.uris.length; i++) {
+                        iceServers.push({
+                            'url': MatrixCall.turnServer.uris[i],
+                            'username': MatrixCall.turnServer.username,
+                            'credential': MatrixCall.turnServer.password,
+                        });
+                    }
+                } else {
+                    console.log("No TURN server: using fallback STUN server");
+                    iceServers.push({ 'url' : MatrixCall.FALLBACK_STUN_SERVER });
+                }
+            }
+          
+            pc = new window.mozRTCPeerConnection({"iceServers":iceServers});
+        } else {
+            var iceServers = [];
+            if (MatrixCall.turnServer) {
+                if (MatrixCall.turnServer.uris) {
+                    iceServers.push({
+                        'urls': MatrixCall.turnServer.uris,
+                        'username': MatrixCall.turnServer.username,
+                        'credential': MatrixCall.turnServer.password,
+                    });
+                } else {
+                    console.log("No TURN server: using fallback STUN server");
+                    iceServers.push({ 'urls' : MatrixCall.FALLBACK_STUN_SERVER });
+                }
+            }
+          
+            pc = new window.RTCPeerConnection({"iceServers":iceServers});
+        }
+        var self = this;
+        pc.oniceconnectionstatechange = function() { self.onIceConnectionStateChanged(); };
+        pc.onsignalingstatechange = function() { self.onSignallingStateChanged(); };
+        pc.onicecandidate = function(c) { self.gotLocalIceCandidate(c); };
+        pc.onaddstream = function(s) { self.onAddStream(s); };
+        return pc;
+    }
+
+    MatrixCall.prototype.getUserMediaVideoContraints = function(callType) {
+        switch (callType) {
+            case 'voice':
+                return ({audio: true, video: false});
+            case 'video':
+                return ({audio: true, video: {
+                    mandatory: {
+                        minWidth: 640,
+                        maxWidth: 640,
+                        minHeight: 360,
+                        maxHeight: 360,
+                    }
+                }});
+        }
+    };
+
+    MatrixCall.prototype.placeVoiceCall = function() {
+        this.placeCallWithConstraints(this.getUserMediaVideoContraints('voice'));
+        this.type = 'voice';
+    };
+
+    MatrixCall.prototype.placeVideoCall = function(config) {
+        this.placeCallWithConstraints(this.getUserMediaVideoContraints('video'));
+        this.type = 'video';
+    };
+
+    MatrixCall.prototype.placeCallWithConstraints = function(constraints) {
+        var self = this;
+        matrixPhoneService.callPlaced(this);
+        navigator.getUserMedia(constraints, function(s) { self.gotUserMediaForInvite(s); }, function(e) { self.getUserMediaFailed(e); });
+        this.state = 'wait_local_media';
+        this.direction = 'outbound';
+        this.config = constraints;
+    };
+
+    MatrixCall.prototype.initWithInvite = function(event) {
+        this.msg = event.content;
+        this.peerConn = this.createPeerConnection();
+        this.peerConn.setRemoteDescription(new RTCSessionDescription(this.msg.offer), this.onSetRemoteDescriptionSuccess, this.onSetRemoteDescriptionError);
+        this.state = 'ringing';
+        this.direction = 'inbound';
+
+        if (window.mozRTCPeerConnection) {
+            // firefox's RTCPeerConnection doesn't add streams until it starts getting media on them
+            // so we need to figure out whether a video channel has been offered by ourselves.
+            if (this.msg.offer.sdp.indexOf('m=video') > -1) {
+                this.type = 'video';
+            } else {
+                this.type = 'voice';
+            }
+        }
+
+        var self = this;
+        $timeout(function() {
+            if (self.state == 'ringing') {
+                self.state = 'ended';
+                self.hangupParty = 'remote'; // effectively
+                self.stopAllMedia();
+                if (self.peerConn.signalingState != 'closed') self.peerConn.close();
+                if (self.onHangup) self.onHangup(self);
+            }
+        }, this.msg.lifetime - event.age);
+    };
+
+    // perverse as it may seem, sometimes we want to instantiate a call with a hangup message
+    // (because when getting the state of the room on load, events come in reverse order and
+    // we want to remember that a call has been hung up)
+    MatrixCall.prototype.initWithHangup = function(event) {
+        this.msg = event.content;
+        this.state = 'ended';
+    };
+
+    MatrixCall.prototype.answer = function() {
+        console.log("Answering call "+this.call_id);
+
+        var self = this;
+
+        var roomMembers = modelService.getRoom(this.room_id).current_room_state.members;
+        if (roomMembers[matrixService.config().user_id].membership != 'join') {
+            console.log("We need to join the room before we can accept this call");
+            matrixService.join(this.room_id).then(function() {
+                self.answer();
+            }, function() {
+                console.log("Failed to join room: can't answer call!");
+                self.onError("Unable to join room to answer call!");
+                self.hangup();
+            });
+            return;
+        }
+
+        if (!this.localAVStream && !this.waitForLocalAVStream) {
+            navigator.getUserMedia(this.getUserMediaVideoContraints(this.type), function(s) { self.gotUserMediaForAnswer(s); }, function(e) { self.getUserMediaFailed(e); });
+            this.state = 'wait_local_media';
+        } else if (this.localAVStream) {
+            this.gotUserMediaForAnswer(this.localAVStream);
+        } else if (this.waitForLocalAVStream) {
+            this.state = 'wait_local_media';
+        }
+    };
+
+    MatrixCall.prototype.stopAllMedia = function() {
+        if (this.localAVStream) {
+            forAllTracksOnStream(this.localAVStream, function(t) {
+                if (t.stop) t.stop();
+            });
+        }
+        if (this.remoteAVStream) {
+            forAllTracksOnStream(this.remoteAVStream, function(t) {
+                if (t.stop) t.stop();
+            });
+        }
+    };
+
+    MatrixCall.prototype.hangup = function(reason, suppressEvent) {
+        console.log("Ending call "+this.call_id);
+
+        // pausing now keeps the last frame (ish) of the video call in the video element
+        // rather than it just turning black straight away
+        if (this.remoteVideoElement) this.remoteVideoElement.pause();
+        if (this.localVideoElement) this.localVideoElement.pause();
+
+        this.stopAllMedia();
+        if (this.peerConn) this.peerConn.close();
+
+        this.hangupParty = 'local';
+        this.hangupReason = reason;
+
+        var content = {
+            version: 0,
+            call_id: this.call_id,
+            reason: reason
+        };
+        this.sendEventWithRetry('m.call.hangup', content);
+        this.state = 'ended';
+        if (this.onHangup && !suppressEvent) this.onHangup(this);
+    };
+
+    MatrixCall.prototype.gotUserMediaForInvite = function(stream) {
+        if (this.successor) {
+            this.successor.gotUserMediaForAnswer(stream);
+            return;
+        }
+        if (this.state == 'ended') return;
+
+        if (this.localVideoElement && this.type == 'video') {
+            var vidTrack = stream.getVideoTracks()[0];
+            this.localVideoElement.src = URL.createObjectURL(stream);
+            this.localVideoElement.muted = true;
+            this.localVideoElement.play();
+        }
+
+        this.localAVStream = stream;
+        var audioTracks = stream.getAudioTracks();
+        for (var i = 0; i < audioTracks.length; i++) {
+            audioTracks[i].enabled = true;
+        }
+        this.peerConn = this.createPeerConnection();
+        this.peerConn.addStream(stream);
+        var self = this;
+        this.peerConn.createOffer(function(d) {
+            self.gotLocalOffer(d);
+        }, function(e) {
+            self.getLocalOfferFailed(e);
+        });
+        $rootScope.$apply(function() {
+            self.state = 'create_offer';
+        });
+    };
+
+    MatrixCall.prototype.gotUserMediaForAnswer = function(stream) {
+        if (this.state == 'ended') return;
+
+        if (this.localVideoElement && this.type == 'video') {
+            var vidTrack = stream.getVideoTracks()[0];
+            this.localVideoElement.src = URL.createObjectURL(stream);
+            this.localVideoElement.muted = true;
+            this.localVideoElement.play();
+        }
+
+        this.localAVStream = stream;
+        var audioTracks = stream.getAudioTracks();
+        for (var i = 0; i < audioTracks.length; i++) {
+            audioTracks[i].enabled = true;
+        }
+        this.peerConn.addStream(stream);
+        var self = this;
+        var constraints = {
+            'mandatory': {
+                'OfferToReceiveAudio': true,
+                'OfferToReceiveVideo': this.type == 'video'
+            },
+        };
+        this.peerConn.createAnswer(function(d) { self.createdAnswer(d); }, function(e) {}, constraints);
+        // This can't be in an apply() because it's called by a predecessor call under glare conditions :(
+        self.state = 'create_answer';
+    };
+
+    MatrixCall.prototype.gotLocalIceCandidate = function(event) {
+        if (event.candidate) {
+            console.log("Got local ICE "+event.candidate.sdpMid+" candidate: "+event.candidate.candidate);
+            this.sendCandidate(event.candidate);
+        }
+    }
+
+    MatrixCall.prototype.gotRemoteIceCandidate = function(cand) {
+        console.log("Got remote ICE "+cand.sdpMid+" candidate: "+cand.candidate);
+        if (this.state == 'ended') {
+            console.log("Ignoring remote ICE candidate because call has ended");
+            return;
+        }
+        this.peerConn.addIceCandidate(new RTCIceCandidate(cand), function() {}, function(e) {});
+    };
+
+    MatrixCall.prototype.receivedAnswer = function(msg) {
+        if (this.state == 'ended') return;
+
+        this.peerConn.setRemoteDescription(new RTCSessionDescription(msg.answer), this.onSetRemoteDescriptionSuccess, this.onSetRemoteDescriptionError);
+        this.state = 'connecting';
+    };
+
+
+    MatrixCall.prototype.gotLocalOffer = function(description) {
+        console.log("Created offer: "+description);
+
+        if (this.state == 'ended') {
+            console.log("Ignoring newly created offer on call ID "+this.call_id+" because the call has ended");
+            return;
+        }
+
+        this.peerConn.setLocalDescription(description);
+
+        var content = {
+            version: 0,
+            call_id: this.call_id,
+            offer: description,
+            lifetime: MatrixCall.CALL_TIMEOUT
+        };
+        this.sendEventWithRetry('m.call.invite', content);
+
+        var self = this;
+        $timeout(function() {
+            if (self.state == 'invite_sent') {
+                self.hangup('invite_timeout');
+            }
+        }, MatrixCall.CALL_TIMEOUT);
+
+        $rootScope.$apply(function() {
+            self.state = 'invite_sent';
+        });
+    };
+
+    MatrixCall.prototype.createdAnswer = function(description) {
+        console.log("Created answer: "+description);
+        this.peerConn.setLocalDescription(description);
+        var content = {
+            version: 0,
+            call_id: this.call_id,
+            answer: description
+        };
+        this.sendEventWithRetry('m.call.answer', content);
+        var self = this;
+        $rootScope.$apply(function() {
+            self.state = 'connecting';
+        });
+    };
+
+    MatrixCall.prototype.getLocalOfferFailed = function(error) {
+        this.onError("Failed to start audio for call!");
+    };
+
+    MatrixCall.prototype.getUserMediaFailed = function() {
+        this.onError("Couldn't start capturing! Is your microphone set up?");
+        this.hangup();
+    };
+
+    MatrixCall.prototype.onIceConnectionStateChanged = function() {
+        if (this.state == 'ended') return; // because ICE can still complete as we're ending the call
+        console.log("Ice connection state changed to: "+this.peerConn.iceConnectionState);
+        // ideally we'd consider the call to be connected when we get media but chrome doesn't implement nay of the 'onstarted' events yet
+        if (this.peerConn.iceConnectionState == 'completed' || this.peerConn.iceConnectionState == 'connected') {
+            var self = this;
+            $rootScope.$apply(function() {
+                self.state = 'connected';
+                self.didConnect = true;
+            });
+        } else if (this.peerConn.iceConnectionState == 'failed') {
+            this.hangup('ice_failed');
+        }
+    };
+
+    MatrixCall.prototype.onSignallingStateChanged = function() {
+        console.log("call "+this.call_id+": Signalling state changed to: "+this.peerConn.signalingState);
+    };
+
+    MatrixCall.prototype.onSetRemoteDescriptionSuccess = function() {
+        console.log("Set remote description");
+    };
+    
+    MatrixCall.prototype.onSetRemoteDescriptionError = function(e) {
+        console.log("Failed to set remote description"+e);
+    };
+
+    MatrixCall.prototype.onAddStream = function(event) {
+        console.log("Stream added"+event);
+
+        var s = event.stream;
+
+        this.remoteAVStream = s;
+
+        if (this.direction == 'inbound') {
+            if (s.getVideoTracks().length > 0) {
+                this.type = 'video';
+            } else {
+                this.type = 'voice';
+            }
+        }
+
+        var self = this;
+        forAllTracksOnStream(s, function(t) {
+            // not currently implemented in chrome
+            t.onstarted = self.onRemoteStreamTrackStarted;
+        });
+
+        event.stream.onended = function(e) { self.onRemoteStreamEnded(e); }; 
+        // not currently implemented in chrome
+        event.stream.onstarted = function(e) { self.onRemoteStreamStarted(e); };
+
+        this.tryPlayRemoteStream();
+    };
+
+    MatrixCall.prototype.tryPlayRemoteStream = function(event) {
+        if (this.remoteVideoElement && this.remoteAVStream) {
+            var player = this.remoteVideoElement;
+            player.src = URL.createObjectURL(this.remoteAVStream);
+            player.play();
+        }
+    };
+
+    MatrixCall.prototype.onRemoteStreamStarted = function(event) {
+        var self = this;
+        $rootScope.$apply(function() {
+            self.state = 'connected';
+        });
+    };
+
+    MatrixCall.prototype.onRemoteStreamEnded = function(event) {
+        console.log("Remote stream ended");
+        var self = this;
+        $rootScope.$apply(function() {
+            self.state = 'ended';
+            self.hangupParty = 'remote';
+            self.stopAllMedia();
+            if (self.peerConn.signalingState != 'closed') self.peerConn.close();
+            if (self.onHangup) self.onHangup(self);
+        });
+    };
+
+    MatrixCall.prototype.onRemoteStreamTrackStarted = function(event) {
+        var self = this;
+        $rootScope.$apply(function() {
+            self.state = 'connected';
+        });
+    };
+
+    MatrixCall.prototype.onHangupReceived = function(msg) {
+        console.log("Hangup received");
+        if (this.remoteVideoElement) this.remoteVideoElement.pause();
+        if (this.localVideoElement) this.localVideoElement.pause();
+        this.state = 'ended';
+        this.hangupParty = 'remote';
+        this.hangupReason = msg.reason;
+        this.stopAllMedia();
+        if (this.peerConn && this.peerConn.signalingState != 'closed') this.peerConn.close();
+        if (this.onHangup) this.onHangup(this);
+    };
+
+    MatrixCall.prototype.replacedBy = function(newCall) {
+        console.log(this.call_id+" being replaced by "+newCall.call_id);
+        if (this.state == 'wait_local_media') {
+            console.log("Telling new call to wait for local media");
+            newCall.waitForLocalAVStream = true;
+        } else if (this.state == 'create_offer') {
+            console.log("Handing local stream to new call");
+            newCall.gotUserMediaForAnswer(this.localAVStream);
+            delete(this.localAVStream);
+        } else if (this.state == 'invite_sent') {
+            console.log("Handing local stream to new call");
+            newCall.gotUserMediaForAnswer(this.localAVStream);
+            delete(this.localAVStream);
+        }
+        newCall.localVideoElement = this.localVideoElement;
+        newCall.remoteVideoElement = this.remoteVideoElement;
+        this.successor = newCall;
+        this.hangup(true);
+    };
+
+    MatrixCall.prototype.sendEventWithRetry = function(evType, content) {
+        var ev = { type:evType, content:content, tries:1 };
+        var self = this;
+        matrixService.sendEvent(this.room_id, evType, undefined, content).then(this.eventSent, function(error) { self.eventSendFailed(ev, error); } );
+    };
+
+    MatrixCall.prototype.eventSent = function() {
+    };
+
+    MatrixCall.prototype.eventSendFailed = function(ev, error) {
+        if (ev.tries > 5) {
+            console.log("Failed to send event of type "+ev.type+" on attempt "+ev.tries+". Giving up.");
+            return;
+        }
+        var delayMs = 500 * Math.pow(2, ev.tries);
+        console.log("Failed to send event of type "+ev.type+". Retrying in "+delayMs+"ms");
+        ++ev.tries;
+        var self = this;
+        $timeout(function() {
+            matrixService.sendEvent(self.room_id, ev.type, undefined, ev.content).then(self.eventSent, function(error) { self.eventSendFailed(ev, error); } );
+        }, delayMs);
+    };
+
+    // Sends candidates with are sent in a special way because we try to amalgamate them into one message
+    MatrixCall.prototype.sendCandidate = function(content) {
+        this.candidateSendQueue.push(content);
+        var self = this;
+        if (this.candidateSendTries == 0) $timeout(function() { self.sendCandidateQueue(); }, 100);
+    };
+
+    MatrixCall.prototype.sendCandidateQueue = function(content) {
+        if (this.candidateSendQueue.length == 0) return;
+
+        var cands = this.candidateSendQueue;
+        this.candidateSendQueue = [];
+        ++this.candidateSendTries;
+        var content = {
+            version: 0,
+            call_id: this.call_id,
+            candidates: cands
+        };
+        var self = this;
+        console.log("Attempting to send "+cands.length+" candidates");
+        matrixService.sendEvent(self.room_id, 'm.call.candidates', undefined, content).then(function() { self.candsSent(); }, function(error) { self.candsSendFailed(cands, error); } );
+    };
+
+    MatrixCall.prototype.candsSent = function() {
+        this.candidateSendTries = 0;
+        this.sendCandidateQueue();
+    };
+
+    MatrixCall.prototype.candsSendFailed = function(cands, error) {
+        for (var i = 0; i < cands.length; ++i) {
+            this.candidateSendQueue.push(cands[i]);
+        }
+
+        if (this.candidateSendTries > 5) {
+            console.log("Failed to send candidates on attempt "+this.candidateSendTries+". Giving up for now.");
+            this.candidateSendTries = 0;
+            return;
+        }
+
+        var delayMs = 500 * Math.pow(2, this.candidateSendTries);
+        ++this.candidateSendTries;
+        console.log("Failed to send candidates. Retrying in "+delayMs+"ms");
+        var self = this;
+        $timeout(function() {
+            self.sendCandidateQueue();
+        }, delayMs);
+    };
+
+    return MatrixCall;
+}]);
diff --git a/syweb/webclient/components/matrix/matrix-filter.js b/syweb/webclient/components/matrix/matrix-filter.js
new file mode 100644
index 0000000000..4d264e93f3
--- /dev/null
+++ b/syweb/webclient/components/matrix/matrix-filter.js
@@ -0,0 +1,108 @@
+/*
+ Copyright 2014 OpenMarket Ltd
+ 
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+ 
+ http://www.apache.org/licenses/LICENSE-2.0
+ 
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+'use strict';
+
+angular.module('matrixFilter', [])
+
+// Compute the room name according to information we have
+// TODO: It would be nice if this was stateless and had no dependencies. That would
+//       make the business logic here a lot easier to see.
+.filter('mRoomName', ['$rootScope', 'matrixService', 'eventHandlerService', 'modelService', 
+function($rootScope, matrixService, eventHandlerService, modelService) {
+    return function(room_id) {
+        var roomName;
+
+        // If there is an alias, use it
+        // TODO: only one alias is managed for now
+        var alias = matrixService.getRoomIdToAliasMapping(room_id);
+        var room = modelService.getRoom(room_id).current_room_state;
+        
+        var room_name_event = room.state("m.room.name");
+
+        // Determine if it is a public room
+        var isPublicRoom = false;
+        if (room.state("m.room.join_rules") && room.state("m.room.join_rules").content) {
+            isPublicRoom = ("public" === room.state("m.room.join_rules").content.join_rule);
+        }
+
+        if (room_name_event) {
+            roomName = room_name_event.content.name;
+        }
+        else if (alias) {
+            roomName = alias;
+        }
+        else if (Object.keys(room.members).length > 0 && !isPublicRoom) { // Do not rename public room
+            var user_id = matrixService.config().user_id;
+            
+            // this is a "one to one" room and should have the name of the other user.
+            if (Object.keys(room.members).length === 2) {
+                for (var i in room.members) {
+                    if (!room.members.hasOwnProperty(i)) continue;
+
+                    var member = room.members[i];
+                    if (member.state_key !== user_id) {
+                        roomName = eventHandlerService.getUserDisplayName(room_id, member.state_key);
+                        break;
+                    }
+                }
+            }
+            else if (Object.keys(room.members).length === 1) {
+                // this could be just us (self-chat) or could be the other person
+                // in a room if they have invited us to the room. Find out which.
+                var otherUserId = Object.keys(room.members)[0];
+                if (otherUserId === user_id) {
+                    // it's us, we may have been invited to this room or it could
+                    // be a self chat.
+                    if (room.members[otherUserId].content.membership === "invite") {
+                        // someone invited us, use the right ID.
+                        roomName = eventHandlerService.getUserDisplayName(room_id, room.members[otherUserId].user_id);
+                    }
+                    else {
+                        roomName = eventHandlerService.getUserDisplayName(room_id, otherUserId);
+                    }
+                }
+                else { // it isn't us, so use their name if we know it.
+                    roomName = eventHandlerService.getUserDisplayName(room_id, otherUserId);
+                }
+            }
+            else if (Object.keys(room.members).length === 0) {
+                // this shouldn't be possible
+                console.error("0 members in room >> " + room_id);
+            }
+        }
+        
+
+        // Always show the alias in the room displayed name
+        if (roomName && alias && alias !== roomName) {
+            roomName += " (" + alias + ")";
+        }
+
+        if (undefined === roomName) {
+            // By default, use the room ID
+            roomName = room_id;
+        }
+
+        return roomName;
+    };
+}])
+
+// Return the user display name
+.filter('mUserDisplayName', ['eventHandlerService', function(eventHandlerService) {
+    return function(user_id, room_id) {
+        return eventHandlerService.getUserDisplayName(room_id, user_id);
+    };
+}]);
diff --git a/syweb/webclient/components/matrix/matrix-phone-service.js b/syweb/webclient/components/matrix/matrix-phone-service.js
new file mode 100644
index 0000000000..06465ed821
--- /dev/null
+++ b/syweb/webclient/components/matrix/matrix-phone-service.js
@@ -0,0 +1,155 @@
+/*
+Copyright 2014 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+angular.module('matrixPhoneService', [])
+.factory('matrixPhoneService', ['$rootScope', '$injector', 'matrixService', 'eventHandlerService', function MatrixPhoneService($rootScope, $injector, matrixService, eventHandlerService) {
+    var matrixPhoneService = function() {
+    };
+
+    matrixPhoneService.INCOMING_CALL_EVENT = "INCOMING_CALL_EVENT";
+    matrixPhoneService.REPLACED_CALL_EVENT = "REPLACED_CALL_EVENT";
+    matrixPhoneService.allCalls = {};
+    // a place to save candidates that come in for calls we haven't got invites for yet (when paginating backwards)
+    matrixPhoneService.candidatesByCall = {};
+
+    matrixPhoneService.callPlaced = function(call) {
+        matrixPhoneService.allCalls[call.call_id] = call;
+    };
+
+    $rootScope.$on(eventHandlerService.CALL_EVENT, function(ngEvent, event, isLive) {
+        if (event.user_id == matrixService.config().user_id) return;
+
+        var msg = event.content;
+
+        if (event.type == 'm.call.invite') {
+            if (event.age == undefined || msg.lifetime == undefined) {
+                // if the event doesn't have either an age (the HS is too old) or a lifetime
+                // (the sending client was too old when it sent it) then fall back to old behaviour
+                if (!isLive) return; // until matrix supports expiring messages
+            }
+
+            if (event.age > msg.lifetime) {
+                console.log("Ignoring expired call event of type "+event.type);
+                return;
+            }
+
+            var call = undefined;
+            if (!isLive) {
+                // if this event wasn't live then this call may already be over
+                call = matrixPhoneService.allCalls[msg.call_id];
+                if (call && call.state == 'ended') {
+                    return;
+                }
+            }
+
+            var MatrixCall = $injector.get('MatrixCall');
+            var call = new MatrixCall(event.room_id);
+
+            if (!isWebRTCSupported()) {
+                console.log("Incoming call ID "+msg.call_id+" but this browser doesn't support WebRTC");
+                // don't hang up the call: there could be other clients connected that do support WebRTC and declining the
+                // the call on their behalf would be really annoying.
+                // instead, we broadcast a fake call event with a non-functional call object
+                $rootScope.$broadcast(matrixPhoneService.INCOMING_CALL_EVENT, call);
+                return;
+            }
+
+            call.call_id = msg.call_id;
+            call.initWithInvite(event);
+            matrixPhoneService.allCalls[call.call_id] = call;
+
+            // if we stashed candidate events for that call ID, play them back now
+            if (!isLive && matrixPhoneService.candidatesByCall[call.call_id] != undefined) {
+                for (var i = 0; i < matrixPhoneService.candidatesByCall[call.call_id].length; ++i) {
+                    call.gotRemoteIceCandidate(matrixPhoneService.candidatesByCall[call.call_id][i]);
+                }
+            }
+
+            // Were we trying to call that user (room)?
+            var existingCall;
+            var callIds = Object.keys(matrixPhoneService.allCalls);
+            for (var i = 0; i < callIds.length; ++i) {
+                var thisCallId = callIds[i];
+                var thisCall = matrixPhoneService.allCalls[thisCallId];
+
+                if (call.room_id == thisCall.room_id && thisCall.direction == 'outbound'
+                     && (thisCall.state == 'wait_local_media' || thisCall.state == 'create_offer' || thisCall.state == 'invite_sent')) {
+                    existingCall = thisCall;
+                    break;
+                }
+            }
+
+            if (existingCall) {
+                // If we've only got to wait_local_media or create_offer and we've got an invite,
+                // pick the incoming call because we know we haven't sent our invite yet
+                // otherwise, pick whichever call has the lowest call ID (by string comparison)
+                if (existingCall.state == 'wait_local_media' || existingCall.state == 'create_offer' || existingCall.call_id > call.call_id) {
+                    console.log("Glare detected: answering incoming call "+call.call_id+" and canceling outgoing call "+existingCall.call_id);
+                    existingCall.replacedBy(call);
+                    call.answer();
+                    $rootScope.$broadcast(matrixPhoneService.REPLACED_CALL_EVENT, existingCall, call);
+                } else {
+                    console.log("Glare detected: rejecting incoming call "+call.call_id+" and keeping outgoing call "+existingCall.call_id);
+                    call.hangup();
+                }
+            } else {
+                $rootScope.$broadcast(matrixPhoneService.INCOMING_CALL_EVENT, call);
+            }
+        } else if (event.type == 'm.call.answer') {
+            var call = matrixPhoneService.allCalls[msg.call_id];
+            if (!call) {
+                console.log("Got answer for unknown call ID "+msg.call_id);
+                return;
+            }
+            call.receivedAnswer(msg);
+        } else if (event.type == 'm.call.candidates') {
+            var call = matrixPhoneService.allCalls[msg.call_id];
+            if (!call && isLive) {
+                console.log("Got candidates for unknown call ID "+msg.call_id);
+                return;
+            } else if (!call) {
+                if (matrixPhoneService.candidatesByCall[msg.call_id] == undefined) {
+                    matrixPhoneService.candidatesByCall[msg.call_id] = [];
+                }
+                matrixPhoneService.candidatesByCall[msg.call_id] = matrixPhoneService.candidatesByCall[msg.call_id].concat(msg.candidates);
+            } else {
+                for (var i = 0; i < msg.candidates.length; ++i) {
+                    call.gotRemoteIceCandidate(msg.candidates[i]);
+                }
+            }
+        } else if (event.type == 'm.call.hangup') {
+            var call = matrixPhoneService.allCalls[msg.call_id];
+            if (!call && isLive) {
+                console.log("Got hangup for unknown call ID "+msg.call_id);
+            } else if (!call) {
+                // if not live, store the fact that the call has ended because we're probably getting events backwards so
+                // the hangup will come before the invite
+                var MatrixCall = $injector.get('MatrixCall');
+                var call = new MatrixCall(event.room_id);
+                call.call_id = msg.call_id;
+                call.initWithHangup(event);
+                matrixPhoneService.allCalls[msg.call_id] = call;
+            } else {
+                call.onHangupReceived(msg);
+                delete(matrixPhoneService.allCalls[msg.call_id]);
+            }
+        }
+    });
+    
+    return matrixPhoneService;
+}]);
diff --git a/syweb/webclient/components/matrix/matrix-service.js b/syweb/webclient/components/matrix/matrix-service.js
new file mode 100644
index 0000000000..fedfb8910d
--- /dev/null
+++ b/syweb/webclient/components/matrix/matrix-service.js
@@ -0,0 +1,759 @@
+/*
+Copyright 2014 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+/*
+This service wraps up Matrix API calls. 
+
+This serves to isolate the caller from changes to the underlying url paths, as
+well as attach common params (e.g. access_token) to requests.
+*/
+angular.module('matrixService', [])
+.factory('matrixService', ['$http', '$q', '$rootScope', function($http, $q, $rootScope) {
+        
+   /* 
+    * Permanent storage of user information
+    * The config contains:
+    *    - homeserver url
+    *    - Identity server url
+    *    - user_id
+    *    - access_token
+    *    - version: the version of this cache
+    */    
+    var config;
+    
+    var roomIdToAlias = {};
+    var aliasToRoomId = {};
+    
+    // Current version of permanent storage
+    var configVersion = 0;
+    var prefixPath = "/_matrix/client/api/v1";
+    var MAPPING_PREFIX = "alias_for_";
+
+    var doRequest = function(method, path, params, data, $httpParams) {
+        if (!config) {
+            console.warn("No config exists. Cannot perform request to "+path);
+            return;
+        }
+    
+        // Inject the access token
+        if (!params) {
+            params = {};
+        }
+        
+        params.access_token = config.access_token;
+        
+        if (path.indexOf(prefixPath) !== 0) {
+            path = prefixPath + path;
+        }
+        
+        return doBaseRequest(config.homeserver, method, path, params, data, undefined, $httpParams);
+    };
+
+    var doBaseRequest = function(baseUrl, method, path, params, data, headers, $httpParams) {
+
+        var request = {
+            method: method,
+            url: baseUrl + path,
+            params: params,
+            data: data,
+            headers: headers
+        };
+
+        // Add additional $http parameters
+        if ($httpParams) {
+            angular.extend(request, $httpParams);
+        }
+
+        return $http(request);
+    };
+    
+    var doRegisterLogin = function(path, loginType, sessionId, userName, password, threepidCreds) {
+        var data = {};
+        if (loginType === "m.login.recaptcha") {
+            var challengeToken = Recaptcha.get_challenge();
+            var captchaEntry = Recaptcha.get_response();
+            data = {
+                type: "m.login.recaptcha",
+                challenge: challengeToken,
+                response: captchaEntry
+            };
+        }
+        else if (loginType === "m.login.email.identity") {
+            data = {
+                threepidCreds: threepidCreds
+            };
+        }
+        else if (loginType === "m.login.password") {
+            data = {
+                user: userName,
+                password: password
+            };
+        }
+        
+        if (sessionId) {
+            data.session = sessionId;
+        }
+        data.type = loginType;
+        console.log("doRegisterLogin >>> " + loginType);
+        return doRequest("POST", path, undefined, data);
+    };
+
+    return {
+        /****** Home server API ******/
+        prefix: prefixPath,
+
+        // Register an user
+        register: function(user_name, password, threepidCreds, useCaptcha) {
+            // registration is composed of multiple requests, to check you can
+            // register, then to actually register. This deferred will fire when
+            // all the requests are done, along with the final response.
+            var deferred = $q.defer();
+            var path = "/register";
+            
+            // check we can actually register with this HS.
+            doRequest("GET", path, undefined, undefined).then(
+                function(response) {
+                    console.log("/register [1] : "+JSON.stringify(response));
+                    var flows = response.data.flows;
+                    var knownTypes = [
+                        "m.login.password",
+                        "m.login.recaptcha",
+                        "m.login.email.identity"
+                    ];
+                    // if they entered 3pid creds, we want to use a flow which uses it.
+                    var useThreePidFlow = threepidCreds != undefined;
+                    var flowIndex = 0;
+                    var firstRegType = undefined;
+                    
+                    for (var i=0; i<flows.length; i++) {
+                        var isThreePidFlow = false;
+                        if (flows[i].stages) {
+                            for (var j=0; j<flows[i].stages.length; j++) {
+                                var regType = flows[i].stages[j];
+                                if (knownTypes.indexOf(regType) === -1) {
+                                    deferred.reject("Unknown type: "+regType);
+                                    return;
+                                }
+                                if (regType == "m.login.email.identity") {
+                                    isThreePidFlow = true;
+                                }
+                                if (!useCaptcha && regType == "m.login.recaptcha") {
+                                    console.error("Web client setup to not use captcha, but HS demands a captcha.");
+                                    deferred.reject({
+                                        data: {
+                                            errcode: "M_CAPTCHA_NEEDED",
+                                            error: "Home server requires a captcha."
+                                        }
+                                    });
+                                    return;
+                                }
+                            }
+                        }
+                        
+                        if ( (isThreePidFlow && useThreePidFlow) || (!isThreePidFlow && !useThreePidFlow) ) {
+                            flowIndex = i;
+                        }
+                        
+                        if (knownTypes.indexOf(flows[i].type) == -1) {
+                            deferred.reject("Unknown type: "+flows[i].type);
+                            return;
+                        }
+                    }
+                    
+                    // looks like we can register fine, go ahead and do it.
+                    console.log("Using flow " + JSON.stringify(flows[flowIndex]));
+                    firstRegType = flows[flowIndex].type;
+                    var sessionId = undefined;
+                    
+                    // generic response processor so it can loop as many times as required
+                    var loginResponseFunc = function(response) {
+                        if (response.data.session) {
+                            sessionId = response.data.session;
+                        }
+                        console.log("login response: " + JSON.stringify(response.data));
+                        if (response.data.access_token) {
+                            deferred.resolve(response);
+                        }
+                        else if (response.data.next) {
+                            var nextType = response.data.next;
+                            if (response.data.next instanceof Array) {
+                                for (var i=0; i<response.data.next.length; i++) {
+                                    if (useThreePidFlow && response.data.next[i] == "m.login.email.identity") {
+                                        nextType = response.data.next[i];
+                                        break;
+                                    }
+                                    else if (!useThreePidFlow && response.data.next[i] != "m.login.email.identity") {
+                                        nextType = response.data.next[i];
+                                        break;
+                                    }
+                                }
+                            }
+                            return doRegisterLogin(path, nextType, sessionId, user_name, password, threepidCreds).then(
+                                loginResponseFunc,
+                                function(err) {
+                                    deferred.reject(err);
+                                }
+                            );
+                        }
+                        else {
+                            deferred.reject("Unknown continuation: "+JSON.stringify(response));
+                        }
+                    };
+                    
+                    // set the ball rolling
+                    doRegisterLogin(path, firstRegType, undefined, user_name, password, threepidCreds).then(
+                        loginResponseFunc,
+                        function(err) {
+                            deferred.reject(err);
+                        }
+                    );
+                    
+                },
+                function(err) {
+                    deferred.reject(err);
+                }
+            );
+            
+            return deferred.promise;
+        },
+
+        // Create a room
+        create: function(room_alias, visibility) {
+            // The REST path spec
+            var path = "/createRoom";
+
+            var req = {
+                "visibility": visibility
+            };
+            if (room_alias) {
+                req.room_alias_name = room_alias;
+            }
+            
+            return doRequest("POST", path, undefined, req);
+        },
+
+        // Get the user's current state: his presence, the list of his rooms with
+        // the last {limit} events
+        initialSync: function(limit, feedback) {
+            // The REST path spec
+
+            var path = "/initialSync";
+
+            var params = {};
+            if (limit) {
+                params.limit = limit;
+            }
+            if (feedback) {
+                params.feedback = feedback;
+            }
+
+            return doRequest("GET", path, params);
+        },
+        
+        // get room state for a specific room
+        roomState: function(room_id) {
+            var path = "/rooms/" + room_id + "/state";
+            return doRequest("GET", path);
+        },
+        
+        // Joins a room
+        join: function(room_id) {
+            return this.membershipChange(room_id, undefined, "join");
+        },
+
+        joinAlias: function(room_alias) {
+            var path = "/join/$room_alias";
+            room_alias = encodeURIComponent(room_alias);
+
+            path = path.replace("$room_alias", room_alias);
+
+            // TODO: PUT with txn ID
+            return doRequest("POST", path, undefined, {});
+        },
+
+        // Invite a user to a room
+        invite: function(room_id, user_id) {
+            return this.membershipChange(room_id, user_id, "invite");
+        },
+
+        // Leaves a room
+        leave: function(room_id) {
+            return this.membershipChange(room_id, undefined, "leave");
+        },
+
+        membershipChange: function(room_id, user_id, membershipValue) {
+            // The REST path spec
+            var path = "/rooms/$room_id/$membership";
+            path = path.replace("$room_id", encodeURIComponent(room_id));
+            path = path.replace("$membership", encodeURIComponent(membershipValue));
+
+            var data = {};
+            if (user_id !== undefined) {
+                data = { user_id: user_id };
+            }
+
+            // TODO: Use PUT with transaction IDs
+            return doRequest("POST", path, undefined, data);
+        },
+
+        // Change the membership of an another user
+        setMembership: function(room_id, user_id, membershipValue, reason) {
+            
+            // The REST path spec
+            var path = "/rooms/$room_id/state/m.room.member/$user_id";
+            path = path.replace("$room_id", encodeURIComponent(room_id));
+            path = path.replace("$user_id", user_id);
+
+            return doRequest("PUT", path, undefined, {
+                membership : membershipValue,
+                reason: reason
+            });
+        },
+           
+        // Bans a user from a room
+        ban: function(room_id, user_id, reason) {
+            var path = "/rooms/$room_id/ban";
+            path = path.replace("$room_id", encodeURIComponent(room_id));
+            
+            return doRequest("POST", path, undefined, {
+                user_id: user_id,
+                reason: reason
+            });
+        },
+        
+        // Unbans a user in a room
+        unban: function(room_id, user_id) {
+            // FIXME: To update when there will be homeserver API for unban 
+            // For now, do an unban by resetting the user membership to "leave"
+            return this.setMembership(room_id, user_id, "leave");
+        },
+        
+        // Kicks a user from a room
+        kick: function(room_id, user_id, reason) {
+            // Set the user membership to "leave" to kick him
+            return this.setMembership(room_id, user_id, "leave", reason);
+        },
+        
+        // Retrieves the room ID corresponding to a room alias
+        resolveRoomAlias:function(room_alias) {
+            var path = "/_matrix/client/api/v1/directory/room/$room_alias";
+            room_alias = encodeURIComponent(room_alias);
+
+            path = path.replace("$room_alias", room_alias);
+
+            return doRequest("GET", path, undefined, {});
+        },
+        
+        setName: function(room_id, name) {
+            var data = {
+                name: name
+            };
+            return this.sendStateEvent(room_id, "m.room.name", data);
+        },
+        
+        setTopic: function(room_id, topic) {
+            var data = {
+                topic: topic
+            };
+            return this.sendStateEvent(room_id, "m.room.topic", data);
+        },
+        
+        
+        sendStateEvent: function(room_id, eventType, content, state_key) {
+            var path = "/rooms/$room_id/state/"+eventType;
+            if (state_key !== undefined) {
+                path += "/" + state_key;
+            }
+            room_id = encodeURIComponent(room_id);
+            path = path.replace("$room_id", room_id);
+
+            return doRequest("PUT", path, undefined, content);
+        },
+
+        sendEvent: function(room_id, eventType, txn_id, content) {
+            // The REST path spec
+            var path = "/rooms/$room_id/send/"+eventType+"/$txn_id";
+
+            if (!txn_id) {
+                txn_id = "m" + new Date().getTime();
+            }
+
+            // Like the cmd client, escape room ids
+            room_id = encodeURIComponent(room_id);            
+
+            // Customize it
+            path = path.replace("$room_id", room_id);
+            path = path.replace("$txn_id", txn_id);
+
+            return doRequest("PUT", path, undefined, content);
+        },
+
+        sendMessage: function(room_id, txn_id, content) {
+            return this.sendEvent(room_id, 'm.room.message', txn_id, content);
+        },
+
+        // Send a text message
+        sendTextMessage: function(room_id, body, msg_id) {
+            var content = {
+                 msgtype: "m.text",
+                 body: body
+            };
+
+            return this.sendMessage(room_id, msg_id, content);
+        },
+
+        // Send an image message
+        sendImageMessage: function(room_id, image_url, image_body, msg_id) {
+            var content = {
+                 msgtype: "m.image",
+                 url: image_url,
+                 info: image_body,
+                 body: "Image"
+            };
+
+            return this.sendMessage(room_id, msg_id, content);
+        },
+
+        // Send an emote message
+        sendEmoteMessage: function(room_id, body, msg_id) {
+            var content = {
+                 msgtype: "m.emote",
+                 body: body
+            };
+
+            return this.sendMessage(room_id, msg_id, content);
+        },
+
+        redactEvent: function(room_id, event_id) {
+            var path = "/rooms/$room_id/redact/$event_id";
+            path = path.replace("$room_id", room_id);
+            path = path.replace("$event_id", event_id);
+            var content = {};
+            return doRequest("POST", path, undefined, content);
+        },
+
+        // get a snapshot of the members in a room.
+        getMemberList: function(room_id) {
+            // Like the cmd client, escape room ids
+            room_id = encodeURIComponent(room_id);
+
+            var path = "/rooms/$room_id/members";
+            path = path.replace("$room_id", room_id);
+            return doRequest("GET", path);
+        },
+        
+        paginateBackMessages: function(room_id, from_token, limit) {
+            var path = "/rooms/$room_id/messages";
+            path = path.replace("$room_id", room_id);
+            var params = {
+                from: from_token,
+                limit: limit,
+                dir: 'b'
+            };
+            return doRequest("GET", path, params);
+        },
+
+        // get a list of public rooms on your home server
+        publicRooms: function() {
+            var path = "/publicRooms";
+            return doRequest("GET", path);
+        },
+        
+        // get a user's profile
+        getProfile: function(userId) {
+            return this.getProfileInfo(userId);
+        },
+
+        // get a display name for this user ID
+        getDisplayName: function(userId) {
+            return this.getProfileInfo(userId, "displayname");
+        },
+
+        // get the profile picture url for this user ID
+        getProfilePictureUrl: function(userId) {
+            return this.getProfileInfo(userId, "avatar_url");
+        },
+
+        // update your display name
+        setDisplayName: function(newName) {
+            var content = {
+                displayname: newName
+            };
+            return this.setProfileInfo(content, "displayname");
+        },
+
+        // update your profile picture url
+        setProfilePictureUrl: function(newUrl) {
+            var content = {
+                avatar_url: newUrl
+            };
+            return this.setProfileInfo(content, "avatar_url");
+        },
+
+        setProfileInfo: function(data, info_segment) {
+            var path = "/profile/$user/" + info_segment;
+            path = path.replace("$user", config.user_id);
+            return doRequest("PUT", path, undefined, data);
+        },
+
+        getProfileInfo: function(userId, info_segment) {
+            var path = "/profile/"+userId
+            if (info_segment) path += '/' + info_segment;
+            return doRequest("GET", path);
+        },
+        
+        login: function(userId, password) {
+            // TODO We should be checking to make sure the client can support
+            // logging in to this HS, else use the fallback.
+            var path = "/login";
+            var data = {
+                "type": "m.login.password",
+                "user": userId,
+                "password": password  
+            };
+            return doRequest("POST", path, undefined, data);
+        },
+
+        // hit the Identity Server for a 3PID request.
+        linkEmail: function(email, clientSecret, sendAttempt) {
+            var path = "/_matrix/identity/api/v1/validate/email/requestToken";
+            var data = "clientSecret="+clientSecret+"&email=" + encodeURIComponent(email)+"&sendAttempt="+sendAttempt;
+            var headers = {};
+            headers["Content-Type"] = "application/x-www-form-urlencoded";
+            return doBaseRequest(config.identityServer, "POST", path, {}, data, headers); 
+        },
+
+        authEmail: function(clientSecret, sid, code) {
+            var path = "/_matrix/identity/api/v1/validate/email/submitToken";
+            var data = "token="+code+"&sid="+sid+"&clientSecret="+clientSecret;
+            var headers = {};
+            headers["Content-Type"] = "application/x-www-form-urlencoded";
+            return doBaseRequest(config.identityServer, "POST", path, {}, data, headers);
+        },
+
+        bindEmail: function(userId, tokenId, clientSecret) {
+            var path = "/_matrix/identity/api/v1/3pid/bind";
+            var data = "mxid="+encodeURIComponent(userId)+"&sid="+tokenId+"&clientSecret="+clientSecret;
+            var headers = {};
+            headers["Content-Type"] = "application/x-www-form-urlencoded";
+            return doBaseRequest(config.identityServer, "POST", path, {}, data, headers); 
+        },
+
+        lookup3pid: function(medium, address) {
+            var path = "/_matrix/identity/api/v1/lookup?medium="+encodeURIComponent(medium)+"&address="+encodeURIComponent(address);
+            return doBaseRequest(config.identityServer, "GET", path, {}, undefined, {}); 
+        },
+        
+        uploadContent: function(file) {
+            var path = "/_matrix/content";
+            var headers = {
+                "Content-Type": undefined // undefined means angular will figure it out
+            };
+            var params = {
+                access_token: config.access_token
+            };
+
+            // If the file is actually a Blob object, prevent $http from JSON-stringified it before sending
+            // (Equivalent to jQuery ajax processData = false)
+            var $httpParams;
+            if (file instanceof Blob) {
+                $httpParams = {
+                    transformRequest: angular.identity
+                };
+            }
+
+            return doBaseRequest(config.homeserver, "POST", path, params, file, headers, $httpParams);
+        },
+
+        /**
+         * Start listening on /events
+         * @param {String} from the token from which to listen events to
+         * @param {Integer} serverTimeout the time in ms the server will hold open the connection
+         * @param {Integer} clientTimeout the timeout in ms used at the client HTTP request level
+         * @returns a promise
+         */
+        getEventStream: function(from, serverTimeout, clientTimeout) {
+            var path = "/events";
+            var params = {
+                from: from,
+                timeout: serverTimeout
+            };
+
+            var $httpParams;
+            if (clientTimeout) {
+                // If the Internet connection is lost, this timeout is used to be able to
+                // cancel the current request and notify the client so that it can retry with a new request.
+                $httpParams = {
+                    timeout: clientTimeout
+                };
+            }
+
+            return doRequest("GET", path, params, undefined, $httpParams);
+        },
+
+        // Indicates if user authentications details are stored in cache
+        isUserLoggedIn: function() {
+            var config = this.config();
+
+            // User is considered logged in if his cache is not empty and contains
+            // an access token
+            if (config && config.access_token) {
+                return true;
+            }
+            else {
+                return false;
+            }
+        },
+        
+        // Enum of presence state
+        presence: {
+            offline: "offline",
+            unavailable: "unavailable",
+            online: "online",
+            free_for_chat: "free_for_chat"
+        },
+        
+        // Set the logged in user presence state
+        setUserPresence: function(presence) {
+            var path = "/presence/$user_id/status";
+            path = path.replace("$user_id", config.user_id);
+            return doRequest("PUT", path, undefined, {
+                presence: presence
+            });
+        },
+        
+        
+        /****** Permanent storage of user information ******/
+        
+        // Returns the current config
+        config: function() {
+            if (!config) {
+                config = localStorage.getItem("config");
+                if (config) {
+                    config = JSON.parse(config);
+
+                    // Reset the cache if the version loaded is not the expected one
+                    if (configVersion !== config.version) {
+                        config = undefined;
+                        this.saveConfig();
+                    }
+                }
+            }
+            return config;
+        },
+        
+        // Set a new config (Use saveConfig to actually store it permanently)
+        setConfig: function(newConfig) {
+            config = newConfig;
+            console.log("new IS: "+config.identityServer);
+        },
+        
+        // Commits config into permanent storage
+        saveConfig: function() {
+            config.version = configVersion;
+            localStorage.setItem("config", JSON.stringify(config));
+        },
+
+
+        /****** Room aliases management ******/
+
+        /**
+         * Get the room_alias & room_display_name which are computed from data 
+         * already retrieved from the server.
+         * @param {Room object} room one element of the array returned by the response
+         *  of rooms() and publicRooms()
+         * @returns {Object} {room_alias: "...", room_display_name: "..."}
+         */
+        getRoomAliasAndDisplayName: function(room) {
+            var result = {
+                room_alias: undefined,
+                room_display_name: undefined
+            };
+            var alias = this.getRoomIdToAliasMapping(room.room_id);
+            if (alias) {
+                // use the existing alias from storage
+                result.room_alias = alias;
+                result.room_display_name = alias;
+            }
+            // XXX: this only lets us learn aliases from our local HS - we should
+            // make the client stop returning this if we can trust m.room.aliases state events
+            else if (room.aliases && room.aliases[0]) {
+                // save the mapping
+                // TODO: select the smarter alias from the array
+                this.createRoomIdToAliasMapping(room.room_id, room.aliases[0]);
+                result.room_display_name = room.aliases[0];
+                result.room_alias = room.aliases[0];
+            }
+            else if (room.membership === "invite" && "inviter" in room) {
+                result.room_display_name = room.inviter + "'s room";
+            }
+            else {
+                // last resort use the room id
+                result.room_display_name = room.room_id;
+            }
+            return result;
+        },
+        
+        createRoomIdToAliasMapping: function(roomId, alias) {
+            roomIdToAlias[roomId] = alias;
+            aliasToRoomId[alias] = roomId;
+        },
+        
+        getRoomIdToAliasMapping: function(roomId) {
+            var alias = roomIdToAlias[roomId];
+            //console.log("looking for alias for " + roomId + "; found: " + alias);
+            return alias;
+        },
+
+        getAliasToRoomIdMapping: function(alias) {
+            var roomId = aliasToRoomId[alias];
+            //console.log("looking for roomId for " + alias + "; found: " + roomId);
+            return roomId;
+        },
+            
+        /**
+         * Change or reset the power level of a user
+         * @param {String} room_id the room id
+         * @param {String} user_id the user id
+         * @param {Number} powerLevel The desired power level.
+         *    If undefined, the user power level will be reset, ie he will use the default room user power level
+         * @param event The existing m.room.power_levels event if one exists.
+         * @returns {promise} an $http promise
+         */
+        setUserPowerLevel: function(room_id, user_id, powerLevel, event) {
+            var content = {};
+            if (event) {
+                // if there is an existing event, copy the content as it contains
+                // the power level values for other members which we do not want
+                // to modify.
+                content = angular.copy(event.content);
+            }
+            content[user_id] = powerLevel;
+                
+            var path = "/rooms/$room_id/state/m.room.power_levels";
+            path = path.replace("$room_id", encodeURIComponent(room_id));
+                
+            return doRequest("PUT", path, undefined, content);
+        },
+
+        getTurnServer: function() {
+            return doRequest("GET", "/voip/turnServer");
+        }
+
+    };
+}]);
diff --git a/syweb/webclient/components/matrix/model-service.js b/syweb/webclient/components/matrix/model-service.js
new file mode 100644
index 0000000000..8b2ee877b1
--- /dev/null
+++ b/syweb/webclient/components/matrix/model-service.js
@@ -0,0 +1,170 @@
+/*
+Copyright 2014 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+/*
+This service serves as the entry point for all models in the app. If access to
+underlying data in a room is required, then this service should be used as the
+dependency.
+*/
+// NB: This is more explicit than linking top-level models to $rootScope
+//     in that by adding this service as a dep you are clearly saying "this X
+//     needs access to the underlying data store", rather than polluting the
+//     $rootScope.
+angular.module('modelService', [])
+.factory('modelService', ['matrixService', function(matrixService) {
+    
+    /***** Room Object *****/
+    var Room = function Room(room_id) {
+        this.room_id = room_id;
+        this.old_room_state = new RoomState();
+        this.current_room_state = new RoomState();
+        this.events = []; // events which can be displayed on the UI. TODO move?
+    };
+    Room.prototype = {
+        addMessageEvents: function addMessageEvents(events, toFront) {
+            for (var i=0; i<events.length; i++) {
+                this.addMessageEvent(events[i], toFront);
+            }
+        },
+        
+        addMessageEvent: function addMessageEvent(event, toFront) {
+            // every message must reference the RoomMember which made it *at
+            // that time* so things like display names display correctly.
+            var stateAtTheTime = toFront ? this.old_room_state : this.current_room_state;
+            event.__room_member = stateAtTheTime.getStateEvent("m.room.member", event.user_id);
+            if (event.type === "m.room.member" && event.content.membership === "invite") {
+                // give information on both the inviter and invitee
+                event.__target_room_member = stateAtTheTime.getStateEvent("m.room.member", event.state_key);
+            }
+            
+            if (toFront) {
+                this.events.unshift(event);
+            }
+            else {
+                this.events.push(event);
+            }
+        },
+        
+        addOrReplaceMessageEvent: function addOrReplaceMessageEvent(event, toFront) {
+            // Start looking from the tail since the first goal of this function 
+            // is to find a message among the latest ones
+            for (var i = this.events.length - 1; i >= 0; i--) {
+                var storedEvent = this.events[i];
+                if (storedEvent.event_id === event.event_id) {
+                    // It's clobbering time!
+                    this.events[i] = event;
+                    return;
+                }
+            }
+            this.addMessageEvent(event, toFront);
+        },
+        
+        leave: function leave() {
+            return matrixService.leave(this.room_id);
+        }
+    };
+    
+    /***** Room State Object *****/
+    var RoomState = function RoomState() {
+        // list of RoomMember
+        this.members = {}; 
+        // state events, the key is a compound of event type + state_key
+        this.state_events = {}; 
+        this.pagination_token = ""; 
+    };
+    RoomState.prototype = {
+        // get a state event for this room from this.state_events. State events
+        // are unique per type+state_key tuple, with a lot of events using 0-len
+        // state keys. To make it not Really Annoying to access, this method is
+        // provided which can just be given the type and it will return the 
+        // 0-len event by default.
+        state: function state(type, state_key) {
+            if (!type) {
+                return undefined; // event type MUST be specified
+            }
+            if (!state_key) {
+                return this.state_events[type]; // treat as 0-len state key
+            }
+            return this.state_events[type + state_key];
+        },
+        
+        storeStateEvent: function storeState(event) {
+            this.state_events[event.type + event.state_key] = event;
+            if (event.type === "m.room.member") {
+                this.members[event.state_key] = event;
+            }
+        },
+        
+        storeStateEvents: function storeState(events) {
+            if (!events) {
+                return;
+            }
+            for (var i=0; i<events.length; i++) {
+                this.storeStateEvent(events[i]);
+            }
+        },
+        
+        getStateEvent: function getStateEvent(event_type, state_key) {
+            return this.state_events[event_type + state_key];
+        }
+    };
+    
+    /***** Room Member Object *****/
+    var RoomMember = function RoomMember() {
+        this.event = {}; // the m.room.member event representing the RoomMember.
+        this.user = undefined; // the User
+    };
+    
+    /***** User Object *****/
+    var User = function User() {
+        this.event = {}; // the m.presence event representing the User.
+    };
+    
+    // rooms are stored here when they come in.
+    var rooms = {
+        // roomid: <Room>
+    };
+    
+    console.log("Models inited.");
+    
+    return {
+    
+        getRoom: function(roomId) {
+            if(!rooms[roomId]) {
+                rooms[roomId] = new Room(roomId);
+            }
+            return rooms[roomId];
+        },
+        
+        getRooms: function() {
+            return rooms;
+        },
+        
+        /**
+         * Get the member object of a room member
+         * @param {String} room_id the room id
+         * @param {String} user_id the id of the user
+         * @returns {undefined | Object} the member object of this user in this room if he is part of the room
+         */
+        getMember: function(room_id, user_id) {
+            var room = this.getRoom(room_id);
+            return room.current_room_state.members[user_id];
+        }
+    
+    };
+}]);
diff --git a/syweb/webclient/components/matrix/notification-service.js b/syweb/webclient/components/matrix/notification-service.js
new file mode 100644
index 0000000000..9a911413c3
--- /dev/null
+++ b/syweb/webclient/components/matrix/notification-service.js
@@ -0,0 +1,104 @@
+/*
+Copyright 2014 OpenMarket Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+'use strict';
+
+/*
+This service manages notifications: enabling, creating and showing them. This
+also contains 'bing word' logic.
+*/
+angular.module('notificationService', [])
+.factory('notificationService', ['$timeout', function($timeout) {
+
+    var getLocalPartFromUserId = function(user_id) {
+        if (!user_id) {
+            return null;
+        }
+        var localpartRegex = /@(.*):\w+/i
+        var results = localpartRegex.exec(user_id);
+        if (results && results.length == 2) {
+            return results[1];
+        }
+        return null;
+    };
+    
+    return {
+    
+        containsBingWord: function(userId, displayName, bingWords, content) {
+            // case-insensitive name check for user_id OR display_name if they exist
+            var userRegex = "";
+            if (userId) {
+                var localpart = getLocalPartFromUserId(userId);
+                if (localpart) {
+                    localpart = localpart.toLocaleLowerCase();
+                    userRegex += "\\b" + localpart + "\\b";
+                }
+            }
+            if (displayName) {
+                displayName = displayName.toLocaleLowerCase();
+                if (userRegex.length > 0) {
+                    userRegex += "|";
+                }
+                userRegex += "\\b" + displayName + "\\b";
+            }
+
+            var regexList = [new RegExp(userRegex, 'i')];
+            
+            // bing word list check
+            if (bingWords && bingWords.length > 0) {
+                for (var i=0; i<bingWords.length; i++) {
+                    var re = RegExp(bingWords[i], 'i');
+                    regexList.push(re);
+                }
+            }
+            return this.hasMatch(regexList, content);
+        },
+    
+        hasMatch: function(regExps, content) {
+            if (!content || $.type(content) != "string") {
+                return false;
+            }
+            
+            if (regExps && regExps.length > 0) {
+                for (var i=0; i<regExps.length; i++) {
+                    if (content.search(regExps[i]) != -1) {
+                        return true;
+                    }
+                }
+            }
+            return false;
+        },
+        
+        showNotification: function(title, body, icon, onclick) {
+            var notification = new window.Notification(
+                title,
+                {
+                    "body": body,
+                    "icon": icon
+                }
+            );
+
+            if (onclick) {
+                notification.onclick = onclick;
+            }
+
+            $timeout(function() {
+                notification.close();
+            }, 5 * 1000);
+        }
+    };
+
+}]);
diff --git a/syweb/webclient/components/matrix/presence-service.js b/syweb/webclient/components/matrix/presence-service.js
new file mode 100644
index 0000000000..b487e3d3bd
--- /dev/null
+++ b/syweb/webclient/components/matrix/presence-service.js
@@ -0,0 +1,113 @@
+/*
+ Copyright 2014 OpenMarket Ltd
+ 
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+ 
+ http://www.apache.org/licenses/LICENSE-2.0
+ 
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+'use strict';
+
+/*
+ * This service tracks user activity on the page to determine his presence state.
+ * Any state change will be sent to the Home Server.
+ */
+angular.module('mPresence', [])
+.service('mPresence', ['$timeout', 'matrixService', function ($timeout, matrixService) {
+
+    // Time in ms after that a user is considered as unavailable/away
+    var UNAVAILABLE_TIME = 3 * 60000; // 3 mins
+   
+    // The current presence state
+    var state = undefined;
+
+    var self =this;
+    var timer;
+    
+    /**
+     * Start listening the user activity to evaluate his presence state.
+     * Any state change will be sent to the Home Server.
+     */
+    this.start = function() {
+        if (undefined === state) {
+            // The user is online if he moves the mouser or press a key
+            document.onmousemove = resetTimer;
+            document.onkeypress = resetTimer;
+            
+            resetTimer();
+        }
+    };
+    
+    /**
+     * Stop tracking user activity
+     */
+    this.stop = function() {
+        if (timer) {
+            $timeout.cancel(timer);
+            timer = undefined;
+        }
+        state = undefined;
+    };
+    
+    /**
+     * Get the current presence state.
+     * @returns {matrixService.presence} the presence state
+     */
+    this.getState = function() {
+        return state;
+    };
+    
+    /**
+     * Set the presence state.
+     * If the state has changed, the Home Server will be notified.
+     * @param {matrixService.presence} newState the new presence state
+     */
+    this.setState = function(newState) {
+        if (newState !== state) {
+            console.log("mPresence - New state: " + newState);
+
+            state = newState;
+
+            // Inform the HS on the new user state
+            matrixService.setUserPresence(state).then(
+                function() {
+
+                },
+                function(error) {
+                    console.log("mPresence - Failed to send new presence state: " + JSON.stringify(error));
+                });
+        }
+    };
+    
+    /**
+     * Callback called when the user made no action on the page for UNAVAILABLE_TIME ms.
+     * @private
+     */
+    function onUnvailableTimerFire() {
+        self.setState(matrixService.presence.unavailable);
+    }
+
+    /**
+     * Callback called when the user made an action on the page
+     * @private
+     */
+    function resetTimer() {
+        // User is still here
+        self.setState(matrixService.presence.online);
+        
+        // Re-arm the timer
+        $timeout.cancel(timer);
+        timer = $timeout(onUnvailableTimerFire, UNAVAILABLE_TIME);
+    }    
+
+}]);
+
+
diff --git a/syweb/webclient/components/utilities/utilities-service.js b/syweb/webclient/components/utilities/utilities-service.js
new file mode 100644
index 0000000000..b417cc5b39
--- /dev/null
+++ b/syweb/webclient/components/utilities/utilities-service.js
@@ -0,0 +1,151 @@
+/*
+ Copyright 2014 OpenMarket Ltd
+ 
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+ 
+ http://www.apache.org/licenses/LICENSE-2.0
+ 
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+ */
+
+'use strict';
+
+/*
+ * This service contains multipurpose helper functions.
+ */
+angular.module('mUtilities', [])
+.service('mUtilities', ['$q', function ($q) {
+    /*
+     * Get the size of an image
+     * @param {File|Blob} imageFile the file containing the image
+     * @returns {promise} A promise that will be resolved by an object with 2 members:
+     *   width & height
+     */
+    this.getImageSize = function(imageFile) {
+        var deferred = $q.defer();
+        
+        // Load the file into an html element
+        var img = document.createElement("img");
+        
+        var reader = new FileReader();  
+        reader.onload = function(e) {   
+            img.src = e.target.result;
+            
+            // Once ready, returns its size
+            img.onload = function() {
+                deferred.resolve({
+                    width: img.width,
+                    height: img.height
+                });
+            };
+            img.onerror = function(e) {
+                deferred.reject(e);
+            };
+        };
+        reader.onerror = function(e) {
+            deferred.reject(e);
+        };
+        reader.readAsDataURL(imageFile);
+        
+        return deferred.promise;
+    };
+
+    /*
+     * Resize the image to fit in a square of the side maxSize. 
+     * The aspect ratio is kept. The returned image data uses JPEG compression.
+     * Source: http://hacks.mozilla.org/2011/01/how-to-develop-a-html5-image-uploader/
+     * @param {File} imageFile the file containing the image 
+     * @param {Integer} maxSize the max side size 
+     * @returns {promise} A promise that will be resolved by a Blob object containing
+     *   the resized image data
+     */
+    this.resizeImage = function(imageFile, maxSize) {
+        var self = this;
+        var deferred = $q.defer();
+
+        var canvas = document.createElement("canvas");
+
+        var img = document.createElement("img");
+        var reader = new FileReader();  
+        reader.onload = function(e) {
+
+            img.src = e.target.result;
+            
+            // Once ready, returns its size
+            img.onload = function() {
+                var ctx = canvas.getContext("2d");
+                ctx.drawImage(img, 0, 0);
+
+                var MAX_WIDTH = maxSize;
+                var MAX_HEIGHT = maxSize;
+                var width = img.width;
+                var height = img.height;
+
+                if (width > height) {
+                    if (width > MAX_WIDTH) {
+                        height *= MAX_WIDTH / width;
+                        width = MAX_WIDTH;
+                    }
+                } else {
+                    if (height > MAX_HEIGHT) {
+                        width *= MAX_HEIGHT / height;
+                        height = MAX_HEIGHT;
+                    }
+                }
+                canvas.width = width;
+                canvas.height = height;
+                var ctx = canvas.getContext("2d");
+                ctx.drawImage(img, 0, 0, width, height);
+
+                // Extract image data in the same format as the original one.
+                // The 0.7 compression value will work with formats that supports it like JPEG.
+                var dataUrl = canvas.toDataURL(imageFile.type, 0.7); 
+                deferred.resolve(self.dataURItoBlob(dataUrl));
+            };
+            img.onerror = function(e) {
+                deferred.reject(e);
+            };
+        };
+        reader.onerror = function(e) {
+            deferred.reject(e);
+        };
+        reader.readAsDataURL(imageFile);
+
+        return deferred.promise;
+    };
+
+    /*
+     * Convert a dataURI string to a blob 
+     * Source: http://stackoverflow.com/a/17682951
+     * @param {String} dataURI the dataURI can be a base64 encoded string or an URL encoded string.
+     * @returns {Blob} the blob
+     */
+    this.dataURItoBlob = function(dataURI) {
+        // convert base64 to raw binary data held in a string
+        // doesn't handle URLEncoded DataURIs
+        var byteString;
+        if (dataURI.split(',')[0].indexOf('base64') >= 0)
+            byteString = atob(dataURI.split(',')[1]);
+        else
+            byteString = unescape(dataURI.split(',')[1]);
+        // separate out the mime component
+        var mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0];
+
+        // write the bytes of the string to an ArrayBuffer
+        var ab = new ArrayBuffer(byteString.length);
+        var ia = new Uint8Array(ab);
+        for (var i = 0; i < byteString.length; i++) {
+            ia[i] = byteString.charCodeAt(i);
+        }
+
+        // write the ArrayBuffer to a blob, and you're done
+        return new Blob([ab],{type: mimeString});
+    };
+
+}]);
\ No newline at end of file