diff options
Diffstat (limited to 'webclient/components')
-rw-r--r-- | webclient/components/matrix/event-handler-service.js | 186 | ||||
-rw-r--r-- | webclient/components/matrix/event-stream-service.js | 6 | ||||
-rw-r--r-- | webclient/components/matrix/matrix-call.js | 42 | ||||
-rw-r--r-- | webclient/components/matrix/matrix-phone-service.js | 64 | ||||
-rw-r--r-- | webclient/components/matrix/matrix-service.js | 163 |
5 files changed, 404 insertions, 57 deletions
diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index 4604ff6192..258de9a31e 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -27,7 +27,8 @@ Typically, this service will store events or broadcast them to any listeners if typically all the $on method would do is update its own $scope. */ angular.module('eventHandlerService', []) -.factory('eventHandlerService', ['matrixService', '$rootScope', '$q', function(matrixService, $rootScope, $q) { +.factory('eventHandlerService', ['matrixService', '$rootScope', '$q', '$timeout', 'mPresence', +function(matrixService, $rootScope, $q, $timeout, mPresence) { var ROOM_CREATE_EVENT = "ROOM_CREATE_EVENT"; var MSG_EVENT = "MSG_EVENT"; var MEMBER_EVENT = "MEMBER_EVENT"; @@ -44,6 +45,44 @@ angular.module('eventHandlerService', []) var eventMap = {}; $rootScope.presence = {}; + + // TODO: This is attached to the rootScope so .html can just go containsBingWord + // for determining classes so it is easy to highlight bing messages. It seems a + // bit strange to put the impl in this service though, but I can't think of a better + // file to put it in. + $rootScope.containsBingWord = function(content) { + if (!content || $.type(content) != "string") { + return false; + } + var bingWords = matrixService.config().bingWords; + var shouldBing = false; + + // case-insensitive name check for user_id OR display_name if they exist + var myUserId = matrixService.config().user_id; + if (myUserId) { + myUserId = myUserId.toLocaleLowerCase(); + } + var myDisplayName = matrixService.config().display_name; + if (myDisplayName) { + myDisplayName = myDisplayName.toLocaleLowerCase(); + } + if ( (myDisplayName && content.toLocaleLowerCase().indexOf(myDisplayName) != -1) || + (myUserId && content.toLocaleLowerCase().indexOf(myUserId) != -1) ) { + shouldBing = true; + } + + // bing word list check + if (bingWords && !shouldBing) { + for (var i=0; i<bingWords.length; i++) { + var re = RegExp(bingWords[i]); + if (content.search(re) != -1) { + shouldBing = true; + break; + } + } + } + return shouldBing; + }; var initialSyncDeferred; @@ -63,13 +102,14 @@ angular.module('eventHandlerService', []) var initRoom = function(room_id) { if (!(room_id in $rootScope.events.rooms)) { console.log("Creating new handler entry for " + room_id); - $rootScope.events.rooms[room_id] = {}; - $rootScope.events.rooms[room_id].messages = []; - $rootScope.events.rooms[room_id].members = {}; - - // Pagination information - $rootScope.events.rooms[room_id].pagination = { - earliest_token: "END" // how far back we've paginated + $rootScope.events.rooms[room_id] = { + room_id: room_id, + messages: [], + members: {}, + // Pagination information + pagination: { + earliest_token: "END" // how far back we've paginated + } }; } }; @@ -136,6 +176,42 @@ angular.module('eventHandlerService', []) else { $rootScope.events.rooms[event.room_id].messages.push(event); } + + 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. + var isIdle = (document.hidden || matrixService.presence.unavailable === mPresence.getState()); + + // always bing if there are 0 bing words... apparently. + var bingWords = matrixService.config().bingWords; + if (bingWords && bingWords.length === 0) { + shouldBing = true; + } + + if (shouldBing) { + console.log("Displaying notification for "+JSON.stringify(event)); + var member = $rootScope.events.rooms[event.room_id].members[event.user_id]; + var displayname = undefined; + if (member) { + displayname = member.displayname; + } + var notification = new window.Notification( + (displayname || event.user_id) + + " (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here + { + "body": event.content.body, + "icon": member ? member.avatar_url : undefined + }); + $timeout(function() { + notification.close(); + }, 5 * 1000); + } + } } else { $rootScope.events.rooms[event.room_id].messages.unshift(event); @@ -257,7 +333,9 @@ angular.module('eventHandlerService', []) // FIXME: /initialSync on a particular room is not yet available // So initRoom on a new room is not called. Make sure the room data is initialised here - initRoom(event.room_id); + if (event.room_id) { + initRoom(event.room_id); + } // Avoid duplicated events // Needed for rooms where initialSync has not been done. @@ -326,13 +404,29 @@ angular.module('eventHandlerService', []) }, // Handle messages from /initialSync or /messages - handleRoomMessages: function(room_id, messages, isLiveEvents) { + handleRoomMessages: function(room_id, messages, isLiveEvents, dir) { initRoom(room_id); - this.handleEvents(messages.chunk, isLiveEvents); - // Store how far back we've paginated - // This assumes the paginations requests are contiguous and in reverse chronological order - $rootScope.events.rooms[room_id].pagination.earliest_token = messages.end; + var events = messages.chunk; + + // Handles messages according to their time order + if (dir && 'b' === dir) { + // paginateBackMessages requests messages to be in reverse chronological order + for (var i=0; i<events.length; i++) { + this.handleEvent(events[i], isLiveEvents, isLiveEvents); + } + + // Store how far back we've paginated + $rootScope.events.rooms[room_id].pagination.earliest_token = messages.end; + } + else { + // InitialSync returns messages in chronological order + for (var i=events.length - 1; i>=0; i--) { + this.handleEvent(events[i], isLiveEvents, isLiveEvents); + } + // Store where to start pagination + $rootScope.events.rooms[room_id].pagination.earliest_token = messages.start; + } }, handleInitialSyncDone: function(initialSyncData) { @@ -347,6 +441,70 @@ angular.module('eventHandlerService', []) resetRoomMessages: function(room_id) { resetRoomMessages(room_id); + }, + + /** + * Return the last message event of a room + * @param {String} room_id the room id + * @param {Boolean} filterFake true to not take into account fake messages + * @returns {undefined | Event} the last message event if available + */ + getLastMessage: function(room_id, filterEcho) { + var lastMessage; + + var room = $rootScope.events.rooms[room_id]; + if (room) { + for (var i = room.messages.length - 1; i >= 0; i--) { + var message = room.messages[i]; + + if (!filterEcho || undefined === message.echo_msg_state) { + lastMessage = message; + break; + } + } + } + + return lastMessage; + }, + + /** + * Compute the room users number, ie the number of members who has joined the room. + * @param {String} room_id the room id + * @returns {undefined | Number} the room users number if available + */ + getUsersCountInRoom: function(room_id) { + var memberCount; + + var room = $rootScope.events.rooms[room_id]; + if (room) { + memberCount = 0; + + for (var i in room.members) { + var member = room.members[i]; + + if ("join" === member.membership) { + memberCount = memberCount + 1; + } + } + } + + return memberCount; + }, + + /** + * Get the member object of a room member + * @param {String} room_id the room id + * @param {String} user_id the id of the user + * @returns {undefined | Object} the member object of this user in this room if he is part of the room + */ + getMember: function(room_id, user_id) { + var member; + + var room = $rootScope.events.rooms[room_id]; + if (room) { + member = room.members[user_id]; + } + return member; } }; }]); diff --git a/webclient/components/matrix/event-stream-service.js b/webclient/components/matrix/event-stream-service.js index 03b805213d..6f92332246 100644 --- a/webclient/components/matrix/event-stream-service.js +++ b/webclient/components/matrix/event-stream-service.js @@ -104,8 +104,10 @@ angular.module('eventStreamService', []) settings.isActive = true; var deferred = $q.defer(); - // Initial sync: get all information and the last message of all rooms of the user - matrixService.initialSync(1, false).then( + // Initial sync: get all information and the last 30 messages of all rooms of the user + // 30 messages should be enough to display a full page of messages in a room + // without requiring to make an additional request + matrixService.initialSync(30, false).then( function(response) { var rooms = response.data.rooms; for (var i = 0; i < rooms.length; ++i) { diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js index fd21198d24..bf1e61ad7e 100644 --- a/webclient/components/matrix/matrix-call.js +++ b/webclient/components/matrix/matrix-call.js @@ -53,6 +53,8 @@ angular.module('MatrixCall', []) this.candidateSendTries = 0; } + MatrixCall.CALL_TIMEOUT = 60000; + MatrixCall.prototype.createPeerConnection = function() { var stunServer = 'stun:stun.l.google.com:19302'; var pc; @@ -78,12 +80,30 @@ angular.module('MatrixCall', []) this.config = config; }; - MatrixCall.prototype.initWithInvite = function(msg) { - this.msg = msg; + MatrixCall.prototype.initWithInvite = function(event) { + this.msg = event.content; this.peerConn = this.createPeerConnection(); this.peerConn.setRemoteDescription(new RTCSessionDescription(this.msg.offer), this.onSetRemoteDescriptionSuccess, this.onSetRemoteDescriptionError); this.state = 'ringing'; this.direction = 'inbound'; + var self = this; + $timeout(function() { + if (self.state == 'ringing') { + self.state = 'ended'; + self.hangupParty = 'remote'; // effectively + self.stopAllMedia(); + if (self.peerConn.signalingState != 'closed') self.peerConn.close(); + if (self.onHangup) self.onHangup(self); + } + }, this.msg.lifetime - event.age); + }; + + // perverse as it may seem, sometimes we want to instantiate a call with a hangup message + // (because when getting the state of the room on load, events come in reverse order and + // we want to remember that a call has been hung up) + MatrixCall.prototype.initWithHangup = function(event) { + this.msg = event.content; + this.state = 'ended'; }; MatrixCall.prototype.answer = function() { @@ -188,14 +208,12 @@ angular.module('MatrixCall', []) console.log("Ignoring remote ICE candidate because call has ended"); return; } - var candidateObject = new RTCIceCandidate({ - sdpMLineIndex: cand.label, - candidate: cand.candidate - }); - this.peerConn.addIceCandidate(candidateObject, function() {}, function(e) {}); + this.peerConn.addIceCandidate(new RTCIceCandidate(cand), function() {}, function(e) {}); }; MatrixCall.prototype.receivedAnswer = function(msg) { + if (this.state == 'ended') return; + this.peerConn.setRemoteDescription(new RTCSessionDescription(msg.answer), this.onSetRemoteDescriptionSuccess, this.onSetRemoteDescriptionError); this.state = 'connecting'; }; @@ -213,11 +231,19 @@ angular.module('MatrixCall', []) var content = { version: 0, call_id: this.call_id, - offer: description + offer: description, + lifetime: MatrixCall.CALL_TIMEOUT }; this.sendEventWithRetry('m.call.invite', content); var self = this; + $timeout(function() { + if (self.state == 'invite_sent') { + self.hangupReason = 'invite_timeout'; + self.hangup(); + } + }, MatrixCall.CALL_TIMEOUT); + $rootScope.$apply(function() { self.state = 'invite_sent'; }); diff --git a/webclient/components/matrix/matrix-phone-service.js b/webclient/components/matrix/matrix-phone-service.js index 2d0732a8da..d05eecf72a 100644 --- a/webclient/components/matrix/matrix-phone-service.js +++ b/webclient/components/matrix/matrix-phone-service.js @@ -24,22 +24,52 @@ angular.module('matrixPhoneService', []) matrixPhoneService.INCOMING_CALL_EVENT = "INCOMING_CALL_EVENT"; matrixPhoneService.REPLACED_CALL_EVENT = "REPLACED_CALL_EVENT"; matrixPhoneService.allCalls = {}; + // a place to save candidates that come in for calls we haven't got invites for yet (when paginating backwards) + matrixPhoneService.candidatesByCall = {}; matrixPhoneService.callPlaced = function(call) { matrixPhoneService.allCalls[call.call_id] = call; }; $rootScope.$on(eventHandlerService.CALL_EVENT, function(ngEvent, event, isLive) { - if (!isLive) return; // until matrix supports expiring messages if (event.user_id == matrixService.config().user_id) return; + var msg = event.content; + if (event.type == 'm.call.invite') { + if (event.age == undefined || msg.lifetime == undefined) { + // if the event doesn't have either an age (the HS is too old) or a lifetime + // (the sending client was too old when it sent it) then fall back to old behaviour + if (!isLive) return; // until matrix supports expiring messages + } + + if (event.age > msg.lifetime) { + console.log("Ignoring expired call event of type "+event.type); + return; + } + + var call = undefined; + if (!isLive) { + // if this event wasn't live then this call may already be over + call = matrixPhoneService.allCalls[msg.call_id]; + if (call && call.state == 'ended') { + return; + } + } + var MatrixCall = $injector.get('MatrixCall'); var call = new MatrixCall(event.room_id); call.call_id = msg.call_id; - call.initWithInvite(msg); + call.initWithInvite(event); matrixPhoneService.allCalls[call.call_id] = call; + // if we stashed candidate events for that call ID, play them back now + if (!isLive && matrixPhoneService.candidatesByCall[call.call_id] != undefined) { + for (var i = 0; i < matrixPhoneService.candidatesByCall[call.call_id].length; ++i) { + call.gotRemoteIceCandidate(matrixPhoneService.candidatesByCall[call.call_id][i]); + } + } + // Were we trying to call that user (room)? var existingCall; var callIds = Object.keys(matrixPhoneService.allCalls); @@ -79,21 +109,35 @@ angular.module('matrixPhoneService', []) call.receivedAnswer(msg); } else if (event.type == 'm.call.candidates') { var call = matrixPhoneService.allCalls[msg.call_id]; - if (!call) { + if (!call && isLive) { console.log("Got candidates for unknown call ID "+msg.call_id); return; - } - for (var i = 0; i < msg.candidates.length; ++i) { - call.gotRemoteIceCandidate(msg.candidates[i]); + } else if (!call) { + if (matrixPhoneService.candidatesByCall[msg.call_id] == undefined) { + matrixPhoneService.candidatesByCall[msg.call_id] = []; + } + matrixPhoneService.candidatesByCall[msg.call_id] = matrixPhoneService.candidatesByCall[msg.call_id].concat(msg.candidates); + } else { + for (var i = 0; i < msg.candidates.length; ++i) { + call.gotRemoteIceCandidate(msg.candidates[i]); + } } } else if (event.type == 'm.call.hangup') { var call = matrixPhoneService.allCalls[msg.call_id]; - if (!call) { + if (!call && isLive) { console.log("Got hangup for unknown call ID "+msg.call_id); - return; + } else if (!call) { + // if not live, store the fact that the call has ended because we're probably getting events backwards so + // the hangup will come before the invite + var MatrixCall = $injector.get('MatrixCall'); + var call = new MatrixCall(event.room_id); + call.call_id = msg.call_id; + call.initWithHangup(event); + matrixPhoneService.allCalls[msg.call_id] = call; + } else { + call.onHangupReceived(); + delete(matrixPhoneService.allCalls[msg.call_id]); } - call.onHangupReceived(); - delete(matrixPhoneService.allCalls[msg.call_id]); } }); diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js index 68ef16800b..069e02e939 100644 --- a/webclient/components/matrix/matrix-service.js +++ b/webclient/components/matrix/matrix-service.js @@ -81,38 +81,155 @@ angular.module('matrixService', []) return $http(request); }; + + var doRegisterLogin = function(path, loginType, sessionId, userName, password, threepidCreds) { + var data = {}; + if (loginType === "m.login.recaptcha") { + var challengeToken = Recaptcha.get_challenge(); + var captchaEntry = Recaptcha.get_response(); + data = { + type: "m.login.recaptcha", + challenge: challengeToken, + response: captchaEntry + }; + } + else if (loginType === "m.login.email.identity") { + data = { + threepidCreds: threepidCreds + }; + } + else if (loginType === "m.login.password") { + data = { + user: userName, + password: password + }; + } + + if (sessionId) { + data.session = sessionId; + } + data.type = loginType; + console.log("doRegisterLogin >>> " + loginType); + return doRequest("POST", path, undefined, data); + }; return { /****** Home server API ******/ prefix: prefixPath, // Register an user - register: function(user_name, password, threepidCreds, useCaptcha) { - // The REST path spec + register: function(user_name, password, threepidCreds, useCaptcha) { + // registration is composed of multiple requests, to check you can + // register, then to actually register. This deferred will fire when + // all the requests are done, along with the final response. + var deferred = $q.defer(); var path = "/register"; - var data = { - user_id: user_name, - password: password, - threepidCreds: threepidCreds - }; + // check we can actually register with this HS. + doRequest("GET", path, undefined, undefined).then( + function(response) { + console.log("/register [1] : "+JSON.stringify(response)); + var flows = response.data.flows; + var knownTypes = [ + "m.login.password", + "m.login.recaptcha", + "m.login.email.identity" + ]; + // if they entered 3pid creds, we want to use a flow which uses it. + var useThreePidFlow = threepidCreds != undefined; + var flowIndex = 0; + var firstRegType = undefined; + + for (var i=0; i<flows.length; i++) { + var isThreePidFlow = false; + if (flows[i].stages) { + for (var j=0; j<flows[i].stages.length; j++) { + var regType = flows[i].stages[j]; + if (knownTypes.indexOf(regType) === -1) { + deferred.reject("Unknown type: "+regType); + return; + } + if (regType == "m.login.email.identity") { + isThreePidFlow = true; + } + if (!useCaptcha && regType == "m.login.recaptcha") { + console.error("Web client setup to not use captcha, but HS demands a captcha."); + deferred.reject({ + data: { + errcode: "M_CAPTCHA_NEEDED", + error: "Home server requires a captcha." + } + }); + return; + } + } + } + + if ( (isThreePidFlow && useThreePidFlow) || (!isThreePidFlow && !useThreePidFlow) ) { + flowIndex = i; + } + + if (knownTypes.indexOf(flows[i].type) == -1) { + deferred.reject("Unknown type: "+flows[i].type); + return; + } + } + + // looks like we can register fine, go ahead and do it. + console.log("Using flow " + JSON.stringify(flows[flowIndex])); + firstRegType = flows[flowIndex].type; + var sessionId = undefined; + + // generic response processor so it can loop as many times as required + var loginResponseFunc = function(response) { + if (response.data.session) { + sessionId = response.data.session; + } + console.log("login response: " + JSON.stringify(response.data)); + if (response.data.access_token) { + deferred.resolve(response); + } + else if (response.data.next) { + var nextType = response.data.next; + if (response.data.next instanceof Array) { + for (var i=0; i<response.data.next.length; i++) { + if (useThreePidFlow && response.data.next[i] == "m.login.email.identity") { + nextType = response.data.next[i]; + break; + } + else if (!useThreePidFlow && response.data.next[i] != "m.login.email.identity") { + nextType = response.data.next[i]; + break; + } + } + } + return doRegisterLogin(path, nextType, sessionId, user_name, password, threepidCreds).then( + loginResponseFunc, + function(err) { + deferred.reject(err); + } + ); + } + else { + deferred.reject("Unknown continuation: "+JSON.stringify(response)); + } + }; + + // set the ball rolling + doRegisterLogin(path, firstRegType, undefined, user_name, password, threepidCreds).then( + loginResponseFunc, + function(err) { + deferred.reject(err); + } + ); + + }, + function(err) { + deferred.reject(err); + } + ); - if (useCaptcha) { - // Not all home servers will require captcha on signup, but if this flag is checked, - // send captcha information. - // TODO: Might be nice to make this a bit more flexible.. - var challengeToken = Recaptcha.get_challenge(); - var captchaEntry = Recaptcha.get_response(); - var captchaType = "m.login.recaptcha"; - - data.captcha = { - type: captchaType, - challenge: challengeToken, - response: captchaEntry - }; - } - - return doRequest("POST", path, undefined, data); + return deferred.promise; }, // Create a room |