diff options
Diffstat (limited to 'webclient')
-rw-r--r-- | webclient/app-controller.js | 35 | ||||
-rwxr-xr-x | webclient/app.css | 88 | ||||
-rw-r--r-- | webclient/app.js | 19 | ||||
-rw-r--r-- | webclient/components/fileInput/file-input-directive.js | 22 | ||||
-rw-r--r-- | webclient/components/matrix/event-handler-service.js | 22 | ||||
-rw-r--r-- | webclient/components/matrix/matrix-call.js | 144 | ||||
-rw-r--r-- | webclient/components/matrix/matrix-filter.js | 68 | ||||
-rw-r--r-- | webclient/components/matrix/matrix-phone-service.js | 12 | ||||
-rw-r--r-- | webclient/index.html | 41 | ||||
-rw-r--r-- | webclient/room/room-controller.js | 40 | ||||
-rw-r--r-- | webclient/room/room.html | 21 | ||||
-rw-r--r-- | webclient/test/README | 9 |
12 files changed, 429 insertions, 92 deletions
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> <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"> © 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 + + |