summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--README.rst18
-rw-r--r--synapse/config/captcha.py14
-rw-r--r--synapse/storage/stream.py2
-rw-r--r--tests/storage/test_stream.py226
-rw-r--r--webclient/app-controller.js35
-rwxr-xr-xwebclient/app.css88
-rw-r--r--webclient/app.js19
-rw-r--r--webclient/components/fileInput/file-input-directive.js22
-rw-r--r--webclient/components/matrix/event-handler-service.js22
-rw-r--r--webclient/components/matrix/matrix-call.js144
-rw-r--r--webclient/components/matrix/matrix-filter.js68
-rw-r--r--webclient/components/matrix/matrix-phone-service.js12
-rw-r--r--webclient/index.html41
-rw-r--r--webclient/room/room-controller.js40
-rw-r--r--webclient/room/room.html21
-rw-r--r--webclient/test/README9
16 files changed, 675 insertions, 106 deletions
diff --git a/README.rst b/README.rst
index 6791e686b7..6f7940e742 100644
--- a/README.rst
+++ b/README.rst
@@ -102,7 +102,7 @@ service provider in Matrix, unlike WhatsApp, Facebook, Hangouts, etc.
 
 Synapse ships with two basic demo Matrix clients: webclient (a basic group chat
 web client demo implemented in AngularJS) and cmdclient (a basic Python
-commandline utility which lets you easily see what the JSON APIs are up to).
+command line utility which lets you easily see what the JSON APIs are up to).
 
 We'd like to invite you to take a look at the Matrix spec, try to run a
 homeserver, and join the existing Matrix chatrooms already out there, experiment
@@ -122,7 +122,7 @@ Homeserver Installation
 First, the dependencies need to be installed.  Start by installing 
 'python2.7-dev' and the various tools of the compiler toolchain.
 
-  Installing prerequisites on ubuntu::
+  Installing prerequisites on Ubuntu::
 
     $ sudo apt-get install build-essential python2.7-dev libffi-dev
 
