diff options
Diffstat (limited to 'syweb/webclient/room')
-rw-r--r-- | syweb/webclient/room/room-controller.js | 852 | ||||
-rw-r--r-- | syweb/webclient/room/room-directive.js | 275 | ||||
-rw-r--r-- | syweb/webclient/room/room.html | 266 |
3 files changed, 1393 insertions, 0 deletions
diff --git a/syweb/webclient/room/room-controller.js b/syweb/webclient/room/room-controller.js new file mode 100644 index 0000000000..67372a804f --- /dev/null +++ b/syweb/webclient/room/room-controller.js @@ -0,0 +1,852 @@ +/* +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. +*/ + +angular.module('RoomController', ['ngSanitize', 'matrixFilter', 'mFileInput', 'angular-peity']) +.controller('RoomController', ['$modal', '$filter', '$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'mPresence', 'eventHandlerService', 'mFileUpload', 'matrixPhoneService', 'MatrixCall', 'modelService', 'recentsService', 'commandsService', + function($modal, $filter, $scope, $timeout, $routeParams, $location, $rootScope, matrixService, mPresence, eventHandlerService, mFileUpload, matrixPhoneService, MatrixCall, modelService, recentsService, commandsService) { + 'use strict'; + var MESSAGES_PER_PAGINATION = 30; + var THUMBNAIL_SIZE = 320; + + // .html needs this + $scope.containsBingWord = eventHandlerService.eventContainsBingWord; + + // Room ids. Computed and resolved in onInit + $scope.room_id = undefined; + $scope.room_alias = undefined; + + $scope.state = { + user_id: matrixService.config().user_id, + permission_denied: undefined, // If defined, this string contains the reason why the user cannot join the room + first_pagination: true, // this is toggled off when the first pagination is done + 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 + 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.imageURLToSend = ""; + + + // vars and functions for updating the name + $scope.name = { + isEditing: false, + newNameText: "", + editName: function() { + if ($scope.name.isEditing) { + console.log("Warning: Already editing name."); + return; + }; + + var nameEvent = $scope.room.current_room_state.state_events['m.room.name']; + if (nameEvent) { + $scope.name.newNameText = nameEvent.content.name; + } + else { + $scope.name.newNameText = ""; + } + + // Force focus to the input + $timeout(function() { + angular.element('.roomNameInput').focus(); + }, 0); + + $scope.name.isEditing = true; + }, + updateName: function() { + console.log("Updating name to "+$scope.name.newNameText); + matrixService.setName($scope.room_id, $scope.name.newNameText).then( + function() { + }, + function(error) { + $scope.feedback = "Request failed: " + error.data.error; + } + ); + + $scope.name.isEditing = false; + }, + cancelEdit: function() { + $scope.name.isEditing = false; + } + }; + + // vars and functions for updating the topic + $scope.topic = { + isEditing: false, + newTopicText: "", + editTopic: function() { + if ($scope.topic.isEditing) { + console.log("Warning: Already editing topic."); + return; + } + var topicEvent = $scope.room.current_room_state.state_events['m.room.topic']; + if (topicEvent) { + $scope.topic.newTopicText = topicEvent.content.topic; + } + else { + $scope.topic.newTopicText = ""; + } + + // Force focus to the input + $timeout(function() { + angular.element('.roomTopicInput').focus(); + }, 0); + + $scope.topic.isEditing = true; + }, + updateTopic: function() { + console.log("Updating topic to "+$scope.topic.newTopicText); + matrixService.setTopic($scope.room_id, $scope.topic.newTopicText).then( + function() { + }, + function(error) { + $scope.feedback = "Request failed: " + error.data.error; + } + ); + + $scope.topic.isEditing = false; + }, + cancelEdit: function() { + $scope.topic.isEditing = false; + } + }; + + var scrollToBottom = function(force) { + console.log("Scrolling to bottom"); + + // Do not autoscroll to the bottom to display the new event if the user is not at the bottom. + // Exception: in case where the event is from the user, we want to force scroll to the bottom + var objDiv = document.getElementById("messageTableWrapper"); + // add a 10px buffer to this check so if the message list is not *quite* + // at the bottom it still scrolls since it basically is at the bottom. + if ((10 + objDiv.offsetHeight + objDiv.scrollTop >= objDiv.scrollHeight) || force) { + + $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); + } + }; + + $scope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) { + if (isLive && event.room_id === $scope.room_id) { + scrollToBottom(); + } + }); + + $scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, 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; + onInit3(); + } + else if (event.state_key === $scope.state.user_id && "invite" !== event.membership && "join" !== event.membership) { + var user; + + if ($scope.members[event.user_id]) { + user = $scope.members[event.user_id].displayname; + } + if (user) { + user = user + " (" + event.user_id + ")"; + } + 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); + } + } + }); + + $scope.$on(eventHandlerService.PRESENCE_EVENT, function(ngEvent, event, isLive) { + if (isLive) { + updatePresence(event); + } + }); + + $scope.$on(eventHandlerService.POWERLEVEL_EVENT, function(ngEvent, event, isLive) { + if (isLive && event.room_id === $scope.room_id) { + for (var user_id in event.content) { + updateUserPowerLevel(user_id); + } + } + }); + + $scope.memberCount = function() { + return Object.keys($scope.members).length; + }; + + $scope.paginateMore = function() { + if ($scope.state.can_paginate) { + // console.log("Paginating more."); + paginate(MESSAGES_PER_PAGINATION); + } + }; + + var paginate = function(numItems) { + //console.log("paginate " + numItems + " and first_pagination is " + $scope.state.first_pagination); + if ($scope.state.paginating || !$scope.room_id) { + return; + } + else { + $scope.state.paginating = true; + } + + console.log("paginateBackMessages from " + $scope.room.old_room_state.pagination_token + " for " + numItems); + var originalTopRow = $("#messageTable>tbody>tr:first")[0]; + + // Paginate events from the point in cache + matrixService.paginateBackMessages($scope.room_id, $scope.room.old_room_state.pagination_token, numItems).then( + function(response) { + + 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. + $scope.state.can_paginate = false; + } + + $scope.state.paginating = false; + + var wrapper = $("#messageTableWrapper")[0]; + var table = $("#messageTable")[0]; + // console.log("wrapper height=" + wrapper.clientHeight + ", table scrollHeight=" + table.scrollHeight); + + if ($scope.state.can_paginate) { + // check we don't have to pull in more messages + // n.b. we dispatch through a timeout() to allow the digest to run otherwise the .height methods are stale + $timeout(function() { + if (table.scrollHeight < wrapper.clientHeight) { + paginate(MESSAGES_PER_PAGINATION); + scrollToBottom(); + } + }, 0); + } + + if ($scope.state.first_pagination) { + scrollToBottom(true); + $scope.state.first_pagination = false; + } + else { + // lock the scroll position + $timeout(function() { + // FIXME: this risks a flicker before the scrollTop is actually updated, but we have to + // dispatch it into a function in order to first update the layout. The right solution + // might be to implement it as a directive, more like + // http://stackoverflow.com/questions/23736647/how-to-retain-scroll-position-of-ng-repeat-in-angularjs + // however, this specific solution breaks because it measures the rows height before + // the contents are interpolated. + wrapper.scrollTop = originalTopRow ? (originalTopRow.offsetTop + wrapper.scrollTop) : 0; + }, 0); + } + }, + function(error) { + console.log("Failed to paginateBackMessages: " + JSON.stringify(error)); + $scope.state.paginating = false; + } + ); + }; + + var updateMemberList = function(chunk) { + if (chunk.room_id != $scope.room_id) return; + + + // set target_user_id to keep things clear + var target_user_id = chunk.state_key; + + var isNewMember = !(target_user_id in $scope.members); + if (isNewMember) { + + // Ignore banned and kicked (leave) people + if ("ban" === chunk.membership || "leave" === chunk.membership) { + return; + } + + // FIXME: why are we copying these fields around inside chunk? + if ("presence" in chunk.content) { + chunk.presence = chunk.content.presence; + } + if ("last_active_ago" in chunk.content) { + chunk.last_active_ago = chunk.content.last_active_ago; + $scope.now = new Date().getTime(); + chunk.last_updated = $scope.now; + } + if ("displayname" in chunk.content) { + chunk.displayname = chunk.content.displayname; + } + if ("avatar_url" in chunk.content) { + chunk.avatar_url = chunk.content.avatar_url; + } + $scope.members[target_user_id] = chunk; + + var usr = modelService.getUser(target_user_id); + if (usr) { + updatePresence(usr.event); + } + } + else { + // selectively update membership and presence else it will nuke the picture and displayname too :/ + + // Remove banned and kicked (leave) people + if ("ban" === chunk.membership || "leave" === chunk.membership) { + delete $scope.members[target_user_id]; + return; + } + + var member = $scope.members[target_user_id]; + member.membership = chunk.content.membership; + if ("presence" in chunk.content) { + member.presence = chunk.content.presence; + } + if ("last_active_ago" in chunk.content) { + member.last_active_ago = chunk.content.last_active_ago; + $scope.now = new Date().getTime(); + member.last_updated = $scope.now; + } + } + }; + + var updateMemberListPresenceAge = function() { + $scope.now = new Date().getTime(); + // TODO: don't bother polling every 5s if we know none of our counters are younger than 1 minute + $timeout(updateMemberListPresenceAge, 5 * 1000); + }; + + var updatePresence = function(chunk) { + if (!(chunk.content.user_id in $scope.members)) { + console.log("updatePresence: Unknown member for chunk " + JSON.stringify(chunk)); + return; + } + var member = $scope.members[chunk.content.user_id]; + + // XXX: why not just pass the chunk straight through? + if ("presence" in chunk.content) { + member.presence = chunk.content.presence; + } + + if ("last_active_ago" in chunk.content) { + member.last_active_ago = chunk.content.last_active_ago; + $scope.now = new Date().getTime(); + member.last_updated = $scope.now; + } + + // this may also contain a new display name or avatar url, so check. + if ("displayname" in chunk.content) { + member.displayname = chunk.content.displayname; + } + + if ("avatar_url" in chunk.content) { + member.avatar_url = chunk.content.avatar_url; + } + }; + + var updateUserPowerLevel = function(user_id) { + var member = $scope.members[user_id]; + if (member) { + member.powerLevel = eventHandlerService.getUserPowerLevel($scope.room_id, user_id); + + normaliseMembersPowerLevels(); + } + }; + + // Normalise users power levels so that the user with the higher power level + // will have a bar covering 100% of the width of his avatar + var normaliseMembersPowerLevels = function() { + // Find the max power level + var maxPowerLevel = 0; + for (var i in $scope.members) { + if (!$scope.members.hasOwnProperty(i)) continue; + + var member = $scope.members[i]; + if (member.powerLevel) { + maxPowerLevel = Math.max(maxPowerLevel, member.powerLevel); + } + } + + // Normalized them on a 0..100% scale to be use in css width + if (maxPowerLevel) { + for (var i in $scope.members) { + if (!$scope.members.hasOwnProperty(i)) continue; + + var member = $scope.members[i]; + member.powerLevelNorm = (member.powerLevel * 100) / maxPowerLevel; + } + } + }; + + $scope.send = function() { + var input = $('#mainInput').val(); + + if (undefined === input || input === "") { + return; + } + + scrollToBottom(true); + + // Store the command in the history + $rootScope.$broadcast("commandHistory:BROADCAST_NEW_HISTORY_ITEM(item)", + input); + + var isEmote = input.indexOf("/me ") === 0; + var promise; + if (!isEmote) { + promise = commandsService.processInput($scope.room_id, input); + } + var echo = false; + + + if (!promise) { // not a non-echoable command + echo = true; + if (isEmote) { + promise = matrixService.sendEmoteMessage($scope.room_id, input.substring(4)); + } + else { + promise = matrixService.sendTextMessage($scope.room_id, input); + } + } + + if (echo) { + // Echo the message to the room + // To do so, create a minimalist fake text message event and add it to the in-memory list of room messages + var echoMessage = { + content: { + body: (isEmote ? input.substring(4) : input), + msgtype: (isEmote ? "m.emote" : "m.text"), + }, + origin_server_ts: new Date().getTime(), // fake a timestamp + room_id: $scope.room_id, + type: "m.room.message", + user_id: $scope.state.user_id, + echo_msg_state: "messagePending" // Add custom field to indicate the state of this fake message to HTML + }; + + $('#mainInput').val(''); + $scope.room.addMessageEvent(echoMessage); + scrollToBottom(); + } + + if (promise) { + // Reset previous feedback + $scope.feedback = ""; + + promise.then( + function(response) { + console.log("Request successfully sent"); + + if (echo) { + // Mark this fake message event with its allocated event_id + // When the true message event will come from the events stream (in handleMessage), + // we will be able to replace the fake one by the true one + echoMessage.event_id = response.data.event_id; + } + else { + $('#mainInput').val(''); + } + }, + function(error) { + $scope.feedback = error.data.error; + + if (echoMessage) { + // Mark the message as unsent for the rest of the page life + echoMessage.origin_server_ts = "Unsent"; + echoMessage.echo_msg_state = "messageUnSent"; + } + }); + } + }; + + $scope.onInit = function() { + console.log("onInit"); + + // Does the room ID provided in the URL? + var room_id_or_alias; + if ($routeParams.room_id_or_alias) { + room_id_or_alias = decodeURIComponent($routeParams.room_id_or_alias); + } + + if (room_id_or_alias && '!' === room_id_or_alias[0]) { + // Yes. We can go on right now + $scope.room_id = room_id_or_alias; + $scope.room_alias = modelService.getRoomIdToAliasMapping($scope.room_id); + onInit2(); + } + else { + // No. The URL contains the room alias. Get this alias. + if (room_id_or_alias) { + // The room alias was passed urlencoded, use it as is + $scope.room_alias = room_id_or_alias; + } + else { + // Else get the room alias by hand from the URL + // ie: extract #public:localhost:8080 from http://127.0.0.1:8000/#/room/#public:localhost:8080 + if (3 === location.hash.split("#").length) { + $scope.room_alias = "#" + location.hash.split("#")[2]; + } + else { + // In case of issue, go to the default page + console.log("Error: cannot extract room alias"); + $location.url("/"); + return; + } + } + + // Need a room ID required in Matrix API requests + console.log("Resolving alias: " + $scope.room_alias); + matrixService.resolveRoomAlias($scope.room_alias).then(function(response) { + $scope.room_id = response.data.room_id; + console.log(" -> Room ID: " + $scope.room_id); + + // Now, we can go on + onInit2(); + }, + function () { + // In case of issue, go to the default page + console.log("Error: cannot resolve room alias"); + $location.url("/"); + }); + } + }; + + var onInit2 = function() { + console.log("onInit2"); + // ============================= + $scope.room = modelService.getRoom($scope.room_id); + // ============================= + + // 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() { + + var needsToJoin = true; + + // The room members is available in the data fetched by initialSync + if ($scope.room) { + + var messages = $scope.room.events; + + if (0 === messages.length + || (1 === messages.length && "m.room.member" === messages[0].type && "invite" === messages[0].content.membership && $scope.state.user_id === messages[0].state_key)) { + // If we just joined a room, we won't have this history from initial sync, so we should try to paginate it anyway + $scope.state.first_pagination = true; + } + else { + // There is no need to do a 1st pagination (initialSync provided enough to fill a page) + $scope.state.first_pagination = false; + } + + var members = $scope.room.current_room_state.members; + + // Update the member list + for (var i in members) { + if (!members.hasOwnProperty(i)) continue; + + var member = members[i].event; + updateMemberList(member); + } + + // Check if the user has already join the room + if ($scope.state.user_id in members) { + if ("join" === members[$scope.state.user_id].event.content.membership) { + needsToJoin = false; + } + } + } + + // Do we to join the room before starting? + if (needsToJoin) { + $scope.state.waiting_for_joined_event = true; + matrixService.join($scope.room_id).then( + function() { + // TODO: factor out the common housekeeping whenever we try to join a room or alias + matrixService.roomState($scope.room_id).then( + function(response) { + eventHandlerService.handleEvents(response.data, false, true); + }, + function(error) { + console.error("Failed to get room state for: " + $scope.room_id); + } + ); + + // onInit3 will be called once the joined m.room.member event is received from the events stream + // This avoids to get the joined information twice in parallel: + // - one from the events stream + // - one from the pagination because the pagination window covers this event ts + console.log("Joined room "+$scope.room_id); + }, + function(reason) { + console.log("Can't join room: " + JSON.stringify(reason)); + // FIXME: what if it wasn't a perms problem? + $scope.state.permission_denied = "You do not have permission to join this room"; + }); + } + else { + onInit3(); + } + } + ); + }; + + var onInit3 = function() { + console.log("onInit3"); + + // Make recents highlight the current room + recentsService.setSelectedRoomId($scope.room_id); + + // 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++) { + var chunk = response.data.chunk[i]; + updateMemberList(chunk); + + // Add his power level + updateUserPowerLevel(chunk.user_id); + } + + // Arm list timing update timer + updateMemberListPresenceAge(); + + // Allow pagination + $scope.state.can_paginate = true; + + // 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; + } + ); + }; + + $scope.leaveRoom = function() { + + matrixService.leave($scope.room_id).then( + function(response) { + console.log("Left room " + $scope.room_id); + $location.url("home"); + }, + function(error) { + $scope.feedback = "Failed to leave room: " + error.data.error; + }); + }; + + $scope.sendImage = function(url, body) { + scrollToBottom(true); + + matrixService.sendImageMessage($scope.room_id, url, body).then( + function() { + console.log("Image sent"); + }, + function(error) { + $scope.feedback = "Failed to send image: " + error.data.error; + }); + }; + + $scope.imageFileToSend; + $scope.$watch("imageFileToSend", function(newValue, oldValue) { + if ($scope.imageFileToSend) { + // Upload this image with its thumbnail to Internet + mFileUpload.uploadImageAndThumbnail($scope.imageFileToSend, THUMBNAIL_SIZE).then( + function(imageMessage) { + // imageMessage is complete message structure, send it as is + matrixService.sendMessage($scope.room_id, undefined, imageMessage).then( + function() { + console.log("Image message sent"); + }, + function(error) { + $scope.feedback = "Failed to send image message: " + error.data.error; + }); + }, + function(error) { + $scope.feedback = "Can't upload image"; + } + ); + } + }); + + $scope.loadMoreHistory = function() { + paginate(MESSAGES_PER_PAGINATION); + }; + + $scope.checkWebRTC = function() { + if (!$rootScope.isWebRTCSupported()) { + alert("Your browser does not support WebRTC"); + return false; + } + if ($scope.memberCount() != 2) { + alert("WebRTC calls are currently only supported on rooms with two members"); + return false; + } + return true; + }; + + $scope.startVoiceCall = function() { + if (!$scope.checkWebRTC()) return; + var call = new MatrixCall($scope.room_id); + call.onError = $rootScope.onCallError; + call.onHangup = $rootScope.onCallHangup; + // remote video element is used for playing audio in voice calls + call.remoteVideoSelector = angular.element('#remoteVideo')[0]; + call.placeVoiceCall(); + $rootScope.currentCall = call; + }; + + $scope.startVideoCall = function() { + if (!$scope.checkWebRTC()) return; + + var call = new MatrixCall($scope.room_id); + call.onError = $rootScope.onCallError; + call.onHangup = $rootScope.onCallHangup; + call.localVideoSelector = '#localVideo'; + call.remoteVideoSelector = '#remoteVideo'; + call.placeVideoCall(); + $rootScope.currentCall = call; + }; + + $scope.openJson = function(content) { + $scope.event_selected = angular.copy(content); + + // FIXME: Pre-calculated event data should be stripped in a nicer way. + $scope.event_selected.__room_member = undefined; + $scope.event_selected.__target_room_member = undefined; + + // scope this so the template can check power levels and enable/disable + // buttons + $scope.pow = eventHandlerService.getUserPowerLevel; + + var modalInstance = $modal.open({ + templateUrl: 'eventInfoTemplate.html', + controller: 'EventInfoController', + scope: $scope + }); + + modalInstance.result.then(function(action) { + if (action === "redact") { + var eventId = $scope.event_selected.event_id; + console.log("Redacting event ID " + eventId); + matrixService.redactEvent( + $scope.event_selected.room_id, + eventId + ).then(function(response) { + console.log("Redaction = " + JSON.stringify(response)); + }, function(error) { + console.error("Failed to redact event: "+JSON.stringify(error)); + if (error.data.error) { + $scope.feedback = error.data.error; + } + }); + } + }, function() { + // any dismiss code + }); + }; + + $scope.openRoomInfo = function() { + $scope.roomInfo = {}; + $scope.roomInfo.newEvent = { + content: {}, + type: "", + state_key: "" + }; + + var stateEvents = $scope.room.current_room_state.state_events; + // The modal dialog will 2-way bind this field, so we MUST make a deep + // copy of the state events else we will be *actually adjusing our view + // of the world* when fiddling with the JSON!! Apparently parse/stringify + // is faster than jQuery's extend when doing deep copies. + $scope.roomInfo.stateEvents = JSON.parse(JSON.stringify(stateEvents)); + var modalInstance = $modal.open({ + templateUrl: 'roomInfoTemplate.html', + controller: 'RoomInfoController', + size: 'lg', + scope: $scope + }); + }; + +}]) +.controller('EventInfoController', function($scope, $modalInstance) { + console.log("Displaying modal dialog for >>>> " + JSON.stringify($scope.event_selected)); + $scope.redact = function() { + console.log("User level = "+$scope.pow($scope.room_id, $scope.state.user_id)+ + " Redact level = "+$scope.room.current_room_state.state_events["m.room.ops_levels"].content.redact_level); + console.log("Redact event >> " + JSON.stringify($scope.event_selected)); + $modalInstance.close("redact"); + }; + $scope.dismiss = $modalInstance.dismiss; +}) +.controller('RoomInfoController', function($scope, $modalInstance, $filter, matrixService) { + console.log("Displaying room info."); + + $scope.userIDToInvite = ""; + + $scope.inviteUser = function() { + + matrixService.invite($scope.room_id, $scope.userIDToInvite).then( + function() { + console.log("Invited."); + $scope.feedback = "Invite successfully sent to " + $scope.userIDToInvite; + $scope.userIDToInvite = ""; + }, + function(reason) { + $scope.feedback = "Failure: " + reason.data.error; + }); + }; + + $scope.submit = function(event) { + if (event.content) { + console.log("submit >>> " + JSON.stringify(event.content)); + matrixService.sendStateEvent($scope.room_id, event.type, + event.content, event.state_key).then(function(response) { + $modalInstance.dismiss(); + }, function(err) { + $scope.feedback = err.data.error; + } + ); + } + }; + + $scope.dismiss = $modalInstance.dismiss; + +}); diff --git a/syweb/webclient/room/room-directive.js b/syweb/webclient/room/room-directive.js new file mode 100644 index 0000000000..187032aa88 --- /dev/null +++ b/syweb/webclient/room/room-directive.js @@ -0,0 +1,275 @@ +/* + 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('RoomController') +.directive('tabComplete', ['$timeout', function ($timeout) { + return function (scope, element, attrs) { + element.bind("keydown keypress", function (event) { + // console.log("event: " + event.which); + var TAB = 9; + var SHIFT = 16; + var keypressCode = event.which; + if (keypressCode === TAB) { + if (!scope.tabCompleting) { // cache our starting text + scope.tabCompleteOriginal = element[0].value; + scope.tabCompleting = true; + scope.tabCompleteIndex = 0; + } + + // loop in the right direction + if (event.shiftKey) { + scope.tabCompleteIndex--; + if (scope.tabCompleteIndex < 0) { + // wrap to the last search match, and fix up to a real + // index value after we've matched + scope.tabCompleteIndex = Number.MAX_VALUE; + } + } + else { + scope.tabCompleteIndex++; + } + + + var searchIndex = 0; + var targetIndex = scope.tabCompleteIndex; + var text = scope.tabCompleteOriginal; + + // console.log("targetIndex: " + targetIndex + ", + // text=" + text); + + // FIXME: use the correct regexp to recognise userIDs --M + // + // XXX: I don't really know what the point of this is. You + // WANT to match freeform text given you want to match display + // names AND user IDs. Surely you just want to get the last + // word out of the input text and that's that? + // Am I missing something here? -- Kegan + // + // You're not missing anything - my point was that we should + // explicitly define the syntax for user IDs /somewhere/. + // Meanwhile as long as the delimeters are well defined, we + // could just pick "the last word". But to know what the + // correct delimeters are, we probably do need a formal + // syntax for user IDs to refer to... --Matthew + + var search = /@?([a-zA-Z0-9_\-:\.]+)$/.exec(text); + + if (targetIndex === 0) { // 0 is always the original text + element[0].value = text; + // Force angular to wake up and update the input ng-model + // by firing up input event + angular.element(element[0]).triggerHandler('input'); + } + else if (search && search[1]) { + // console.log("search found: " + search+" from "+text); + var expansion; + + // FIXME: could do better than linear search here + angular.forEach(scope.members, function(item, name) { + if (item.displayname && searchIndex < targetIndex) { + if (item.displayname.toLowerCase().indexOf(search[1].toLowerCase()) === 0) { + expansion = item.displayname; + searchIndex++; + } + } + }); + if (searchIndex < targetIndex) { // then search raw mxids + angular.forEach(scope.members, function(item, name) { + if (searchIndex < targetIndex) { + // === 1 because mxids are @username + if (name.toLowerCase().indexOf(search[1].toLowerCase()) === 1) { + expansion = name; + searchIndex++; + } + } + }); + } + + if (searchIndex === targetIndex || + targetIndex === Number.MAX_VALUE) { + // xchat-style tab complete, add a colon if tab + // completing at the start of the text + if (search[0].length === text.length) + expansion += ": "; + else + expansion += " "; + element[0].value = text.replace(/@?([a-zA-Z0-9_\-:\.]+)$/, expansion); + // cancel blink + element[0].className = ""; + if (targetIndex === Number.MAX_VALUE) { + // wrap the index around to the last index found + scope.tabCompleteIndex = searchIndex; + targetIndex = searchIndex; + } + } + else { + // console.log("wrapped!"); + element[0].className = "blink"; // XXX: slightly naughty to bypass angular + $timeout(function() { + element[0].className = ""; + }, 150); + element[0].value = text; + scope.tabCompleteIndex = 0; + } + + // Force angular to wak up and update the input ng-model by + // firing up input event + angular.element(element[0]).triggerHandler('input'); + } + else { + scope.tabCompleteIndex = 0; + } + // prevent the default TAB operation (typically focus shifting) + event.preventDefault(); + } + else if (keypressCode !== SHIFT && scope.tabCompleting) { + scope.tabCompleting = false; + scope.tabCompleteIndex = 0; + } + }); + }; +}]) +// A directive which stores text sent into it and restores it via up/down arrows +.directive('commandHistory', [ function() { + var BROADCAST_NEW_HISTORY_ITEM = "commandHistory:BROADCAST_NEW_HISTORY_ITEM(item)"; + + // Manage history of typed messages + // History is saved in sessionStorage 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, + + element: undefined, + roomId: undefined, + + // The message the user has started to type before going into the history + typingMessage: undefined, + + // Init/load data for the current room + init: function(element, roomId) { + this.roomId = roomId; + this.element = element; + var data = sessionStorage.getItem("history_" + this.roomId); + 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_" + this.roomId, 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 = this.element.val(); + } + else { + // If the user modified this line in history, keep the change + this.data[this.position] = this.element.val(); + } + + // 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 + this.element.val(this.data[this.position]); + } + else if (undefined !== this.typingMessage) { + // Go back to the message the user started to type + this.element.val(this.typingMessage); + } + } + }; + + return { + restrict: "AE", + scope: { + roomId: "=commandHistory" + }, + link: function (scope, element, attrs) { + element.bind("keydown", function (event) { + var keycodePressed = event.which; + var UP_ARROW = 38; + var DOWN_ARROW = 40; + if (scope.roomId) { + if (keycodePressed === UP_ARROW) { + history.go(1); + event.preventDefault(); + } + else if (keycodePressed === DOWN_ARROW) { + history.go(-1); + event.preventDefault(); + } + } + }); + + scope.$on(BROADCAST_NEW_HISTORY_ITEM, function(ngEvent, item) { + history.push(item); + }); + + history.init(element, scope.roomId); + }, + + } +}]) + +// A directive to anchor the scroller position at the bottom when the browser is resizing. +// When the screen resizes, the bottom of the element remains the same, not the top. +.directive('keepScroll', ['$window', function($window) { + return { + link: function(scope, elem, attrs) { + + scope.windowHeight = $window.innerHeight; + + // Listen to window size change + angular.element($window).bind('resize', function() { + + // If the scroller is scrolled to the bottom, there is nothing to do. + // The browser will move it as expected + if (elem.scrollTop() + elem.height() !== elem[0].scrollHeight) { + // Else, move the scroller position according to the window height change delta + var windowHeightDelta = $window.innerHeight - scope.windowHeight; + elem.scrollTop(elem.scrollTop() - windowHeightDelta); + } + + // Store the new window height for the next screen size change + scope.windowHeight = $window.innerHeight; + }); + } + }; +}]); + diff --git a/syweb/webclient/room/room.html b/syweb/webclient/room/room.html new file mode 100644 index 0000000000..17565f879b --- /dev/null +++ b/syweb/webclient/room/room.html @@ -0,0 +1,266 @@ +<div ng-controller="RoomController" data-ng-init="onInit()" class="room" style="height: 100%;"> + + <script type="text/ng-template" id="eventInfoTemplate.html"> + <div class="modal-body"> + <pre> {{event_selected | json}} </pre> + </div> + <div class="modal-footer"> + <button ng-click="redact()" type="button" class="btn btn-danger redact-button" + ng-disabled="!room.current_room_state.state('m.room.ops_levels').content.redact_level || !pow(room_id, state.user_id) || pow(room_id, state.user_id) < room.current_room_state.state('m.room.ops_levels').content.redact_level" + title="Delete this event on all home servers. This cannot be undone."> + Redact + </button> + + <button ng-click="dismiss()" type="button" class="btn"> + Close + </button> + </div> + </script> + + <script type="text/ng-template" id="roomInfoTemplate.html"> + <div class="modal-body"> + <span> + Invite a user: + <input ng-model="userIDToInvite" size="32" type="text" ng-enter="inviteUser()" ng-disabled="state.permission_denied" placeholder="User ID (ex:@user:homeserver)"/> + <button ng-click="inviteUser()" ng-disabled="state.permission_denied">Invite</button> + </span> + <br/> + <br/> + <button ng-click="leaveRoom()" ng-disabled="state.permission_denied">Leave Room</button> + </br/> + <table class="room-info"> + <tr ng-repeat="(key, event) in roomInfo.stateEvents" class="room-info-event"> + <td class="room-info-event-meta" width="30%"> + <span class="monospace">{{ event.type }}</span> + <span ng-show="event.state_key" class="monospace"> ({{event.state_key}})</span> + <br/> + {{ (event.origin_server_ts) | date:'MMM d HH:mm' }} + <br/> + Set by: <span class="monospace">{{ event.user_id }}</span> + <br/> + <span ng-show="event.required_power_level >= 0">Required power level: {{event.required_power_level}}<br/></span> + <button ng-click="submit(event)" type="button" class="btn btn-success" ng-disabled="!event.content"> + Submit + </button> + </td> + <td class="room-info-event-content" width="70%"> + <textarea class="room-info-textarea-content" msd-elastic ng-model="event.content" asjson></textarea> + </td> + </tr> + <tr> + <td class="room-info-event-meta" width="30%"> + <input ng-model="roomInfo.newEvent.type" placeholder="your.event.type" /> + <br/> + <button ng-click="submit(roomInfo.newEvent)" type="button" class="btn btn-success" ng-disabled="!roomInfo.newEvent.content || !roomInfo.newEvent.type"> + Submit + </button> + </td> + <td class="room-info-event-content" width="70%"> + <textarea class="room-info-textarea-content" msd-elastic ng-model="roomInfo.newEvent.content" asjson></textarea> + </td> + </tr> + </table> + </div> + <div class="modal-footer"> + <button ng-click="dismiss()" type="button" class="btn"> + Close + </button> + </div> + </script> + + <div id="roomHeader"> + <a href ng-click="goToPage('/')"><img src="img/logo-small.png" width="100" height="43" alt="[matrix]"/></a> + + <div id="controlButtons"> + <button ng-click="startVoiceCall()" class="controlButton" + style="background: url('img/voice.png')" + ng-show="(currentCall == undefined || currentCall.state == 'ended')" + ng-disabled="state.permission_denied" + > + </button> + <button ng-click="startVideoCall()" class="controlButton" + style="background: url('img/video.png')" + ng-show="(currentCall == undefined || currentCall.state == 'ended')" + ng-disabled="state.permission_denied" + > + </button> + <button ng-click="openRoomInfo()" class="controlButton" + style="background: url('img/settings.png')" + > + </button> + </div> + + <div class="roomHeaderInfo"> + + <div class="roomNameSection"> + <div ng-hide="name.isEditing" ng-dblclick="name.editName()" id="roomName"> + {{ 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" placeholder="Room name"/> + </form> + </div> + + <div class="roomTopicSection"> + <button ng-hide="room.current_room_state.state_events['m.room.topic'].content.topic || topic.isEditing" + ng-click="topic.editTopic()" class="roomTopicSetNew"> + Set Topic + </button> + <div ng-show="room.current_room_state.state_events['m.room.topic'].content.topic || topic.isEditing"> + <div ng-hide="topic.isEditing" ng-dblclick="topic.editTopic()" id="roomTopic" + ng-bind-html="room.current_room_state.state_events['m.room.topic'].content.topic | limitTo: 200 | linky:'_blank'"> + </div> + <form ng-submit="topic.updateTopic()" ng-show="topic.isEditing" class="roomTopicForm"> + <input ng-model="topic.newTopicText" ng-blur="topic.cancelEdit()" class="roomTopicInput" placeholder="Topic"/> + </form> + </div> + </div> + </div> + </div> + + <div id="roomPage"> + <div id="roomWrapper"> + + <div id="roomRecentsTableWrapper"> + <div ng-include="'recents/recents.html'"></div> + </div> + + <div id="usersTableWrapper" ng-hide="state.permission_denied"> + <div ng-repeat="member in members | orderMembersList" class="userAvatar"> + <div class="userAvatarFrame" ng-class="(member.presence === 'online' ? 'online' : (member.presence === 'unavailable' ? 'unavailable' : '')) + ' ' + (member.membership == 'invite' ? 'invited' : '')"> + <img class="userAvatarImage mouse-pointer" + ng-click="$parent.goToUserPage(member.id)" + ng-src="{{member.avatar_url || 'img/default-profile.png'}}" + alt="{{ member.displayname || member.id.substr(0, member.id.indexOf(':')) }}" + title="{{ member.id }} - power: {{ member.powerLevel }}" + width="80" height="80"/> + <!-- <div class="userPowerLevel" ng-style="{'width': member.powerLevelNorm +'%'}"></div> --> + </div> + <div class="userName"> + <pie-chart ng-show="member.powerLevelNorm" data="[ (member.powerLevelNorm + 0), (100 - member.powerLevelNorm) ]"></pie-chart> + {{ member.id | mUserDisplayName:room_id:true }} + <span ng-show="member.last_active_ago" style="color: #aaa">({{ member.last_active_ago + (now - member.last_updated) | duration }})</span> + </div> + </div> + </div> + + <div id="messageTableWrapper" + ng-hide="state.permission_denied" + ng-style="{ 'visibility': state.messages_visibility }" + keep-scroll> + <table id="messageTable" infinite-scroll="paginateMore()"> + <tr ng-repeat="msg in room.events" + ng-class="(room.events[$index - 1].user_id !== msg.user_id ? 'differentUser' : '') + (msg.user_id === state.user_id ? ' mine' : '')" scroll-item> + <td class="leftBlock" ng-mouseover="state.showTs = 1" ng-mouseout="state.showTs = 0"> + <div class="timestamp" + ng-style="{ 'opacity': state.showTs ? 1.0 : 0.0 }" + ng-class="msg.echo_msg_state"> + {{ (msg.origin_server_ts) | date:'MMM d HH:mm' }} + </div> + <div class="sender" ng-hide="room.events[$index - 1].user_id === msg.user_id || msg.user_id === state.user_id"> {{ msg.__room_member.cnt.displayname || msg.user_id | mUserDisplayName:room_id:true }}</div> + </td> + <td class="avatar"> + <!-- msg.__room_member.avatar_url is just backwards compat, and can be removed in the future. --> + <img class="avatarImage" ng-src="{{ msg.__room_member.cnt.avatar_url || msg.__room_member.avatar_url || 'img/default-profile.png' }}" width="32" height="32" title="{{msg.user_id}}" + ng-hide="room.events[$index - 1].user_id === msg.user_id || msg.user_id === state.user_id"/> + </td> + <td class="msg" ng-class="(!msg.content.membership && ('m.room.topic' !== msg.type && 'm.room.name' !== msg.type))? (msg.content.msgtype === 'm.emote' ? 'emote text' : 'text') : 'membership text'"> + <div class="bubble" ng-dblclick="openJson(msg)"> + <span ng-if="'join' === msg.content.membership && msg.changedKey === 'membership'"> + {{ msg.content.displayname || members[msg.state_key].displayname || msg.state_key }} joined + </span> + <span ng-if="'leave' === msg.content.membership && msg.changedKey === 'membership'"> + <span ng-if="msg.user_id === msg.state_key"> + <!-- FIXME: This seems like a synapse bug that the 'leave' content doesn't give the displayname... --> + {{ msg.__room_member.cnt.displayname || members[msg.state_key].displayname || msg.state_key }} left + </span> + <span ng-if="msg.user_id !== msg.state_key && msg.prev_content"> + {{ msg.content.displayname || members[msg.user_id].displayname || msg.user_id }} + {{ {"invite": "kicked", "join": "kicked", "ban": "unbanned"}[msg.prev_content.membership] }} + {{ msg.__target_room_member.content.displayname || msg.state_key }} + <span ng-if="'join' === msg.prev_content.membership && msg.content.reason"> + : {{ msg.content.reason }} + </span> + </span> + </span> + <span ng-if="'invite' === msg.content.membership && msg.changedKey === 'membership' || + 'ban' === msg.content.membership && msg.changedKey === 'membership'"> + {{ msg.__room_member.cnt.displayname || msg.user_id }} + {{ {"invite": "invited", "ban": "banned"}[msg.content.membership] }} + {{ msg.__target_room_member.cnt.displayname || msg.state_key }} + <span ng-if="msg.prev_content && 'ban' === msg.prev_content.membership && msg.content.reason"> + : {{ msg.content.reason }} + </span> + </span> + <span ng-if="msg.changedKey === 'displayname'"> + {{ msg.user_id }} changed their display name from {{ msg.prev_content.displayname }} to {{ msg.content.displayname }} + </span> + + <span ng-show='msg.content.msgtype === "m.emote"' + ng-class="msg.echo_msg_state" + ng-bind-html="'* ' + (members[msg.user_id].displayname || msg.user_id) + ' ' + msg.content.body | linky:'_blank'" + /> + + <span ng-show='msg.content.msgtype === "m.text"' + class="message" + ng-class="containsBingWord(msg) && msg.user_id != state.user_id ? msg.echo_msg_state + ' messageBing' : msg.echo_msg_state" + ng-bind-html="(msg.content.msgtype === 'm.text' && msg.type === 'm.room.message' && msg.content.format === 'org.matrix.custom.html') ? + (msg.content.formatted_body | unsanitizedLinky) : + (msg.content.msgtype === 'm.text' && msg.type === 'm.room.message') ? (msg.content.body | linky:'_blank') : '' "/> + + <span ng-show='msg.type === "m.call.invite" && msg.user_id == state.user_id'>Outgoing Call{{ isWebRTCSupported() ? '' : ' (But your browser does not support VoIP)' }}</span> + <span ng-show='msg.type === "m.call.invite" && msg.user_id != state.user_id'>Incoming Call{{ isWebRTCSupported() ? '' : ' (But your browser does not support VoIP)' }}</span> + + <div ng-show='msg.content.msgtype === "m.image"'> + <div ng-hide='msg.content.thumbnail_url' ng-style="msg.content.body.h && { 'height' : (msg.content.body.h < 320) ? msg.content.body.h : 320}"> + <img class="image" ng-src="{{ msg.content.url }}"/> + </div> + <div ng-show='msg.content.thumbnail_url' ng-style="{ 'height' : msg.content.thumbnail_info.h }"> + <img class="image mouse-pointer" ng-src="{{ msg.content.thumbnail_url }}" + ng-click="$parent.fullScreenImageURL = msg.content.url; $event.stopPropagation();"/> + </div> + </div> + + <span ng-if="'m.room.topic' === msg.type"> + {{ members[msg.user_id].displayname || msg.user_id }} changed the topic to: {{ msg.content.topic }} + </span> + + <span ng-if="'m.room.name' === msg.type"> + {{ members[msg.user_id].displayname || msg.user_id }} changed the room name to: {{ msg.content.name }} + </span> + + </div> + </td> + <td class="rightBlock"> + <img class="avatarImage" ng-src="{{ members[msg.user_id].avatar_url || 'img/default-profile.png' }}" width="32" height="32" + ng-hide="room.events[$index - 1].user_id === msg.user_id || msg.user_id !== state.user_id"/> + </td> + </tr> + </table> + </div> + + <div ng-show="state.permission_denied"> + {{ state.permission_denied }} + </div> + + </div> + </div> + + <div id="controlPanel"> + <div id="controls"> + <button id="attachButton" m-file-input="imageFileToSend" class="extraControls" ng-disabled="state.permission_denied"></button> + <textarea id="mainInput" rows="1" ng-enter="send()" + ng-disabled="state.permission_denied" + ng-focus="true" autocomplete="off" tab-complete command-history="room_id"/> + {{ feedback }} + <div ng-show="state.stream_failure"> + {{ state.stream_failure.data.error || "Connection failure" }} + </div> + </div> + </div> + + <div id="room-fullscreen-image" ng-show="fullScreenImageURL" ng-click="fullScreenImageURL = undefined;"> + <img ng-src="{{ fullScreenImageURL }}"/> + </div> + + </div> |