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
+
+
|