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