@@ -151,8 +151,8 @@ you can check PyNaCl out of git directly (https://github.com/pyca/pynacl) and
 installing it. Installing PyNaCl using pip may also work (remember to remove any
 other versions installed by setuputils in, for example, ~/.local/lib).
 
-On OSX, if you encounter ``clang: error: unknown argument: '-mno-fused-madd'`` you will
-need to ``export CFLAGS=-Qunused-arguments``.
+On OSX, if you encounter ``clang: error: unknown argument: '-mno-fused-madd'`` 
+you will need to ``export CFLAGS=-Qunused-arguments``.
 
 This will run a process of downloading and installing into your
 user's .local/lib directory all of the required dependencies that are
@@ -203,9 +203,10 @@ For the first form, simply pass the required hostname (of the machine) as the
         --generate-config
     $ python synapse/app/homeserver.py --config-path homeserver.config
     
-Alternatively, you can run synapse via synctl - running ``synctl start`` to generate a
-homeserver.yaml config file, where you can then edit server-name to specify
-machine.my.domain.name, and then set the actual server running again with synctl start.
+Alternatively, you can run synapse via synctl - running ``synctl start`` to 
+generate a homeserver.yaml config file, where you can then edit server-name to 
+specify machine.my.domain.name, and then set the actual server running again 
+with synctl start.
 
 For the second form, first create your SRV record and publish it in DNS. This
 needs to be named _matrix._tcp.YOURDOMAIN, and point at at least one hostname
@@ -293,7 +294,8 @@ track 3PID logins and publish end-user public keys.
 
 It's currently early days for identity servers as Matrix is not yet using 3PIDs
 as the primary means of identity and E2E encryption is not complete. As such,
-we are running a single identity server (http://matrix.org:8090) at the current time.
+we are running a single identity server (http://matrix.org:8090) at the current 
+time.
 
 
 Where's the spec?!
diff --git a/synapse/config/captcha.py b/synapse/config/captcha.py
index a97a5bab1e..8ebcfc3623 100644
--- a/synapse/config/captcha.py
+++ b/synapse/config/captcha.py
@@ -14,13 +14,16 @@
 
 from ._base import Config
 
+
 class CaptchaConfig(Config):
 
     def __init__(self, args):
         super(CaptchaConfig, self).__init__(args)
         self.recaptcha_private_key = args.recaptcha_private_key
         self.enable_registration_captcha = args.enable_registration_captcha
-        self.captcha_ip_origin_is_x_forwarded = args.captcha_ip_origin_is_x_forwarded
+        self.captcha_ip_origin_is_x_forwarded = (
+            args.captcha_ip_origin_is_x_forwarded
+        )
 
     @classmethod
     def add_arguments(cls, parser):
@@ -32,11 +35,12 @@ class CaptchaConfig(Config):
         )
         group.add_argument(
             "--enable-registration-captcha", type=bool, default=False,
-            help="Enables ReCaptcha checks when registering, preventing signup "+
-            "unless a captcha is answered. Requires a valid ReCaptcha public/private key."
+            help="Enables ReCaptcha checks when registering, preventing signup"
+            + " unless a captcha is answered. Requires a valid ReCaptcha "
+            + "public/private key."
         )
         group.add_argument(
             "--captcha_ip_origin_is_x_forwarded", type=bool, default=False,
-            help="When checking captchas, use the X-Forwarded-For (XFF) header as the client IP "+
-            "and not the actual client IP."
+            help="When checking captchas, use the X-Forwarded-For (XFF) header"
+            + " as the client IP and not the actual client IP."
         )
\ No newline at end of file
diff --git a/synapse/storage/stream.py b/synapse/storage/stream.py
index 8c766b8a00..a76fecf24f 100644
--- a/synapse/storage/stream.py
+++ b/synapse/storage/stream.py
@@ -146,7 +146,7 @@ class StreamStore(SQLBaseStore):
         current_room_membership_sql = (
             "SELECT m.room_id FROM room_memberships as m "
             "INNER JOIN current_state_events as c ON m.event_id = c.event_id "
-            "WHERE m.user_id = ?"
+            "WHERE m.user_id = ? AND m.membership = 'join'"
         )
 
         # We also want to get any membership events about that user, e.g.
diff --git a/tests/storage/test_stream.py b/tests/storage/test_stream.py
new file mode 100644
index 0000000000..ab30e6ea25
--- /dev/null
+++ b/tests/storage/test_stream.py
@@ -0,0 +1,226 @@
+# -*- coding: utf-8 -*-
+# Copyright 2014 OpenMarket Ltd
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from tests import unittest
+from twisted.internet import defer
+
+from synapse.server import HomeServer
+from synapse.api.constants import Membership
+from synapse.api.events.room import RoomMemberEvent, MessageEvent
+
+from tests.utils import SQLiteMemoryDbPool
+
+
+class StreamStoreTestCase(unittest.TestCase):
+
+    @defer.inlineCallbacks
+    def setUp(self):
+        db_pool = SQLiteMemoryDbPool()
+        yield db_pool.prepare()
+
+        hs = HomeServer(
+            "test",
+            db_pool=db_pool,
+        )
+
+        self.store = hs.get_datastore()
+        self.event_factory = hs.get_event_factory()
+
+        self.u_alice = hs.parse_userid("@alice:test")
+        self.u_bob = hs.parse_userid("@bob:test")
+
+        self.room1 = hs.parse_roomid("!abc123:test")
+        self.room2 = hs.parse_roomid("!xyx987:test")
+
+        self.depth = 1
+
+    @defer.inlineCallbacks
+    def inject_room_member(self, room, user, membership, prev_state=None):
+        self.depth += 1
+
+        event = self.event_factory.create_event(
+            etype=RoomMemberEvent.TYPE,
+            user_id=user.to_string(),
+            state_key=user.to_string(),
+            room_id=room.to_string(),
+            membership=membership,
+            content={"membership": membership},
+            depth=self.depth,
+        )
+
+        if prev_state:
+            event.prev_state = prev_state
+
+        # Have to create a join event using the eventfactory
+        yield self.store.persist_event(
+            event
+        )
+
+        defer.returnValue(event)
+
+    @defer.inlineCallbacks
+    def inject_message(self, room, user, body):
+        self.depth += 1
+
+        # Have to create a join event using the eventfactory
+        yield self.store.persist_event(
+            self.event_factory.create_event(
+                etype=MessageEvent.TYPE,
+                user_id=user.to_string(),
+                room_id=room.to_string(),
+                content={"body": body, "msgtype": u"message"},
+                depth=self.depth,
+            )
+        )
+
+    @defer.inlineCallbacks
+    def test_event_stream_get_other(self):
+        # Both bob and alice joins the room
+        yield self.inject_room_member(
+            self.room1, self.u_alice, Membership.JOIN
+        )
+        yield self.inject_room_member(
+            self.room1, self.u_bob, Membership.JOIN
+        )
+
+        # Initial stream key:
+        start = yield self.store.get_room_events_max_id()
+
+        yield self.inject_message(self.room1, self.u_alice, u"test")
+
+        end = yield self.store.get_room_events_max_id()
+
+        results, _ = yield self.store.get_room_events_stream(
+            self.u_bob.to_string(),
+            start,
+            end,
+            None,  # Is currently ignored
+        )
+
+        self.assertEqual(1, len(results))
+
+        event = results[0]
+
+        self.assertObjectHasAttributes(
+            {
+                "type": MessageEvent.TYPE,
+                "user_id": self.u_alice.to_string(),
+                "content": {"body": "test", "msgtype": "message"},
+            },
+            event,
+        )
+
+    @defer.inlineCallbacks
+    def test_event_stream_get_own(self):
+        # Both bob and alice joins the room
+        yield self.inject_room_member(
+            self.room1, self.u_alice, Membership.JOIN
+        )
+        yield self.inject_room_member(
+            self.room1, self.u_bob, Membership.JOIN
+        )
+
+        # Initial stream key:
+        start = yield self.store.get_room_events_max_id()
+
+        yield self.inject_message(self.room1, self.u_alice, u"test")
+
+        end = yield self.store.get_room_events_max_id()
+
+        results, _ = yield self.store.get_room_events_stream(
+            self.u_alice.to_string(),
+            start,
+            end,
+            None,  # Is currently ignored
+        )
+
+        self.assertEqual(1, len(results))
+
+        event = results[0]
+
+        self.assertObjectHasAttributes(
+            {
+                "type": MessageEvent.TYPE,
+                "user_id": self.u_alice.to_string(),
+                "content": {"body": "test", "msgtype": "message"},
+            },
+            event,
+        )
+
+    @defer.inlineCallbacks
+    def test_event_stream_join_leave(self):
+        # Both bob and alice joins the room
+        yield self.inject_room_member(
+            self.room1, self.u_alice, Membership.JOIN
+        )
+        yield self.inject_room_member(
+            self.room1, self.u_bob, Membership.JOIN
+        )
+
+        # Then bob leaves again.
+        yield self.inject_room_member(
+            self.room1, self.u_bob, Membership.LEAVE
+        )
+
+        # Initial stream key:
+        start = yield self.store.get_room_events_max_id()
+
+        yield self.inject_message(self.room1, self.u_alice, u"test")
+
+        end = yield self.store.get_room_events_max_id()
+
+        results, _ = yield self.store.get_room_events_stream(
+            self.u_bob.to_string(),
+            start,
+            end,
+            None,  # Is currently ignored
+        )
+
+        # We should not get the message, as it happened *after* bob left.
+        self.assertEqual(0, len(results))
+
+    @defer.inlineCallbacks
+    def test_event_stream_prev_content(self):
+        yield self.inject_room_member(
+            self.room1, self.u_bob, Membership.JOIN
+        )
+
+        event1 = yield self.inject_room_member(
+            self.room1, self.u_alice, Membership.JOIN
+        )
+
+        start = yield self.store.get_room_events_max_id()
+
+        event2 = yield self.inject_room_member(
+            self.room1, self.u_alice, Membership.JOIN,
+            prev_state=event1.event_id,
+        )
+
+        end = yield self.store.get_room_events_max_id()
+
+        results, _ = yield self.store.get_room_events_stream(
+            self.u_bob.to_string(),
+            start,
+            end,
+            None,  # Is currently ignored
+        )
+
+        # We should not get the message, as it happened *after* bob left.
+        self.assertEqual(1, len(results))
+
+        event = results[0]
+
+        self.assertTrue(hasattr(event, "prev_content"), msg="No prev_content key")
diff --git a/webclient/app-controller.js b/webclient/app-controller.js
index 6338624486..0e823b43e7 100644
--- a/webclient/app-controller.js
+++ b/webclient/app-controller.js
@@ -26,6 +26,12 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
          
     // Check current URL to avoid to display the logout button on the login page
     $scope.location = $location.path();
+
+    // disable nganimate for the local and remote video elements because ngAnimate appears
+    // to be buggy and leaves animation classes on the video elements causing them to show
+    // when they should not (their animations are pure CSS3)
+    $animate.enabled(false, angular.element('#localVideo'));
+    $animate.enabled(false, angular.element('#remoteVideo'));
     
     // Update the location state when the ng location changed
     $rootScope.$on('$routeChangeSuccess', function (event, current, previous) {
@@ -93,7 +99,13 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
     };
 
     $rootScope.$watch('currentCall', function(newVal, oldVal) {
-        if (!$rootScope.currentCall) return;
+        if (!$rootScope.currentCall) {
+            // This causes the still frame to be flushed out of the video elements,
+            // avoiding a flash of the last frame of the previous call when starting the next
+            angular.element('#localVideo')[0].load();
+            angular.element('#remoteVideo')[0].load();
+            return;
+        }
 
         var roomMembers = angular.copy($rootScope.events.rooms[$rootScope.currentCall.room_id].members);
         delete roomMembers[matrixService.config().user_id];
@@ -126,6 +138,7 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
             angular.element('#ringAudio')[0].pause();
             angular.element('#ringbackAudio')[0].pause();
             angular.element('#callendAudio')[0].play();
+            $scope.videoMode = undefined;
         } else if (newVal == 'ended' && oldVal == 'invite_sent' && $rootScope.currentCall.hangupParty == 'remote') {
             angular.element('#ringAudio')[0].pause();
             angular.element('#ringbackAudio')[0].pause();
@@ -138,6 +151,20 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
             angular.element('#ringbackAudio')[0].pause();
         } else if (oldVal == 'ringing') {
             angular.element('#ringAudio')[0].pause();
+        } else if (newVal == 'connected') {
+            $timeout(function() {
+                if ($scope.currentCall.type == 'video') $scope.videoMode = 'large';
+            }, 500);
+        }
+
+        if ($rootScope.currentCall && $rootScope.currentCall.type == 'video' && $rootScope.currentCall.state != 'connected') {
+            $scope.videoMode = 'mini';
+        }
+    });
+    $rootScope.$watch('currentCall.type', function(newVal, oldVal) {
+        // need to listen for this too as the type of the call won't be know when it's created
+        if ($rootScope.currentCall && $rootScope.currentCall.type == 'video' && $rootScope.currentCall.state != 'connected') {
+            $scope.videoMode = 'mini';
         }
     });
 
@@ -150,6 +177,8 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
         }
         call.onError = $scope.onCallError;
         call.onHangup = $scope.onCallHangup;
+        call.localVideoElement = angular.element('#localVideo')[0];
+        call.remoteVideoElement = angular.element('#remoteVideo')[0];
         $rootScope.currentCall = call;
     });
 
