diff options
Diffstat (limited to 'webclient')
-rw-r--r-- | webclient/app-controller.js | 4 | ||||
-rwxr-xr-x | webclient/app.css | 20 | ||||
-rw-r--r-- | webclient/components/matrix/event-handler-service.js | 238 | ||||
-rw-r--r-- | webclient/components/matrix/event-stream-service.js | 8 | ||||
-rw-r--r-- | webclient/components/matrix/matrix-call.js | 103 | ||||
-rw-r--r-- | webclient/components/matrix/matrix-filter.js | 110 | ||||
-rw-r--r-- | webclient/components/matrix/matrix-phone-service.js | 66 | ||||
-rw-r--r-- | webclient/components/matrix/matrix-service.js | 163 | ||||
-rw-r--r-- | webclient/home/home-controller.js | 6 | ||||
-rw-r--r-- | webclient/index.html | 3 | ||||
-rw-r--r-- | webclient/recents/recents-controller.js | 130 | ||||
-rw-r--r-- | webclient/recents/recents-filter.js | 32 | ||||
-rw-r--r-- | webclient/recents/recents.html | 72 | ||||
-rw-r--r-- | webclient/room/room-controller.js | 174 | ||||
-rw-r--r-- | webclient/room/room.html | 14 | ||||
-rw-r--r-- | webclient/settings/settings-controller.js | 11 | ||||
-rw-r--r-- | webclient/settings/settings.html | 11 |
17 files changed, 826 insertions, 339 deletions
diff --git a/webclient/app-controller.js b/webclient/app-controller.js index 6c3759878b..6338624486 100644 --- a/webclient/app-controller.js +++ b/webclient/app-controller.js @@ -130,6 +130,10 @@ angular.module('MatrixWebClientController', ['matrixService', 'mPresence', 'even angular.element('#ringAudio')[0].pause(); angular.element('#ringbackAudio')[0].pause(); angular.element('#busyAudio')[0].play(); + } else if (newVal == 'ended' && oldVal == 'invite_sent' && $rootScope.currentCall.hangupParty == 'local' && $rootScope.currentCall.hangupReason == 'invite_timeout') { + angular.element('#ringAudio')[0].pause(); + angular.element('#ringbackAudio')[0].pause(); + angular.element('#busyAudio')[0].play(); } else if (oldVal == 'invite_sent') { angular.element('#ringbackAudio')[0].pause(); } else if (oldVal == 'ringing') { diff --git a/webclient/app.css b/webclient/app.css index 064f626f0b..736aea660c 100755 --- a/webclient/app.css +++ b/webclient/app.css @@ -528,9 +528,8 @@ a:active { color: #000; } } .bubble .message { - /* Break lines when encountering CR+LF */ - /* FIXME: this breaks wordwrapping. We need to s#CRLF#<br/>#g instead */ -/* white-space: pre; */ + /* Wrap words and break lines on CR+LF */ + white-space: pre-wrap; } .bubble .messagePending { opacity: 0.3 @@ -539,6 +538,10 @@ a:active { color: #000; } color: #F00; } +.messageBing { + color: #00F; +} + #room-fullscreen-image { position: absolute; top: 0px; @@ -600,7 +603,11 @@ a:active { color: #000; } width: auto; } -.recentsRoomSummaryTS { +.recentsPublicRoom { + font-weight: bold; +} + +.recentsRoomSummaryUsersCount, .recentsRoomSummaryTS { color: #888; font-size: 12px; width: 7em; @@ -613,6 +620,11 @@ a:active { color: #000; } padding-bottom: 5px; } +/* Do not show users count in the recents fragment displayed on the room page */ +#roomPage .recentsRoomSummaryUsersCount { + width: 0em; +} + /*** Recents in the room page ***/ #roomRecentsTableWrapper { diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index 705a5a07f2..ad69d297fa 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -27,7 +27,8 @@ Typically, this service will store events or broadcast them to any listeners if typically all the $on method would do is update its own $scope. */ angular.module('eventHandlerService', []) -.factory('eventHandlerService', ['matrixService', '$rootScope', '$q', function(matrixService, $rootScope, $q) { +.factory('eventHandlerService', ['matrixService', '$rootScope', '$q', '$timeout', 'mPresence', +function(matrixService, $rootScope, $q, $timeout, mPresence) { var ROOM_CREATE_EVENT = "ROOM_CREATE_EVENT"; var MSG_EVENT = "MSG_EVENT"; var MEMBER_EVENT = "MEMBER_EVENT"; @@ -38,6 +39,51 @@ angular.module('eventHandlerService', []) 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 = {}; + + $rootScope.presence = {}; + + // TODO: This is attached to the rootScope so .html can just go containsBingWord + // for determining classes so it is easy to highlight bing messages. It seems a + // bit strange to put the impl in this service though, but I can't think of a better + // file to put it in. + $rootScope.containsBingWord = function(content) { + if (!content || $.type(content) != "string") { + return false; + } + var bingWords = matrixService.config().bingWords; + var shouldBing = false; + + // case-insensitive name check for user_id OR display_name if they exist + var myUserId = matrixService.config().user_id; + if (myUserId) { + myUserId = myUserId.toLocaleLowerCase(); + } + var myDisplayName = matrixService.config().display_name; + if (myDisplayName) { + myDisplayName = myDisplayName.toLocaleLowerCase(); + } + if ( (myDisplayName && content.toLocaleLowerCase().indexOf(myDisplayName) != -1) || + (myUserId && content.toLocaleLowerCase().indexOf(myUserId) != -1) ) { + shouldBing = true; + } + + // bing word list check + if (bingWords && !shouldBing) { + for (var i=0; i<bingWords.length; i++) { + var re = RegExp(bingWords[i]); + if (content.search(re) != -1) { + shouldBing = true; + break; + } + } + } + return shouldBing; + }; + var initialSyncDeferred; var reset = function() { @@ -46,26 +92,24 @@ angular.module('eventHandlerService', []) $rootScope.events = { rooms: {} // will contain roomId: { messages:[], members:{userid1: event} } }; - } - reset(); - // 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 = {}; + $rootScope.presence = {}; + + eventMap = {}; + }; + reset(); - $rootScope.presence = {}; - var initRoom = function(room_id) { if (!(room_id in $rootScope.events.rooms)) { console.log("Creating new handler entry for " + room_id); - $rootScope.events.rooms[room_id] = {}; - $rootScope.events.rooms[room_id].messages = []; - $rootScope.events.rooms[room_id].members = {}; - - // Pagination information - $rootScope.events.rooms[room_id].pagination = { - earliest_token: "END" // how far back we've paginated + $rootScope.events.rooms[room_id] = { + room_id: room_id, + messages: [], + members: {}, + // Pagination information + pagination: { + earliest_token: "END" // how far back we've paginated + } }; } }; @@ -132,6 +176,48 @@ angular.module('eventHandlerService', []) else { $rootScope.events.rooms[event.room_id].messages.push(event); } + + if (window.Notification && event.user_id != matrixService.config().user_id) { + var shouldBing = $rootScope.containsBingWord(event.content.body); + + // TODO: Binging every message when idle doesn't make much sense. Can we use this more sensibly? + // Unfortunately document.hidden = false on ubuntu chrome if chrome is minimised / does not have focus; + // true when you swap tabs though. However, for the case where the chat screen is OPEN and there is + // another window on top, we want to be notifying for those events. This DOES mean that there will be + // notifications when currently viewing the chat screen though, but that is preferable to the alternative imo. + var isIdle = (document.hidden || matrixService.presence.unavailable === mPresence.getState()); + + // always bing if there are 0 bing words... apparently. + var bingWords = matrixService.config().bingWords; + if (bingWords && bingWords.length === 0) { + shouldBing = true; + } + + if (shouldBing) { + console.log("Displaying notification for "+JSON.stringify(event)); + var member = $rootScope.events.rooms[event.room_id].members[event.user_id]; + var displayname = undefined; + if (member) { + displayname = member.displayname; + } + + var message = event.content.body; + if (event.content.msgtype === "m.emote") { + message = "* " + displayname + " " + message; + } + + var notification = new window.Notification( + (displayname || event.user_id) + + " (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here + { + "body": message, + "icon": member ? member.avatar_url : undefined + }); + $timeout(function() { + notification.close(); + }, 5 * 1000); + } + } } else { $rootScope.events.rooms[event.room_id].messages.unshift(event); @@ -157,8 +243,9 @@ angular.module('eventHandlerService', []) // add membership changes as if they were a room message if something interesting changed // Exception: Do not do this if the event is a room state event because such events already come // as room messages events. Moreover, when they come as room messages events, they are relatively ordered - // with other other room messages - if (event.content.prev !== event.content.membership && !isStateEvent) { + // with other other room messages XXX This is no longer true, you only get a single event, not a room message event. + // FIXME: This possibly reintroduces multiple join messages. + if (event.content.prev !== event.content.membership) { // && !isStateEvent if (isLiveEvent) { $rootScope.events.rooms[event.room_id].messages.push(event); } @@ -204,7 +291,7 @@ angular.module('eventHandlerService', []) var handleCallEvent = function(event, isLiveEvent) { $rootScope.$broadcast(CALL_EVENT, event, isLiveEvent); - if (event.type == 'm.call.invite') { + if (event.type === 'm.call.invite') { $rootScope.events.rooms[event.room_id].messages.push(event); } }; @@ -231,7 +318,7 @@ angular.module('eventHandlerService', []) } } return index; - } + }; return { ROOM_CREATE_EVENT: ROOM_CREATE_EVENT, @@ -253,7 +340,9 @@ angular.module('eventHandlerService', []) // FIXME: /initialSync on a particular room is not yet available // So initRoom on a new room is not called. Make sure the room data is initialised here - initRoom(event.room_id); + if (event.room_id) { + initRoom(event.room_id); + } // Avoid duplicated events // Needed for rooms where initialSync has not been done. @@ -287,6 +376,7 @@ angular.module('eventHandlerService', []) handleMessage(event, isLiveEvent); break; case "m.room.member": + isStateEvent = true; handleRoomMember(event, isLiveEvent, isStateEvent); break; case "m.presence": @@ -316,19 +406,39 @@ angular.module('eventHandlerService', []) // isLiveEvents determines whether notifications should be shown, whether // messages get appended to the start/end of lists, etc. handleEvents: function(events, isLiveEvents, isStateEvents) { + // XXX FIXME TODO: isStateEvents is being left as undefined sometimes. It makes no sense + // to have isStateEvents as an arg, since things like m.room.member are ALWAYS state events. 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) { + handleRoomMessages: function(room_id, messages, isLiveEvents, dir) { initRoom(room_id); - this.handleEvents(messages.chunk, isLiveEvents); - // Store how far back we've paginated - // This assumes the paginations requests are contiguous and in reverse chronological order - $rootScope.events.rooms[room_id].pagination.earliest_token = messages.end; + var events = messages.chunk; + + // Handles messages according to their time order + if (dir && 'b' === dir) { + // paginateBackMessages requests messages to be in reverse chronological order + for (var i=0; i<events.length; i++) { + // FIXME: Being live != being state + this.handleEvent(events[i], isLiveEvents, isLiveEvents); + } + + // Store how far back we've paginated + $rootScope.events.rooms[room_id].pagination.earliest_token = messages.end; + } + else { + // InitialSync returns messages in chronological order + for (var i=events.length - 1; i>=0; i--) { + // FIXME: Being live != being state + this.handleEvent(events[i], isLiveEvents, isLiveEvents); + } + // Store where to start pagination + $rootScope.events.rooms[room_id].pagination.earliest_token = messages.start; + } }, handleInitialSyncDone: function(initialSyncData) { @@ -343,6 +453,82 @@ angular.module('eventHandlerService', []) resetRoomMessages: function(room_id) { resetRoomMessages(room_id); + }, + + /** + * Return the last message event of a room + * @param {String} room_id the room id + * @param {Boolean} filterFake true to not take into account fake messages + * @returns {undefined | Event} the last message event if available + */ + getLastMessage: function(room_id, filterEcho) { + var lastMessage; + + var room = $rootScope.events.rooms[room_id]; + if (room) { + for (var i = room.messages.length - 1; i >= 0; i--) { + var message = room.messages[i]; + + if (!filterEcho || undefined === message.echo_msg_state) { + lastMessage = message; + break; + } + } + } + + return lastMessage; + }, + + /** + * Compute the room users number, ie the number of members who has joined the room. + * @param {String} room_id the room id + * @returns {undefined | Number} the room users number if available + */ + getUsersCountInRoom: function(room_id) { + var memberCount; + + var room = $rootScope.events.rooms[room_id]; + if (room) { + memberCount = 0; + + for (var i in room.members) { + var member = room.members[i]; + + if ("join" === member.membership) { + memberCount = memberCount + 1; + } + } + } + + return memberCount; + }, + + /** + * Get the member object of a room member + * @param {String} room_id the room id + * @param {String} user_id the id of the user + * @returns {undefined | Object} the member object of this user in this room if he is part of the room + */ + getMember: function(room_id, user_id) { + var member; + + var room = $rootScope.events.rooms[room_id]; + if (room) { + member = room.members[user_id]; + } + return member; + }, + + setRoomVisibility: function(room_id, visible) { + if (!visible) { + return; + } + initRoom(room_id); + + var room = $rootScope.events.rooms[room_id]; + if (room) { + room.visibility = visible; + } } }; }]); diff --git a/webclient/components/matrix/event-stream-service.js b/webclient/components/matrix/event-stream-service.js index 03b805213d..5af1ab2911 100644 --- a/webclient/components/matrix/event-stream-service.js +++ b/webclient/components/matrix/event-stream-service.js @@ -104,8 +104,10 @@ angular.module('eventStreamService', []) settings.isActive = true; var deferred = $q.defer(); - // Initial sync: get all information and the last message of all rooms of the user - matrixService.initialSync(1, false).then( + // Initial sync: get all information and the last 30 messages of all rooms of the user + // 30 messages should be enough to display a full page of messages in a room + // without requiring to make an additional request + matrixService.initialSync(30, false).then( function(response) { var rooms = response.data.rooms; for (var i = 0; i < rooms.length; ++i) { @@ -118,6 +120,8 @@ angular.module('eventStreamService', []) if ("state" in room) { eventHandlerService.handleEvents(room.state, false, true); } + + eventHandlerService.setRoomVisibility(room.room_id, room.visibility); } var presence = response.data.presence; diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js index 2e3e2b0967..2ecb8b05ff 100644 --- a/webclient/components/matrix/matrix-call.js +++ b/webclient/components/matrix/matrix-call.js @@ -47,13 +47,19 @@ angular.module('MatrixCall', []) 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; } + MatrixCall.CALL_TIMEOUT = 60000; + MatrixCall.prototype.createPeerConnection = function() { var stunServer = 'stun:stun.l.google.com:19302'; var pc; if (window.mozRTCPeerConnection) { - pc = window.mozRTCPeerConnection({'url': stunServer}); + pc = new window.mozRTCPeerConnection({'url': stunServer}); } else { pc = new window.RTCPeerConnection({"iceServers":[{"urls":"stun:stun.l.google.com:19302"}]}); } @@ -74,12 +80,30 @@ angular.module('MatrixCall', []) this.config = config; }; - MatrixCall.prototype.initWithInvite = function(msg) { - this.msg = msg; + MatrixCall.prototype.initWithInvite = function(event) { + this.msg = event.content; this.peerConn = this.createPeerConnection(); this.peerConn.setRemoteDescription(new RTCSessionDescription(this.msg.offer), this.onSetRemoteDescriptionSuccess, this.onSetRemoteDescriptionError); this.state = 'ringing'; this.direction = 'inbound'; + var self = this; + $timeout(function() { + if (self.state == 'ringing') { + self.state = 'ended'; + self.hangupParty = 'remote'; // effectively + self.stopAllMedia(); + if (self.peerConn.signalingState != 'closed') self.peerConn.close(); + if (self.onHangup) self.onHangup(self); + } + }, this.msg.lifetime - event.age); + }; + + // perverse as it may seem, sometimes we want to instantiate a call with a hangup message + // (because when getting the state of the room on load, events come in reverse order and + // we want to remember that a call has been hung up) + MatrixCall.prototype.initWithHangup = function(event) { + this.msg = event.content; + this.state = 'ended'; }; MatrixCall.prototype.answer = function() { @@ -174,12 +198,7 @@ angular.module('MatrixCall', []) MatrixCall.prototype.gotLocalIceCandidate = function(event) { console.log(event); if (event.candidate) { - var content = { - version: 0, - call_id: this.call_id, - candidate: event.candidate - }; - this.sendEventWithRetry('m.call.candidate', content); + this.sendCandidate(event.candidate); } } @@ -189,14 +208,12 @@ angular.module('MatrixCall', []) console.log("Ignoring remote ICE candidate because call has ended"); return; } - var candidateObject = new RTCIceCandidate({ - sdpMLineIndex: cand.label, - candidate: cand.candidate - }); - this.peerConn.addIceCandidate(candidateObject, function() {}, function(e) {}); + this.peerConn.addIceCandidate(new RTCIceCandidate(cand), function() {}, function(e) {}); }; MatrixCall.prototype.receivedAnswer = function(msg) { + if (this.state == 'ended') return; + this.peerConn.setRemoteDescription(new RTCSessionDescription(msg.answer), this.onSetRemoteDescriptionSuccess, this.onSetRemoteDescriptionError); this.state = 'connecting'; }; @@ -214,11 +231,19 @@ angular.module('MatrixCall', []) var content = { version: 0, call_id: this.call_id, - offer: description + offer: description, + lifetime: MatrixCall.CALL_TIMEOUT }; this.sendEventWithRetry('m.call.invite', content); var self = this; + $timeout(function() { + if (self.state == 'invite_sent') { + self.hangupReason = 'invite_timeout'; + self.hangup(); + } + }, MatrixCall.CALL_TIMEOUT); + $rootScope.$apply(function() { self.state = 'invite_sent'; }); @@ -370,5 +395,53 @@ angular.module('MatrixCall', []) }, 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 "+ev.tries+". 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/webclient/components/matrix/matrix-filter.js b/webclient/components/matrix/matrix-filter.js index 015a88bcad..8b168cdedb 100644 --- a/webclient/components/matrix/matrix-filter.js +++ b/webclient/components/matrix/matrix-filter.js @@ -26,72 +26,74 @@ angular.module('matrixFilter', []) // If there is an alias, use it // TODO: only one alias is managed for now var alias = matrixService.getRoomIdToAliasMapping(room_id); - if (alias) { - roomName = alias; - } - - if (undefined === roomName) { - var room = $rootScope.events.rooms[room_id]; - if (room) { - // Get name from room state date - var room_name_event = room["m.room.name"]; - if (room_name_event) { - roomName = room_name_event.content.name; - } - else if (room.members) { - // Else, build the name from its users - // FIXME: Is it still required? - // Limit the room renaming to 1:1 room - if (2 === Object.keys(room.members).length) { - for (var i in room.members) { - var member = room.members[i]; - if (member.state_key !== matrixService.config().user_id) { + var room = $rootScope.events.rooms[room_id]; + if (room) { + // Get name from room state date + var room_name_event = room["m.room.name"]; + if (room_name_event) { + roomName = room_name_event.content.name; + } + else if (alias) { + roomName = alias; + } + else if (room.members) { + // Else, build the name from its users + // FIXME: Is it still required? + // Limit the room renaming to 1:1 room + if (2 === Object.keys(room.members).length) { + for (var i in room.members) { + var member = room.members[i]; + if (member.state_key !== matrixService.config().user_id) { - if (member.state_key in $rootScope.presence) { - // If the user is available in presence, use the displayname there - // as it is the most uptodate - roomName = $rootScope.presence[member.state_key].content.displayname; - } - else if (member.content.displayname) { - roomName = member.content.displayname; - } - else { - roomName = member.state_key; - } + if (member.state_key in $rootScope.presence) { + // If the user is available in presence, use the displayname there + // as it is the most uptodate + roomName = $rootScope.presence[member.state_key].content.displayname; } - } - } - else if (1 === Object.keys(room.members).length) { - // The other member may be in the invite list, get all invited users - var invitedUserIDs = []; - for (var i in room.messages) { - var message = room.messages[i]; - if ("m.room.member" === message.type && "invite" === message.membership) { - // Make sure there is no duplicate user - if (-1 === invitedUserIDs.indexOf(message.state_key)) { - invitedUserIDs.push(message.state_key); - } - } - } - - // For now, only 1:1 room needs to be renamed. It means only 1 invited user - if (1 === invitedUserIDs.length) { - var userID = invitedUserIDs[0]; - - // Try to resolve his displayname in presence global data - if (userID in $rootScope.presence) { - roomName = $rootScope.presence[userID].content.displayname; + else if (member.content.displayname) { + roomName = member.content.displayname; } else { - roomName = userID; + roomName = member.state_key; } } } } + else if (1 === Object.keys(room.members).length) { + // The other member may be in the invite list, get all invited users + var invitedUserIDs = []; + for (var i in room.messages) { + var message = room.messages[i]; + if ("m.room.member" === message.type && "invite" === message.membership) { + // Make sure there is no duplicate user + if (-1 === invitedUserIDs.indexOf(message.state_key)) { + invitedUserIDs.push(message.state_key); + } + } + } + + // For now, only 1:1 room needs to be renamed. It means only 1 invited user + if (1 === invitedUserIDs.length) { + var userID = invitedUserIDs[0]; + + // Try to resolve his displayname in presence global data + if (userID in $rootScope.presence) { + roomName = $rootScope.presence[userID].content.displayname; + } + else { + roomName = userID; + } + } + } } } + // 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; diff --git a/webclient/components/matrix/matrix-phone-service.js b/webclient/components/matrix/matrix-phone-service.js index b0dcf19100..d05eecf72a 100644 --- a/webclient/components/matrix/matrix-phone-service.js +++ b/webclient/components/matrix/matrix-phone-service.js @@ -24,22 +24,52 @@ angular.module('matrixPhoneService', []) matrixPhoneService.INCOMING_CALL_EVENT = "INCOMING_CALL_EVENT"; matrixPhoneService.REPLACED_CALL_EVENT = "REPLACED_CALL_EVENT"; matrixPhoneService.allCalls = {}; + // a place to save candidates that come in for calls we haven't got invites for yet (when paginating backwards) + matrixPhoneService.candidatesByCall = {}; matrixPhoneService.callPlaced = function(call) { matrixPhoneService.allCalls[call.call_id] = call; }; $rootScope.$on(eventHandlerService.CALL_EVENT, function(ngEvent, event, isLive) { - if (!isLive) return; // until matrix supports expiring messages if (event.user_id == matrixService.config().user_id) return; + var msg = event.content; + if (event.type == 'm.call.invite') { + if (event.age == undefined || msg.lifetime == undefined) { + // if the event doesn't have either an age (the HS is too old) or a lifetime + // (the sending client was too old when it sent it) then fall back to old behaviour + if (!isLive) return; // until matrix supports expiring messages + } + + if (event.age > msg.lifetime) { + console.log("Ignoring expired call event of type "+event.type); + return; + } + + var call = undefined; + if (!isLive) { + // if this event wasn't live then this call may already be over + call = matrixPhoneService.allCalls[msg.call_id]; + if (call && call.state == 'ended') { + return; + } + } + var MatrixCall = $injector.get('MatrixCall'); var call = new MatrixCall(event.room_id); call.call_id = msg.call_id; - call.initWithInvite(msg); + call.initWithInvite(event); matrixPhoneService.allCalls[call.call_id] = call; + // if we stashed candidate events for that call ID, play them back now + if (!isLive && matrixPhoneService.candidatesByCall[call.call_id] != undefined) { + for (var i = 0; i < matrixPhoneService.candidatesByCall[call.call_id].length; ++i) { + call.gotRemoteIceCandidate(matrixPhoneService.candidatesByCall[call.call_id][i]); + } + } + // Were we trying to call that user (room)? var existingCall; var callIds = Object.keys(matrixPhoneService.allCalls); @@ -77,21 +107,37 @@ angular.module('matrixPhoneService', []) return; } call.receivedAnswer(msg); - } else if (event.type == 'm.call.candidate') { + } else if (event.type == 'm.call.candidates') { var call = matrixPhoneService.allCalls[msg.call_id]; - if (!call) { - console.log("Got candidate for unknown call ID "+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]); + } } - call.gotRemoteIceCandidate(msg.candidate); } else if (event.type == 'm.call.hangup') { var call = matrixPhoneService.allCalls[msg.call_id]; - if (!call) { + if (!call && isLive) { console.log("Got hangup for unknown call ID "+msg.call_id); - return; + } else if (!call) { + // if not live, store the fact that the call has ended because we're probably getting events backwards so + // the hangup will come before the invite + var MatrixCall = $injector.get('MatrixCall'); + var call = new MatrixCall(event.room_id); + call.call_id = msg.call_id; + call.initWithHangup(event); + matrixPhoneService.allCalls[msg.call_id] = call; + } else { + call.onHangupReceived(); + delete(matrixPhoneService.allCalls[msg.call_id]); } - call.onHangupReceived(); - delete(matrixPhoneService.allCalls[msg.call_id]); } }); diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js index 68ef16800b..069e02e939 100644 --- a/webclient/components/matrix/matrix-service.js +++ b/webclient/components/matrix/matrix-service.js @@ -81,38 +81,155 @@ angular.module('matrixService', []) return $http(request); }; + + var doRegisterLogin = function(path, loginType, sessionId, userName, password, threepidCreds) { + var data = {}; + if (loginType === "m.login.recaptcha") { + var challengeToken = Recaptcha.get_challenge(); + var captchaEntry = Recaptcha.get_response(); + data = { + type: "m.login.recaptcha", + challenge: challengeToken, + response: captchaEntry + }; + } + else if (loginType === "m.login.email.identity") { + data = { + threepidCreds: threepidCreds + }; + } + else if (loginType === "m.login.password") { + data = { + user: userName, + password: password + }; + } + + if (sessionId) { + data.session = sessionId; + } + data.type = loginType; + console.log("doRegisterLogin >>> " + loginType); + return doRequest("POST", path, undefined, data); + }; return { /****** Home server API ******/ prefix: prefixPath, // Register an user - register: function(user_name, password, threepidCreds, useCaptcha) { - // The REST path spec + register: function(user_name, password, threepidCreds, useCaptcha) { + // registration is composed of multiple requests, to check you can + // register, then to actually register. This deferred will fire when + // all the requests are done, along with the final response. + var deferred = $q.defer(); var path = "/register"; - var data = { - user_id: user_name, - password: password, - threepidCreds: threepidCreds - }; + // check we can actually register with this HS. + doRequest("GET", path, undefined, undefined).then( + function(response) { + console.log("/register [1] : "+JSON.stringify(response)); + var flows = response.data.flows; + var knownTypes = [ + "m.login.password", + "m.login.recaptcha", + "m.login.email.identity" + ]; + // if they entered 3pid creds, we want to use a flow which uses it. + var useThreePidFlow = threepidCreds != undefined; + var flowIndex = 0; + var firstRegType = undefined; + + for (var i=0; i<flows.length; i++) { + var isThreePidFlow = false; + if (flows[i].stages) { + for (var j=0; j<flows[i].stages.length; j++) { + var regType = flows[i].stages[j]; + if (knownTypes.indexOf(regType) === -1) { + deferred.reject("Unknown type: "+regType); + return; + } + if (regType == "m.login.email.identity") { + isThreePidFlow = true; + } + if (!useCaptcha && regType == "m.login.recaptcha") { + console.error("Web client setup to not use captcha, but HS demands a captcha."); + deferred.reject({ + data: { + errcode: "M_CAPTCHA_NEEDED", + error: "Home server requires a captcha." + } + }); + return; + } + } + } + + if ( (isThreePidFlow && useThreePidFlow) || (!isThreePidFlow && !useThreePidFlow) ) { + flowIndex = i; + } + + if (knownTypes.indexOf(flows[i].type) == -1) { + deferred.reject("Unknown type: "+flows[i].type); + return; + } + } + + // looks like we can register fine, go ahead and do it. + console.log("Using flow " + JSON.stringify(flows[flowIndex])); + firstRegType = flows[flowIndex].type; + var sessionId = undefined; + + // generic response processor so it can loop as many times as required + var loginResponseFunc = function(response) { + if (response.data.session) { + sessionId = response.data.session; + } + console.log("login response: " + JSON.stringify(response.data)); + if (response.data.access_token) { + deferred.resolve(response); + } + else if (response.data.next) { + var nextType = response.data.next; + if (response.data.next instanceof Array) { + for (var i=0; i<response.data.next.length; i++) { + if (useThreePidFlow && response.data.next[i] == "m.login.email.identity") { + nextType = response.data.next[i]; + break; + } + else if (!useThreePidFlow && response.data.next[i] != "m.login.email.identity") { + nextType = response.data.next[i]; + break; + } + } + } + return doRegisterLogin(path, nextType, sessionId, user_name, password, threepidCreds).then( + loginResponseFunc, + function(err) { + deferred.reject(err); + } + ); + } + else { + deferred.reject("Unknown continuation: "+JSON.stringify(response)); + } + }; + + // set the ball rolling + doRegisterLogin(path, firstRegType, undefined, user_name, password, threepidCreds).then( + loginResponseFunc, + function(err) { + deferred.reject(err); + } + ); + + }, + function(err) { + deferred.reject(err); + } + ); - if (useCaptcha) { - // Not all home servers will require captcha on signup, but if this flag is checked, - // send captcha information. - // TODO: Might be nice to make this a bit more flexible.. - var challengeToken = Recaptcha.get_challenge(); - var captchaEntry = Recaptcha.get_response(); - var captchaType = "m.login.recaptcha"; - - data.captcha = { - type: captchaType, - challenge: challengeToken, - response: captchaEntry - }; - } - - return doRequest("POST", path, undefined, data); + return deferred.promise; }, // Create a room diff --git a/webclient/home/home-controller.js b/webclient/home/home-controller.js index c0c4ea11aa..e35219bebb 100644 --- a/webclient/home/home-controller.js +++ b/webclient/home/home-controller.js @@ -53,6 +53,8 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen // Add room_alias & room_display_name members angular.extend(room, matrixService.getRoomAliasAndDisplayName(room)); + + eventHandlerService.setRoomVisibility(room.room_id, "public"); } } ); @@ -117,6 +119,10 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen matrixService.getDisplayName($scope.config.user_id).then( function(response) { $scope.profile.displayName = response.data.displayname; + var config = matrixService.config(); + config.display_name = response.data.displayname; + matrixService.setConfig(config); + matrixService.saveConfig(); }, function(error) { $scope.feedback = "Can't load display name"; diff --git a/webclient/index.html b/webclient/index.html index 9eea08215c..7e4dcb8345 100644 --- a/webclient/index.html +++ b/webclient/index.html @@ -62,7 +62,8 @@ <span ng-show="currentCall.state == 'connecting'">Call Connecting...</span> <span ng-show="currentCall.state == 'connected'">Call Connected</span> <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'remote'">Call Rejected</span> - <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local'">Call Canceled</span> + <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local' && currentCall.hangupReason == undefined">Call Canceled</span> + <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'outbound' && currentCall.hangupParty == 'local' && currentCall.hangupReason == 'invite_timeout'">User Not Responding</span> <span ng-show="currentCall.state == 'ended' && currentCall.didConnect && currentCall.direction == 'outbound'">Call Ended</span> <span ng-show="currentCall.state == 'ended' && !currentCall.didConnect && currentCall.direction == 'inbound'">Call Canceled</span> <span ng-show="currentCall.state == 'ended' && currentCall.didConnect && currentCall.direction == 'inbound'">Call Ended</span> diff --git a/webclient/recents/recents-controller.js b/webclient/recents/recents-controller.js index a0db0538f3..ee8a41c366 100644 --- a/webclient/recents/recents-controller.js +++ b/webclient/recents/recents-controller.js @@ -16,134 +16,16 @@ 'use strict'; -angular.module('RecentsController', ['matrixService', 'matrixFilter', 'eventHandlerService']) -.controller('RecentsController', ['$rootScope', '$scope', 'matrixService', 'eventHandlerService', - function($rootScope, $scope, matrixService, eventHandlerService) { - - // FIXME: Angularjs reloads the controller (and resets its $scope) each time - // the page URL changes, use $rootScope to avoid to have to reload data - $rootScope.rooms; +angular.module('RecentsController', ['matrixService', 'matrixFilter']) +.controller('RecentsController', ['$rootScope', '$scope', 'eventHandlerService', + function($rootScope, $scope, eventHandlerService) { + + // Expose the service to the view + $scope.eventHandlerService = eventHandlerService; // $rootScope of the parent where the recents component is included can override this value // in order to highlight a specific room in the list $rootScope.recentsSelectedRoomID; - - var listenToEventStream = function() { - // Refresh the list on matrix invitation and message event - $rootScope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) { - if (isLive) { - if (!$rootScope.rooms[event.room_id]) { - // The user has joined a new room, which we do not have data yet. The reason is that - // the room has appeared in the scope of the user rooms after the global initialSync - // FIXME: an initialSync on this specific room should be done - $rootScope.rooms[event.room_id] = { - room_id:event.room_id - }; - } - else if (event.state_key === matrixService.config().user_id && "invite" !== event.membership && "join" !== event.membership) { - // The user has been kicked or banned from the room, remove this room from the recents - delete $rootScope.rooms[event.room_id]; - } - - if ($rootScope.rooms[event.room_id]) { - $rootScope.rooms[event.room_id].lastMsg = event; - } - - // Update room users count - $rootScope.rooms[event.room_id].numUsersInRoom = getUsersCountInRoom(event.room_id); - } - }); - $rootScope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) { - if (isLive) { - $rootScope.rooms[event.room_id].lastMsg = event; - } - }); - $rootScope.$on(eventHandlerService.CALL_EVENT, function(ngEvent, event, isLive) { - if (isLive) { - $rootScope.rooms[event.room_id].lastMsg = event; - } - }); - $rootScope.$on(eventHandlerService.ROOM_CREATE_EVENT, function(ngEvent, event, isLive) { - if (isLive) { - $rootScope.rooms[event.room_id] = event; - } - }); - $rootScope.$on(eventHandlerService.NAME_EVENT, function(ngEvent, event, isLive) { - if (isLive) { - $rootScope.rooms[event.room_id].lastMsg = event; - } - }); - $rootScope.$on(eventHandlerService.TOPIC_EVENT, function(ngEvent, event, isLive) { - if (isLive) { - $rootScope.rooms[event.room_id].lastMsg = event; - } - }); - }; - - /** - * 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 - */ - var getUsersCountInRoom = function(room_id) { - var memberCount; - - var room = $rootScope.events.rooms[room_id]; - if (room) { - memberCount = 0; - - for (var i in room.members) { - var member = room.members[i]; - - if ("join" === member.membership) { - memberCount = memberCount + 1; - } - } - } - - return memberCount; - }; - - $scope.onInit = function() { - // Init recents list only once - if ($rootScope.rooms) { - return; - } - - $rootScope.rooms = {}; - - // Use initialSync data to init the recents list - eventHandlerService.waitForInitialSyncCompletion().then( - function(initialSyncData) { - - var rooms = initialSyncData.data.rooms; - for (var i=0; i<rooms.length; i++) { - var room = rooms[i]; - - // Add room_alias & room_display_name members - $rootScope.rooms[room.room_id] = angular.extend(room, matrixService.getRoomAliasAndDisplayName(room)); - - // Create a shortcut for the last message of this room - if (room.messages && room.messages.chunk && room.messages.chunk[0]) { - $rootScope.rooms[room.room_id].lastMsg = room.messages.chunk[0]; - } - - $rootScope.rooms[room.room_id].numUsersInRoom = getUsersCountInRoom(room.room_id); - } - - // From now, update recents from the stream - listenToEventStream(); - }, - function(error) { - $rootScope.feedback = "Failure: " + error.data; - } - ); - }; - - // Clean data when user logs out - $scope.$on(eventHandlerService.RESET_EVENT, function() { - delete $rootScope.rooms; - }); }]); diff --git a/webclient/recents/recents-filter.js b/webclient/recents/recents-filter.js index d80de6fbeb..d948205e19 100644 --- a/webclient/recents/recents-filter.js +++ b/webclient/recents/recents-filter.js @@ -17,31 +17,47 @@ 'use strict'; angular.module('RecentsController') -.filter('orderRecents', function() { +.filter('orderRecents', ["matrixService", "eventHandlerService", function(matrixService, eventHandlerService) { return function(rooms) { + var user_id = matrixService.config().user_id; + // Transform the dict into an array // The key, room_id, is already in value objects var filtered = []; - angular.forEach(rooms, function(value, key) { - filtered.push( value ); + angular.forEach(rooms, function(room, room_id) { + + // Show the room only if the user has joined it or has been invited + // (ie, do not show it if he has been banned) + var member = eventHandlerService.getMember(room_id, user_id); + if (member && ("invite" === member.membership || "join" === member.membership)) { + + // Count users here + // TODO: Compute it directly in eventHandlerService + room.numUsersInRoom = eventHandlerService.getUsersCountInRoom(room_id); + } + filtered.push(room); }); // And time sort them // The room with the lastest message at first - filtered.sort(function (a, b) { + filtered.sort(function (roomA, roomB) { + + var lastMsgRoomA = eventHandlerService.getLastMessage(roomA.room_id, true); + var lastMsgRoomB = eventHandlerService.getLastMessage(roomB.room_id, true); + // Invite message does not have a body message nor ts // Puth them at the top of the list - if (undefined === a.lastMsg) { + if (undefined === lastMsgRoomA) { return -1; } - else if (undefined === b.lastMsg) { + else if (undefined === lastMsgRoomB) { return 1; } else { - return b.lastMsg.ts - a.lastMsg.ts; + return lastMsgRoomB.ts - lastMsgRoomA.ts; } }); return filtered; }; -}); \ No newline at end of file +}]); diff --git a/webclient/recents/recents.html b/webclient/recents/recents.html index 3d736b6694..edfc1677eb 100644 --- a/webclient/recents/recents.html +++ b/webclient/recents/recents.html @@ -1,20 +1,24 @@ -<div ng-controller="RecentsController" data-ng-init="onInit()"> +<div ng-controller="RecentsController"> <table class="recentsTable"> - <tbody ng-repeat="(rm_id, room) in rooms | orderRecents" + <tbody ng-repeat="(index, room) in events.rooms | orderRecents" ng-click="goToPage('room/' + (room.room_alias ? room.room_alias : room.room_id) )" class ="recentsRoom" - ng-class="{'recentsRoomSelected': (room.room_id === recentsSelectedRoomID)}"> + ng-class="{'recentsRoomSelected': (room.room_id === recentsSelectedRoomID)}"> <tr> - <td class="recentsRoomName"> + <td ng-class="room['m.room.join_rules'].content.join_rule == 'public' ? 'recentsRoomName recentsPublicRoom' : 'recentsRoomName'"> {{ room.room_id | mRoomName }} </td> - <td class="recentsRoomSummaryTS"> + <td class="recentsRoomSummaryUsersCount"> <span ng-show="undefined !== room.numUsersInRoom"> {{ room.numUsersInRoom || '1' }} {{ room.numUsersInRoom == 1 ? 'user' : 'users' }} </span> </td> <td class="recentsRoomSummaryTS"> - {{ (room.lastMsg.ts) | date:'MMM d HH:mm' }} + <!-- Use a temp var as alias to the last room message. + Declaring it in this way ensures the data-binding --> + {{ lastMsg = eventHandlerService.getLastMessage(room.room_id, true);"" }} + + {{ (lastMsg.ts) | date:'MMM d HH:mm' }} </td> </tr> @@ -22,70 +26,70 @@ <td colspan="3" class="recentsRoomSummary"> <div ng-show="room.membership === 'invite'"> - {{ room.lastMsg.inviter | mUserDisplayName: room.room_id }} invited you + {{ room.inviter | mUserDisplayName: room.room_id }} invited you </div> - <div ng-hide="room.membership === 'invite'" ng-switch="room.lastMsg.type"> + <div ng-hide="room.membership === 'invite'" ng-switch="lastMsg.type"> <div ng-switch-when="m.room.member"> - <span ng-if="'join' === room.lastMsg.content.membership"> - {{ room.lastMsg.state_key | mUserDisplayName: room.room_id}} joined + <span ng-if="'join' === lastMsg.content.membership"> + {{ lastMsg.state_key | mUserDisplayName: room.room_id}} joined </span> - <span ng-if="'leave' === room.lastMsg.content.membership"> - <span ng-if="room.lastMsg.user_id === room.lastMsg.state_key"> - {{room.lastMsg.state_key | mUserDisplayName: room.room_id }} left + <span ng-if="'leave' === lastMsg.content.membership"> + <span ng-if="lastMsg.user_id === lastMsg.state_key"> + {{lastMsg.state_key | mUserDisplayName: room.room_id }} left </span> - <span ng-if="room.lastMsg.user_id !== room.lastMsg.state_key"> - {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} - {{ {"join": "kicked", "ban": "unbanned"}[room.lastMsg.content.prev] }} - {{ room.lastMsg.state_key | mUserDisplayName: room.room_id }} + <span ng-if="lastMsg.user_id !== lastMsg.state_key"> + {{ lastMsg.user_id | mUserDisplayName: room.room_id }} + {{ {"join": "kicked", "ban": "unbanned"}[lastMsg.content.prev] }} + {{ lastMsg.state_key | mUserDisplayName: room.room_id }} </span> - <span ng-if="'join' === room.lastMsg.content.prev && room.lastMsg.content.reason"> - : {{ room.lastMsg.content.reason }} + <span ng-if="'join' === lastMsg.content.prev && lastMsg.content.reason"> + : {{ lastMsg.content.reason }} </span> </span> - <span ng-if="'invite' === room.lastMsg.content.membership || 'ban' === room.lastMsg.content.membership"> - {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} - {{ {"invite": "invited", "ban": "banned"}[room.lastMsg.content.membership] }} - {{ room.lastMsg.state_key | mUserDisplayName: room.room_id }} - <span ng-if="'ban' === room.lastMsg.content.prev && room.lastMsg.content.reason"> - : {{ room.lastMsg.content.reason }} + <span ng-if="'invite' === lastMsg.content.membership || 'ban' === lastMsg.content.membership"> + {{ lastMsg.user_id | mUserDisplayName: room.room_id }} + {{ {"invite": "invited", "ban": "banned"}[lastMsg.content.membership] }} + {{ lastMsg.state_key | mUserDisplayName: room.room_id }} + <span ng-if="'ban' === lastMsg.content.prev && lastMsg.content.reason"> + : {{ lastMsg.content.reason }} </span> </span> </div> <div ng-switch-when="m.room.message"> - <div ng-switch="room.lastMsg.content.msgtype"> + <div ng-switch="lastMsg.content.msgtype"> <div ng-switch-when="m.text"> - {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} : - <span ng-bind-html="(room.lastMsg.content.body) | linky:'_blank'"> + {{ lastMsg.user_id | mUserDisplayName: room.room_id }} : + <span ng-bind-html="(lastMsg.content.body) | linky:'_blank'"> </span> </div> <div ng-switch-when="m.image"> - {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} sent an image + {{ lastMsg.user_id | mUserDisplayName: room.room_id }} sent an image </div> <div ng-switch-when="m.emote"> - <span ng-bind-html="'* ' + (room.lastMsg.user_id | mUserDisplayName: room.room_id) + ' ' + room.lastMsg.content.body | linky:'_blank'"> + <span ng-bind-html="'* ' + (lastMsg.user_id | mUserDisplayName: room.room_id) + ' ' + lastMsg.content.body | linky:'_blank'"> </span> </div> <div ng-switch-default> - {{ room.lastMsg.content }} + {{ lastMsg.content }} </div> </div> </div> <div ng-switch-when="m.room.topic"> - {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} changed the topic to: {{ room.lastMsg.content.topic }} + {{ lastMsg.user_id | mUserDisplayName: room.room_id }} changed the topic to: {{ lastMsg.content.topic }} </div> <div ng-switch-when="m.room.name"> - {{ room.lastMsg.user_id | mUserDisplayName: room.room_id }} changed the room name to: {{ room.lastMsg.content.name }} + {{ lastMsg.user_id | mUserDisplayName: room.room_id }} changed the room name to: {{ lastMsg.content.name }} </div> <div ng-switch-default> - <div ng-if="room.lastMsg.type.indexOf('m.call.') === 0"> + <div ng-if="lastMsg.type.indexOf('m.call.') === 0"> Call </div> </div> diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index 50d902ae47..de50058743 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -15,8 +15,8 @@ limitations under the License. */ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) -.controller('RoomController', ['$filter', '$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'eventHandlerService', 'mFileUpload', 'mPresence', 'matrixPhoneService', 'MatrixCall', - function($filter, $scope, $timeout, $routeParams, $location, $rootScope, matrixService, eventHandlerService, mFileUpload, mPresence, matrixPhoneService, MatrixCall) { +.controller('RoomController', ['$filter', '$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'eventHandlerService', 'mFileUpload', 'matrixPhoneService', 'MatrixCall', + function($filter, $scope, $timeout, $routeParams, $location, $rootScope, matrixService, eventHandlerService, mFileUpload, matrixPhoneService, MatrixCall) { 'use strict'; var MESSAGES_PER_PAGINATION = 30; var THUMBNAIL_SIZE = 320; @@ -32,7 +32,8 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) can_paginate: false, // this is toggled off when we are not ready yet to paginate or when we run out of items paginating: false, // used to avoid concurrent pagination requests pulling in dup contents stream_failure: undefined, // the response when the stream fails - waiting_for_joined_event: false // true when the join request is pending. Back to false once the corresponding m.room.member event is received + waiting_for_joined_event: false, // true when the join request is pending. Back to false once the corresponding m.room.member event is received + messages_visibility: "hidden" // In order to avoid flickering when scrolling down the message table at the page opening, delay the message table display }; $scope.members = {}; $scope.autoCompleting = false; @@ -53,8 +54,13 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) return; }; - // Use the filter applied in html to set the input value - $scope.name.newNameText = $filter('mRoomName')($scope.room_id); + var nameEvent = $rootScope.events.rooms[$scope.room_id]['m.room.name']; + if (nameEvent) { + $scope.name.newNameText = nameEvent.content.name; + } + else { + $scope.name.newNameText = ""; + } // Force focus to the input $timeout(function() { @@ -131,6 +137,13 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) $timeout(function() { objDiv.scrollTop = objDiv.scrollHeight; + + // Show the message table once the first scrolldown is done + if ("visible" !== $scope.state.messages_visibility) { + $timeout(function() { + $scope.state.messages_visibility = "visible"; + }, 0); + } }, 0); } }; @@ -139,27 +152,11 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) if (isLive && event.room_id === $scope.room_id) { scrollToBottom(); - - if (window.Notification) { - // Show notification when the window is hidden, or the user is idle - if (document.hidden || matrixService.presence.unavailable === mPresence.getState()) { - var notification = new window.Notification( - ($scope.members[event.user_id].displayname || event.user_id) + - " (" + ($scope.room_alias || $scope.room_id) + ")", // FIXME: don't leak room_ids here - { - "body": event.content.body, - "icon": $scope.members[event.user_id].avatar_url - }); - $timeout(function() { - notification.close(); - }, 5 * 1000); - } - } } }); $scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) { - if (isLive) { + if (isLive && event.room_id === $scope.room_id) { if ($scope.state.waiting_for_joined_event) { // The user has successfully joined the room, we can getting data for this room $scope.state.waiting_for_joined_event = false; @@ -177,19 +174,33 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) else { user = event.user_id; } - if ("ban" === event.membership) { $scope.state.permission_denied = "You have been banned by " + user; } else { $scope.state.permission_denied = "You have been kicked by " + user; - } - + } } else { scrollToBottom(); updateMemberList(event); + + // Notify when a user joins + if ((document.hidden || matrixService.presence.unavailable === mPresence.getState()) + && event.state_key !== $scope.state.user_id && "join" === event.membership) { + debugger; + var notification = new window.Notification( + event.content.displayname + + " (" + (matrixService.getRoomIdToAliasMapping(event.room_id) || event.room_id) + ")", // FIXME: don't leak room_ids here + { + "body": event.content.displayname + " joined", + "icon": event.content.avatar_url ? event.content.avatar_url : undefined + }); + $timeout(function() { + notification.close(); + }, 5 * 1000); + } } } }); @@ -235,7 +246,7 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) matrixService.paginateBackMessages($scope.room_id, $rootScope.events.rooms[$scope.room_id].pagination.earliest_token, numItems).then( function(response) { - eventHandlerService.handleRoomMessages($scope.room_id, response.data, false); + eventHandlerService.handleRoomMessages($scope.room_id, response.data, false, 'b'); if (response.data.chunk.length < MESSAGES_PER_PAGINATION) { // no more messages to paginate. this currently never gets turned true again, as we never // expire paginated contents in the current implementation. @@ -406,12 +417,15 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) }; $scope.send = function() { - if ($scope.textInput === "") { + if (undefined === $scope.textInput || $scope.textInput === "") { return; } scrollToBottom(true); - + + // Store the command in the history + history.push($scope.textInput); + var promise; var cmd; var args; @@ -676,6 +690,10 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) var onInit2 = function() { console.log("onInit2"); + // Scroll down as soon as possible so that we point to the last message + // if it already exists in memory + scrollToBottom(true); + // Make sure the initialSync has been before going further eventHandlerService.waitForInitialSyncCompletion().then( function() { @@ -684,6 +702,10 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) // The room members is available in the data fetched by initialSync if ($rootScope.events.rooms[$scope.room_id]) { + + // There is no need to do a 1st pagination (initialSync provided enough to fill a page) + $scope.state.first_pagination = false; + var members = $rootScope.events.rooms[$scope.room_id].members; // Update the member list @@ -729,7 +751,10 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) // Make recents highlight the current room $scope.recentsSelectedRoomID = $scope.room_id; - // Get the up-to-date the current member list + // Init the history for this room + history.init(); + + // Get the up-to-date the current member list matrixService.getMemberList($scope.room_id).then( function(response) { for (var i = 0; i < response.data.chunk.length; i++) { @@ -743,9 +768,18 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) // Arm list timing update timer updateMemberListPresenceAge(); - // Start pagination + // Allow pagination $scope.state.can_paginate = true; - paginate(MESSAGES_PER_PAGINATION); + + // Do a first pagination only if it is required + // FIXME: Should be no more require when initialSync/{room_id} will be available + if ($scope.state.first_pagination) { + paginate(MESSAGES_PER_PAGINATION); + } + else { + // There are already messages, go to the last message + scrollToBottom(true); + } }, function(error) { $scope.feedback = "Failed get member list: " + error.data.error; @@ -832,4 +866,82 @@ angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput']) $rootScope.currentCall = call; }; + // Manage history of typed messages + // History is saved in sessionStoratge so that it survives when the user + // navigates through the rooms and when it refreshes the page + var history = { + // The list of typed messages. Index 0 is the more recents + data: [], + + // The position in the history currently displayed + position: -1, + + // The message the user has started to type before going into the history + typingMessage: undefined, + + // Init/load data for the current room + init: function() { + var data = sessionStorage.getItem("history_" + $scope.room_id); + if (data) { + this.data = JSON.parse(data); + } + }, + + // Store a message in the history + push: function(message) { + this.data.unshift(message); + + // Update the session storage + sessionStorage.setItem("history_" + $scope.room_id, JSON.stringify(this.data)); + + // Reset history position + this.position = -1; + this.typingMessage = undefined; + }, + + // Move in the history + go: function(offset) { + + if (-1 === this.position) { + // User starts to go to into the history, save the current line + this.typingMessage = $scope.textInput; + } + else { + // If the user modified this line in history, keep the change + this.data[this.position] = $scope.textInput; + } + + // Bounds the new position to valid data + var newPosition = this.position + offset; + newPosition = Math.max(-1, newPosition); + newPosition = Math.min(newPosition, this.data.length - 1); + this.position = newPosition; + + if (-1 !== this.position) { + // Show the message from the history + $scope.textInput = this.data[this.position]; + } + else if (undefined !== this.typingMessage) { + // Go back to the message the user started to type + $scope.textInput = this.typingMessage; + } + } + }; + + // Make history singleton methods available from HTML + $scope.history = { + goUp: function($event) { + if ($scope.room_id) { + history.go(1); + } + $event.preventDefault(); + }, + goDown: function($event) { + if ($scope.room_id) { + history.go(-1); + } + $event.preventDefault(); + } + }; + }]); diff --git a/webclient/room/room.html b/webclient/room/room.html index 9d617eadd8..44a0e34d9f 100644 --- a/webclient/room/room.html +++ b/webclient/room/room.html @@ -9,7 +9,7 @@ {{ room_id | mRoomName }} </div> <form ng-submit="name.updateName()" ng-show="name.isEditing" class="roomNameForm"> - <input ng-model="name.newNameText" ng-blur="name.cancelEdit()" class="roomNameInput" /> + <input ng-model="name.newNameText" ng-blur="name.cancelEdit()" class="roomNameInput" placeholder="Room name"/> </form> </div> @@ -23,7 +23,7 @@ {{ events.rooms[room_id]['m.room.topic'].content.topic | limitTo: 200}} </div> <form ng-submit="topic.updateTopic()" ng-show="topic.isEditing" class="roomTopicForm"> - <input ng-model="topic.newTopicText" ng-blur="topic.cancelEdit()" class="roomTopicInput" /> + <input ng-model="topic.newTopicText" ng-blur="topic.cancelEdit()" class="roomTopicInput" placeholder="Topic"/> </form> </div> </div> @@ -56,7 +56,10 @@ </table> </div> - <div id="messageTableWrapper" ng-hide="state.permission_denied" keep-scroll> + <div id="messageTableWrapper" + ng-hide="state.permission_denied" + ng-style="{ 'visibility': state.messages_visibility }" + keep-scroll> <!-- FIXME: need to have better timestamp semantics than the (msg.content.hsob_ts || msg.ts) hack below --> <table id="messageTable" infinite-scroll="paginateMore()"> <tr ng-repeat="msg in events.rooms[room_id].messages" @@ -105,7 +108,7 @@ <span ng-show='msg.content.msgtype === "m.text"' class="message" - ng-class="msg.echo_msg_state" + ng-class="containsBingWord(msg.content.body) && msg.user_id != state.user_id ? msg.echo_msg_state + ' messageBing' : msg.echo_msg_state" ng-bind-html="((msg.content.msgtype === 'm.text') ? msg.content.body : '') | linky:'_blank'"/> <span ng-show='msg.type === "m.call.invite" && msg.user_id == state.user_id'>Outgoing Call</span> @@ -156,7 +159,8 @@ <td width="*"> <textarea id="mainInput" rows="1" ng-model="textInput" ng-enter="send()" ng-disabled="state.permission_denied" - ng-focus="true" autocomplete="off" tab-complete/> + ng-keydown="(38 === $event.which) ? history.goUp($event) : ((40 === $event.which) ? history.goDown($event) : 0)" + ng-focus="true" autocomplete="off" tab-complete/> </td> <td id="buttonsCell"> <button ng-click="send()" ng-disabled="state.permission_denied">Send</button> diff --git a/webclient/settings/settings-controller.js b/webclient/settings/settings-controller.js index 8c877a24e9..9cdace704a 100644 --- a/webclient/settings/settings-controller.js +++ b/webclient/settings/settings-controller.js @@ -194,7 +194,16 @@ angular.module('SettingsController', ['matrixService', 'mFileUpload', 'mFileInpu /*** Desktop notifications section ***/ $scope.settings = { - notifications: undefined + notifications: undefined, + bingWords: matrixService.config().bingWords + }; + + $scope.saveBingWords = function() { + console.log("Saving words: "+JSON.stringify($scope.settings.bingWords)); + var config = matrixService.config(); + config.bingWords = $scope.settings.bingWords; + matrixService.setConfig(config); + matrixService.saveConfig(); }; // If the browser supports it, check the desktop notification state diff --git a/webclient/settings/settings.html b/webclient/settings/settings.html index c358a6e9d8..0a5a3db51f 100644 --- a/webclient/settings/settings.html +++ b/webclient/settings/settings.html @@ -51,7 +51,16 @@ <h3>Desktop notifications</h3> <div class="section" ng-switch="settings.notifications"> <div ng-switch-when="granted"> - Notifications are enabled. + Notifications are enabled. You will be alerted when a message contains your user ID or display name. + <div class="section"> + <h4>Additional words to alert on:</h4> + <p>Leave blank to alert on all messages.</p> + <input size=40 name="bingWords" ng-model="settings.bingWords" ng-list placeholder="Enter words separated with , (supports regex)" + ng-blur="saveBingWords()"/> + <ul> + <li ng-repeat="word in settings.bingWords">{{word}}</li> + </ul> + </div> </div> <div ng-switch-when="denied"> You have denied permission for notifications.<br/> |