@@ -170,7 +199,7 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
     
     $rootScope.onCallError = function(errStr) {
         $scope.feedback = errStr;
-    }
+    };
 
     $rootScope.onCallHangup = function(call) {
         if (call == $rootScope.currentCall) {
@@ -178,5 +207,5 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even
                 if (call == $rootScope.currentCall) $rootScope.currentCall = undefined;
             }, 4070);
         }
-    }
+    };
 }]);
diff --git a/webclient/app.css b/webclient/app.css
index 736aea660c..bdf475d635 100755
--- a/webclient/app.css
+++ b/webclient/app.css
@@ -20,7 +20,12 @@ a:visited { color: #666; }
 a:hover   { color: #000; }
 a:active  { color: #000; }
 
-#page {
+textarea, input {
+   font-family: inherit;
+   font-size: inherit;
+}
+
+.page {
     min-height: 100%;
     margin-bottom: -32px; /* to make room for the footer */
 }
@@ -34,9 +39,15 @@ a:active  { color: #000; }
     padding-right: 20px;
 }
 
+#unsupportedBrowser {
+    padding-top: 240px;
+    text-align: center;
+}
+
 #header
 {
     position: absolute;
+    z-index: 2;
     top: 0px;
     width: 100%;
     background-color: #333;
@@ -89,6 +100,80 @@ a:active  { color: #000; }
     font-size: 80%;
 }
 
+#videoBackground {
+    position: absolute;
+    height: 100%;
+    width: 100%;
+    top: 0px;
+    left: 0px;
+    z-index: 1;
+    background-color: rgba(0,0,0,0.0);
+    pointer-events: none;
+    transition: background-color linear 500ms;
+}
+
+#videoBackground.large {
+    background-color: rgba(0,0,0,0.85);
+    pointer-events: auto;
+}
+
+#videoContainer {
+    position: relative;
+    top: 32px;
+    max-width: 1280px;
+    margin: auto;
+}
+
+#videoContainerPadding {
+    width: 1280px;
+}
+
+#localVideo {
+    position: absolute;
+    width: 128px;
+    height: 72px;
+    z-index: 1;
+    transition: left linear 500ms, top linear 500ms, width linear 500ms, height linear 500ms;
+}
+
+#localVideo.mini {
+    top: 0px;
+    left: 130px;
+}
+
+#localVideo.large {
+    top: 70px;
+    left: 20px;
+}
+
+#localVideo.ended {
+    -webkit-filter: grayscale(1);
+    filter: grayscale(1);
+}
+
+#remoteVideo {
+    position: relative;
+    height: auto;
+    transition: left linear 500ms, top linear 500ms, width linear 500ms, height linear 500ms;
+}
+
+#remoteVideo.mini {
+    left: 260px;
+    top: 0px;
+    width: 128px;
+}
+
+#remoteVideo.large {
+    left: 0px;
+    top: 50px;
+    width: 100%;
+}
+
+#remoteVideo.ended {
+    -webkit-filter: grayscale(1);
+    filter: grayscale(1);
+}
+
 #headerContent {
     color: #ccc;
     max-width: 1280px;
@@ -96,6 +181,7 @@ a:active  { color: #000; }
     text-align: right;
     height: 32px;
     line-height: 32px;
+    position: relative;
 }
 
 #headerContent a:link,
diff --git a/webclient/app.js b/webclient/app.js
index 9370f773b3..31118304c6 100644
--- a/webclient/app.js
+++ b/webclient/app.js
@@ -80,7 +80,24 @@ matrixWebClient.config(['$routeProvider', '$provide', '$httpProvider',
         $httpProvider.interceptors.push('AccessTokenInterceptor');
     }]);
 
-matrixWebClient.run(['$location', 'matrixService', function($location, matrixService) {
+matrixWebClient.run(['$location', '$rootScope', 'matrixService', function($location, $rootScope, matrixService) {
+
+    // Check browser support
+    // Support IE from 9.0. AngularJS needs some tricks to run on IE8 and below
+    var version = parseFloat($.browser.version);
+    if ($.browser.msie && version < 9.0) {
+        $rootScope.unsupportedBrowser = {
+            browser: navigator.userAgent,
+            reason: "Internet Explorer is supported from version 9"
+        };
+    }
+    // The app requires localStorage
+    if(typeof(Storage) === "undefined") {
+        $rootScope.unsupportedBrowser = {
+            browser: navigator.userAgent,
+            reason: "It does not support HTML local storage"
+        };
+    }
 
     // If user auth details are not in cache, go to the login page
     if (!matrixService.isUserLoggedIn() &&
diff --git a/webclient/components/fileInput/file-input-directive.js b/webclient/components/fileInput/file-input-directive.js
index 14e2f772f7..9c849a140f 100644
--- a/webclient/components/fileInput/file-input-directive.js
+++ b/webclient/components/fileInput/file-input-directive.js
@@ -31,13 +31,23 @@ angular.module('mFileInput', [])
         },
 
         link: function(scope, element, attrs, ctrl) {
-            element.bind("click", function() {
-                element.find("input")[0].click();
-                element.find("input").bind("change", function(e) {
-                    scope.selectedFile = this.files[0];
-                    scope.$apply();
+            
+            // 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");
diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js
index 5e95f34f4e..98003e97bf 100644
--- a/webclient/components/matrix/event-handler-service.js
+++ b/webclient/components/matrix/event-handler-service.js
@@ -189,21 +189,27 @@ function(matrixService, $rootScope, $q, $timeout, mPresence) {
             
             if (window.Notification && event.user_id != matrixService.config().user_id) {
                 var shouldBing = $rootScope.containsBingWord(event.content.body);
-            
-                // TODO: Binging every message when idle doesn't make much sense. Can we use this more sensibly?
-                // Unfortunately document.hidden = false on ubuntu chrome if chrome is minimised / does not have focus;
-                // true when you swap tabs though. However, for the case where the chat screen is OPEN and there is
-                // another window on top, we want to be notifying for those events. This DOES mean that there will be
-                // notifications when currently viewing the chat screen though, but that is preferable to the alternative imo.
+
+                // 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());
                 
-                // always bing if there are 0 bing words... apparently.
+                // 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) {
+                if (shouldBing && isIdle) {
                     console.log("Displaying notification for "+JSON.stringify(event));
                     var member = $rootScope.events.rooms[event.room_id].members[event.user_id];
                     var displayname = undefined;
diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js
index d05047eebb..7b5d9cffef 100644
--- a/webclient/components/matrix/matrix-call.js
+++ b/webclient/components/matrix/matrix-call.js
@@ -40,8 +40,15 @@ window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConne
 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', '$rootScope', '$timeout', function MatrixCallFactory(matrixService, matrixPhoneService, $rootScope, $timeout) {
+    $rootScope.isWebRTCSupported = isWebRTCSupported();
+
     var MatrixCall = function(room_id) {
         this.room_id = room_id;
         this.call_id = "c" + new Date().getTime();
@@ -51,6 +58,12 @@ angular.module('MatrixCall', [])
         // 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.CALL_TIMEOUT = 60000;
@@ -71,13 +84,39 @@ angular.module('MatrixCall', [])
         return pc;
     }
 
-    MatrixCall.prototype.placeCall = function(config) {
+    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({audio: config.audio, video: config.video}, function(s) { self.gotUserMediaForInvite(s); }, function(e) { self.getUserMediaFailed(e); });
+        navigator.getUserMedia(constraints, function(s) { self.gotUserMediaForInvite(s); }, function(e) { self.getUserMediaFailed(e); });
         this.state = 'wait_local_media';
         this.direction = 'outbound';
-        this.config = config;
+        this.config = constraints;
     };
 
     MatrixCall.prototype.initWithInvite = function(event) {
@@ -86,6 +125,17 @@ angular.module('MatrixCall', [])
         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') {
@@ -108,9 +158,24 @@ angular.module('MatrixCall', [])
 
     MatrixCall.prototype.answer = function() {
         console.log("Answering call "+this.call_id);
+
         var self = this;
+
+        var roomMembers = $rootScope.events.rooms[this.room_id].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({audio: true, video: false}, function(s) { self.gotUserMediaForAnswer(s); }, function(e) { self.getUserMediaFailed(e); });
+            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);
@@ -132,17 +197,24 @@ angular.module('MatrixCall', [])
         }
     };
 
-    MatrixCall.prototype.hangup = function(suppressEvent) {
+    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';
@@ -156,6 +228,13 @@ angular.module('MatrixCall', [])
         }
         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++) {
@@ -177,6 +256,13 @@ angular.module('MatrixCall', [])
     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++) {
@@ -187,7 +273,7 @@ angular.module('MatrixCall', [])
         var constraints = {
             'mandatory': {
                 'OfferToReceiveAudio': true,
-                'OfferToReceiveVideo': false
+                'OfferToReceiveVideo': this.type == 'video'
             },
         };
         this.peerConn.createAnswer(function(d) { self.createdAnswer(d); }, function(e) {}, constraints);
@@ -196,14 +282,14 @@ angular.module('MatrixCall', [])
     };
 
     MatrixCall.prototype.gotLocalIceCandidate = function(event) {
-        console.log(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 ICE candidate from remote: "+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;
@@ -218,6 +304,7 @@ angular.module('MatrixCall', [])
         this.state = 'connecting';
     };
 
+
     MatrixCall.prototype.gotLocalOffer = function(description) {
         console.log("Created offer: "+description);
 
@@ -239,8 +326,7 @@ angular.module('MatrixCall', [])
         var self = this;
         $timeout(function() {
             if (self.state == 'invite_sent') {
-                self.hangupReason = 'invite_timeout';
-                self.hangup();
+                self.hangup('invite_timeout');
             }
         }, MatrixCall.CALL_TIMEOUT);
 
@@ -269,7 +355,7 @@ angular.module('MatrixCall', [])
     };
 
     MatrixCall.prototype.getUserMediaFailed = function() {
-        this.onError("Couldn't start capturing audio! Is your microphone set up?");
+        this.onError("Couldn't start capturing! Is your microphone set up?");
         this.hangup();
     };
 
@@ -283,6 +369,8 @@ angular.module('MatrixCall', [])
                 self.state = 'connected';
                 self.didConnect = true;
             });
+        } else if (this.peerConn.iceConnectionState == 'failed') {
+            this.hangup('ice_failed');
         }
     };
 
@@ -305,6 +393,14 @@ angular.module('MatrixCall', [])
 
         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
@@ -314,9 +410,16 @@ angular.module('MatrixCall', [])
         event.stream.onended = function(e) { self.onRemoteStreamEnded(e); }; 
         // not currently implemented in chrome
         event.stream.onstarted = function(e) { self.onRemoteStreamStarted(e); };
-        var player = new Audio();
-        player.src = URL.createObjectURL(s);
-        player.play();
+
+        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) {
@@ -345,12 +448,15 @@ angular.module('MatrixCall', [])
         });
     };
 
-    MatrixCall.prototype.onHangupReceived = function() {
+    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.signalingState != 'closed') this.peerConn.close();
+        if (this.peerConn && this.peerConn.signalingState != 'closed') this.peerConn.close();
         if (this.onHangup) this.onHangup(this);
     };
 
@@ -361,13 +467,15 @@ angular.module('MatrixCall', [])
             newCall.waitForLocalAVStream = true;
         } else if (this.state == 'create_offer') {
             console.log("Handing local stream to new call");
-            newCall.localAVStream = this.localAVStream;
+            newCall.gotUserMediaForAnswer(this.localAVStream);
             delete(this.localAVStream);
         } else if (this.state == 'invite_sent') {
             console.log("Handing local stream to new call");
-            newCall.localAVStream = this.localAVStream;
+            newCall.gotUserMediaForAnswer(this.localAVStream);
             delete(this.localAVStream);
         }
+        newCall.localVideoElement = this.localVideoElement;
+        newCall.remoteVideoElement = this.remoteVideoElement;
         this.successor = newCall;
         this.hangup(true);
     };
diff --git a/webclient/components/matrix/matrix-filter.js b/webclient/components/matrix/matrix-filter.js
index 8b168cdedb..328e3a7086 100644
--- a/webclient/components/matrix/matrix-filter.js
+++ b/webclient/components/matrix/matrix-filter.js
@@ -38,13 +38,15 @@ angular.module('matrixFilter', [])
                 roomName = alias;
             }
             else if (room.members) {
+
+                var user_id = matrixService.config().user_id;
+
                 // Else, build the name from its users
-                // FIXME: Is it still required?
                 // Limit the room renaming to 1:1 room
                 if (2 === Object.keys(room.members).length) {
                     for (var i in room.members) {
                         var member = room.members[i];
-                        if (member.state_key !== matrixService.config().user_id) {
+                        if (member.state_key !== user_id) {
 
                             if (member.state_key in $rootScope.presence) {
                                 // If the user is available in presence, use the displayname there
@@ -61,30 +63,44 @@ angular.module('matrixFilter', [])
                     }
                 }
                 else if (1 === Object.keys(room.members).length) {
-                    // The other member may be in the invite list, get all invited users
-                    var invitedUserIDs = [];
-                    for (var i in room.messages) {
-                        var message = room.messages[i];
-                        if ("m.room.member" === message.type && "invite" === message.membership) {
-                            // Make sure there is no duplicate user
-                            if (-1 === invitedUserIDs.indexOf(message.state_key)) {
-                                invitedUserIDs.push(message.state_key);
-                            }
-                        } 
-                    }
-
-                    // For now, only 1:1 room needs to be renamed. It means only 1 invited user
-                    if (1 === invitedUserIDs.length) {
-                        var userID = invitedUserIDs[0];
+                    var otherUserId;
 
-                        // Try to resolve his displayname in presence global data
-                        if (userID in $rootScope.presence) {
-                            roomName = $rootScope.presence[userID].content.displayname;
+                    if (Object.keys(room.members)[0] !== user_id) {
+                        otherUserId = Object.keys(room.members)[0];
+                    }
+                    else {
+                        // The other member may be in the invite list, get all invited users
+                        var invitedUserIDs = [];
+                        for (var i in room.messages) {
+                            var message = room.messages[i];
+                            if ("m.room.member" === message.type && "invite" === message.membership) {
+                                // Filter out the current user
+                                var member_id = message.state_key;
+                                if (member_id === user_id) {
+                                    member_id = message.user_id;
+                                }
+                                if (member_id !== user_id) {
+                                    // Make sure there is no duplicate user
+                                    if (-1 === invitedUserIDs.indexOf(member_id)) {
+                                        invitedUserIDs.push(member_id);
+                                    }
+                                }
+                            } 
                         }
-                        else {
-                            roomName = userID;
+
+                        // For now, only 1:1 room needs to be renamed. It means only 1 invited user
+                        if (1 === invitedUserIDs.length) {
+                            otherUserId = invitedUserIDs[0];
                         }
                     }
+
+                    // Try to resolve his displayname in presence global data
+                    if (otherUserId in $rootScope.presence) {
+                        roomName = $rootScope.presence[otherUserId].content.displayname;
+                    }
+                    else {
+                        roomName = otherUserId;
+                    }
                 }
             }
         }
@@ -97,6 +113,14 @@ angular.module('matrixFilter', [])
         if (undefined === roomName) {
             // By default, use the room ID
             roomName = room_id;
+
+            // XXX: this is *INCREDIBLY* heavy logging for a function that calls every single
+            // time any kind of digest runs which refreshes a room name...
+            // commenting it out for now.
+
+            // Log some information that lead to this leak
+            // console.log("Room ID leak for " + room_id);
+            // console.log("room object: " + JSON.stringify(room, undefined, 4));   
         }
 
         return roomName;
diff --git a/webclient/components/matrix/matrix-phone-service.js b/webclient/components/matrix/matrix-phone-service.js
index d05eecf72a..06465ed821 100644
--- a/webclient/components/matrix/matrix-phone-service.js
+++ b/webclient/components/matrix/matrix-phone-service.js
@@ -59,6 +59,16 @@ angular.module('matrixPhoneService', [])
 
             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;
@@ -135,7 +145,7 @@ angular.module('matrixPhoneService', [])
                 call.initWithHangup(event);
                 matrixPhoneService.allCalls[msg.call_id] = call;
             } else {
-                call.onHangupReceived();
+                call.onHangupReceived(msg);
                 delete(matrixPhoneService.allCalls[msg.call_id]);
             }
         }
diff --git a/webclient/index.html b/webclient/index.html
index 7e4dcb8345..411c2762d3 100644
--- a/webclient/index.html
+++ b/webclient/index.html
@@ -45,6 +45,13 @@
 </head>
 
 <body>
+    <div id="videoBackground" ng-class="videoMode">
+        <div id="videoContainer" ng-class="videoMode">
+            <div id="videoContainerPadding"></div>
+            <video id="localVideo" ng-class="[videoMode, currentCall.state]" ng-show="currentCall && currentCall.type == 'video' && (currentCall.state == 'connected' || currentCall.state == 'connecting' || currentCall.state == 'invite_sent' || currentCall.state == 'ended')"></video>
+            <video id="remoteVideo" ng-class="[videoMode, currentCall.state]" ng-show="currentCall && currentCall.type == 'video' && (currentCall.state == 'connected' || (currentCall.state == 'ended' && currentCall.didConnect))"></video>
+        </div>
+    </div>
 
     <div id="header">
         <!-- Do not show buttons on the login page -->
@@ -58,20 +65,22 @@
                     <br />
                     <span id="callState">
                         <span ng-show="currentCall.state == 'invite_sent'">Calling...</span>
-                        <span ng-show="currentCall.state == 'ringing'">Incoming Call</span>
+                        <span ng-show="currentCall.state == 'ringing' && currentCall && currentCall.type == 'video'">Incoming Video Call</span>
+                        <span ng-show="currentCall.state == 'ringing' && currentCall && currentCall.type == 'voice'">Incoming Voice Call</span>
                         <span ng-show="currentCall.state == 'connecting'">Call Connecting...</span>
                         <span ng-show="currentCall.state == 'connected'">Call Connected</span>
-                        <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'remote'">Call Rejected</span>
-                        <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local' && currentCall.hangupReason == undefined">Call Canceled</span>
-                        <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local' && currentCall.hangupReason == 'invite_timeout'">User Not Responding</span>
-                        <span ng-show="currentCall.state == 'ended' && currentCall.didConnect && currentCall.direction == 'outbound'">Call Ended</span>
-                        <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'inbound'">Call Canceled</span>
-                        <span ng-show="currentCall.state == 'ended' && currentCall.didConnect && currentCall.direction == 'inbound'">Call Ended</span>
+                        <span ng-show="currentCall.state == 'ended' && currentCall.hangupReason == 'ice_failed'">Media Connection Failed</span>
+                        <span ng-show="currentCall.state == 'ended' && !currentCall.hangupReason && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'remote'">Call Rejected</span>
+                        <span ng-show="currentCall.state == 'ended' && !currentCall.hangupReason && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local'">Call Canceled</span>
+                        <span ng-show="currentCall.state == 'ended' && currentCall.hangupReason == 'invite_timeout' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local'">User Not Responding</span>
+                        <span ng-show="currentCall.state == 'ended' && !currentCall.hangupReason && currentCall.didConnect && currentCall.direction == 'outbound'">Call Ended</span>
+                        <span ng-show="currentCall.state == 'ended' && !currentCall.hangupReason && !currentCall.didConnect && currentCall.direction == 'inbound'">Call Canceled</span>
+                        <span ng-show="currentCall.state == 'ended' && !currentCall.hangupReason && currentCall.didConnect && currentCall.direction == 'inbound'">Call Ended</span>
                         <span ng-show="currentCall.state == 'wait_local_media'">Waiting for media permission...</span>
                     </span>
                 </div>
                 <span ng-show="currentCall.state == 'ringing'">
-                    <button ng-click="answerCall()">Answer</button>
+                    <button ng-click="answerCall()" ng-disabled="!isWebRTCSupported" title="{{isWebRTCSupported ? '' : 'Your browser does not support VoIP' }}">Answer {{ currentCall.type }} call</button>
                     <button ng-click="hangupCall()">Reject</button>
                 </span>
                 <button ng-click="hangupCall()" ng-show="currentCall && currentCall.state != 'ringing' && currentCall.state != 'ended' && currentCall.state != 'fledgling'">Hang up</button>
@@ -92,6 +101,7 @@
                     <source src="media/busy.mp3" type="audio/mpeg" />
                 </audio>
             </div>
+
             <a href id="headerUserId" ng-click='goToUserPage(user_id)'>{{ user_id }}</a>
             &nbsp;
             <button ng-click='goToPage("/")'>Home</button>
@@ -100,9 +110,20 @@
         </div>
     </div>
 
-    <div id="page" ng-view></div>
+    <div class="page" ng-hide="unsupportedBrowser" ng-view></div>
+
+    <div class="page" ng-show="unsupportedBrowser">
+        <div id="unsupportedBrowser" ng-show="unsupportedBrowser">
+            Sorry, your browser is not supported. <br/>
+                Reason: {{ unsupportedBrowser.reason }}
+
+            <br/><br/>
+            Your browser: <br/>
+            {{ unsupportedBrowser.browser }}
+        </div>
+    </div>
 
-    <div id="footer" ng-hide="location.indexOf('/room') == 0">
+    <div id="footer" ng-hide="location.indexOf('/room') === 0">
         <div id="footerContent">
             &copy; 2014 Matrix.org
         </div>
diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js
index ac8f767d16..c8104e39e6 100644
--- a/webclient/room/room-controller.js
+++ b/webclient/room/room-controller.js
@@ -33,7 +33,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         paginating: false, // used to avoid concurrent pagination requests pulling in dup contents
         stream_failure: undefined, // the response when the stream fails
         waiting_for_joined_event: false,  // true when the join request is pending. Back to false once the corresponding m.room.member event is received
-        messages_visibility: "hidden" // In order to avoid flickering when scrolling down the message table at the page opening, delay the message table display
+        messages_visibility: "hidden", // In order to avoid flickering when scrolling down the message table at the page opening, delay the message table display
     };
     $scope.members = {};
     $scope.autoCompleting = false;
@@ -416,14 +416,16 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
     };
 
     $scope.send = function() {
-        if (undefined === $scope.textInput || $scope.textInput === "") {
+        var input = $('#mainInput').val();
+        
+        if (undefined === input || input === "") {
             return;
         }
         
         scrollToBottom(true);
 
         // Store the command in the history
-        history.push($scope.textInput);
+        history.push(input);
 
         var promise;
         var cmd;
@@ -431,13 +433,11 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         var echo = false;
         
         // Check for IRC style commands first
-        var line = $scope.textInput;
-        
         // trim any trailing whitespace, as it can confuse the parser for IRC-style commands
-        line = line.replace(/\s+$/, "");
+        input = input.replace(/\s+$/, "");
         
-        if (line[0] === "/" && line[1] !== "/") {
-            var bits = line.match(/^(\S+?)( +(.*))?$/);
+        if (input[0] === "/" && input[1] !== "/") {
+            var bits = input.match(/^(\S+?)( +(.*))?$/);
             cmd = bits[1];
             args = bits[3];
             
@@ -580,7 +580,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         // By default send this as a message unless it's an IRC-style command
         if (!promise && !cmd) {
             // Make the request
-            promise = matrixService.sendTextMessage($scope.room_id, line);
+            promise = matrixService.sendTextMessage($scope.room_id, input);
             echo = true;
         }
         
@@ -589,7 +589,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
             // To do so, create a minimalist fake text message event and add it to the in-memory list of room messages
             var echoMessage = {
                 content: {
-                    body: (cmd === "/me" ? args : line),
+                    body: (cmd === "/me" ? args : input),
                     hsob_ts: new Date().getTime(), // fake a timestamp
                     msgtype: (cmd === "/me" ? "m.emote" : "m.text"),
                 },
@@ -599,7 +599,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
                 echo_msg_state: "messagePending"     // Add custom field to indicate the state of this fake message to HTML
             };
 
-            $scope.textInput = "";
+            $('#mainInput').val('');
             $rootScope.events.rooms[$scope.room_id].messages.push(echoMessage);
             scrollToBottom();
         }
@@ -619,7 +619,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
                         echoMessage.event_id = response.data.event_id;
                     }
                     else {
-                        $scope.textInput = "";
+                        $('#mainInput').val('');
                     }         
                 },
                 function(error) {
@@ -859,7 +859,9 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         var call = new MatrixCall($scope.room_id);
         call.onError = $rootScope.onCallError;
         call.onHangup = $rootScope.onCallHangup;
-        call.placeCall({audio: true, video: false});
+        // remote video element is used for playing audio in voice calls
+        call.remoteVideoElement = angular.element('#remoteVideo')[0];
+        call.placeVoiceCall();
         $rootScope.currentCall = call;
     };
 
@@ -867,7 +869,9 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
         var call = new MatrixCall($scope.room_id);
         call.onError = $rootScope.onCallError;
         call.onHangup = $rootScope.onCallHangup;
-        call.placeCall({audio: true, video: true});
+        call.localVideoElement = angular.element('#localVideo')[0];
+        call.remoteVideoElement = angular.element('#remoteVideo')[0];
+        call.placeVideoCall();
         $rootScope.currentCall = call;
     };
 
@@ -909,11 +913,11 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
 
             if (-1 === this.position) {
                 // User starts to go to into the history, save the current line
-                this.typingMessage = $scope.textInput;
+                this.typingMessage = $('#mainInput').val();
             }
             else {
                 // If the user modified this line in history, keep the change
-                this.data[this.position] = $scope.textInput;
+                this.data[this.position] = $('#mainInput').val();
             }
 
             // Bounds the new position to valid data
@@ -924,11 +928,11 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput'])
 
             if (-1 !== this.position) {
                 // Show the message from the history
-                $scope.textInput = this.data[this.position];
+                $('#mainInput').val(this.data[this.position]);
             }
             else if (undefined !== this.typingMessage) {
                 // Go back to the message the user started to type
-                $scope.textInput = this.typingMessage;
+                $('#mainInput').val(this.typingMessage);
             }
         }
     };
diff --git a/webclient/room/room.html b/webclient/room/room.html
index 44a0e34d9f..db3aa193c5 100644
--- a/webclient/room/room.html
+++ b/webclient/room/room.html
@@ -111,8 +111,8 @@
                               ng-class="containsBingWord(msg.content.body) && msg.user_id != state.user_id ? msg.echo_msg_state + ' messageBing' : msg.echo_msg_state"
                               ng-bind-html="((msg.content.msgtype === 'm.text') ? msg.content.body : '') | linky:'_blank'"/>
 
-                        <span ng-show='msg.type === "m.call.invite" && msg.user_id == state.user_id'>Outgoing Call</span>
-                        <span ng-show='msg.type === "m.call.invite" && msg.user_id != state.user_id'>Incoming Call</span>
+                        <span ng-show='msg.type === "m.call.invite" && msg.user_id == state.user_id'>Outgoing Call{{ isWebRTCSupported ? '' : ' (But your browser does not support VoIP)' }}</span>
+                        <span ng-show='msg.type === "m.call.invite" && msg.user_id != state.user_id'>Incoming Call{{ isWebRTCSupported ? '' : ' (But your browser does not support VoIP)' }}</span>
 
                         <div ng-show='msg.content.msgtype === "m.image"'>
                             <div ng-hide='msg.content.thumbnail_url' ng-style="msg.content.body.h && { 'height' : (msg.content.body.h < 320) ? msg.content.body.h : 320}">
@@ -157,7 +157,7 @@
                         {{ state.user_id }} 
                     </td>
                     <td width="*">
-                        <textarea id="mainInput" rows="1" ng-model="textInput" ng-enter="send()"
+                        <textarea id="mainInput" rows="1" ng-enter="send()"
                                   ng-disabled="state.permission_denied"
                                   ng-keydown="(38 === $event.which) ? history.goUp($event) : ((40 === $event.which) ? history.goDown($event) : 0)"
                                   ng-focus="true" autocomplete="off" tab-complete/>
@@ -176,7 +176,20 @@
                         <button ng-click="inviteUser()" ng-disabled="state.permission_denied">Invite</button>
                 </span>
                 <button ng-click="leaveRoom()" ng-disabled="state.permission_denied">Leave</button>
-                <button ng-click="startVoiceCall()" ng-show="(currentCall == undefined || currentCall.state == 'ended') && memberCount() == 2" ng-disabled="state.permission_denied">Voice Call</button>
+                <button ng-click="startVoiceCall()"
+                        ng-show="(currentCall == undefined || currentCall.state == 'ended')"
+                        ng-disabled="state.permission_denied || !isWebRTCSupported || memberCount() != 2"
+                        title ="{{ !isWebRTCSupported ? 'VoIP requires webRTC but your browser does not support it' : (memberCount() == 2 ? '' : 'VoIP calls can only be made in rooms with two participants') }}"
+                        >
+                    Voice Call
+                </button>
+                <button ng-click="startVideoCall()"
+                        ng-show="(currentCall == undefined || currentCall.state == 'ended')"
+                        ng-disabled="state.permission_denied || !isWebRTCSupported || memberCount() != 2"
+                        title ="{{ !isWebRTCSupported ? 'VoIP requires webRTC but your browser does not support it' : (memberCount() == 2 ? '' : 'VoIP calls can only be made in rooms with two participants') }}"
+                        >
+                    Video Call
+                </button>
             </div>
         
             {{ feedback }}
diff --git a/webclient/test/README b/webclient/test/README
new file mode 100644
index 0000000000..b1e0d7adea
--- /dev/null
+++ b/webclient/test/README
@@ -0,0 +1,9 @@
+Requires:
+ - npm
+ - npm install karma
+ - npm install jasmine
+
+Setting up continuous integration / run the tests:
+  karma start
+
+