diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacClientUserCount.js b/guacamole/src/main/frontend/src/app/client/directives/guacClientUserCount.js new file mode 100644 index 000000000..313d59ced --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/directives/guacClientUserCount.js @@ -0,0 +1,196 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +/** + * A directive that displays a status indicator showing the number of users + * joined to a connection. The specific usernames of those users are visible in + * a tooltip on mouseover, and small notifications are displayed as users + * join/leave the connection. + */ +angular.module('client').directive('guacClientUserCount', [function guacClientUserCount() { + + const directive = { + restrict: 'E', + replace: true, + templateUrl: 'app/client/templates/guacClientUserCount.html' + }; + + directive.scope = { + + /** + * The client whose current users should be displayed. + * + * @type ManagedClient + */ + client : '=' + + }; + + directive.controller = ['$scope', '$injector', '$element', + function guacClientUserCountController($scope, $injector, $element) { + + // Required services + var $translate = $injector.get('$translate'); + + /** + * The maximum number of messages displayed by this directive at any + * given time. Old messages will be discarded as necessary to ensure + * the number of messages displayed never exceeds this value. + * + * @constant + * @type number + */ + var MAX_MESSAGES = 3; + + /** + * The list that should contain any notifications regarding users + * joining or leaving the connection. + * + * @type HTMLUListElement + */ + var messages = $element.find('.client-user-count-messages')[0]; + + /** + * Map of the usernames of all users of the current connection to the + * number of concurrent connections those users have to the current + * connection. + * + * @type Object. + */ + $scope.userCounts = {}; + + /** + * Displays a message noting that a change related to a particular user + * of this connection has occurred. + * + * @param {!string} str + * The key of the translation string containing the message to + * display. This translation key must accept "USERNAME" as the + * name of the translation parameter containing the username of + * the user in question. + * + * @param {!string} username + * The username of the user in question. + */ + var notify = function notify(str, username) { + $translate(str, { 'USERNAME' : username }).then(function translationReady(text) { + + if (messages.childNodes.length === 3) + messages.removeChild(messages.lastChild); + + var message = document.createElement('li'); + message.className = 'client-user-count-message'; + message.textContent = text; + messages.insertBefore(message, messages.firstChild); + + // Automatically remove the notification after its "fadeout" + // animation ends. NOTE: This will not fire if the element is + // not visible at all. + message.addEventListener('animationend', function animationEnded() { + messages.removeChild(message); + }); + + }); + }; + + /** + * Displays a message noting that a particular user has joined the + * current connection. + * + * @param {!string} username + * The username of the user that joined. + */ + var notifyUserJoined = function notifyUserJoined(username) { + notify('CLIENT.TEXT_USER_JOINED', username); + }; + + /** + * Displays a message noting that a particular user has left the + * current connection. + * + * @param {!string} username + * The username of the user that left. + */ + var notifyUserLeft = function notifyUserLeft(username) { + notify('CLIENT.TEXT_USER_LEFT', username); + }; + + /** + * The ManagedClient attached to this directive at the time the + * notification update scope watch was last invoked. This is necessary + * as $scope.$watchGroup() does not allow for the callback to know + * whether the scope was previously uninitialized (it's "oldValues" + * parameter receives a copy of the new values if there are no old + * values). + * + * @type ManagedClient + */ + var oldClient = null; + + // Update visible notifications as users join/leave + $scope.$watchGroup([ 'client', 'client.userCount' ], function usersChanged() { + + // Resynchronize directive with state of any attached client when + // the client changes, to ensure notifications are only shown for + // future changes in users present + if (oldClient !== $scope.client) { + + $scope.userCounts = {}; + oldClient = $scope.client; + + angular.forEach($scope.client.users, function initUsers(connections, username) { + var count = Object.keys(connections).length; + $scope.userCounts[username] = count; + }); + + return; + + } + + // Display join/leave notifications for users who are currently + // connected but whose connection counts have changed + angular.forEach($scope.client.users, function addNewUsers(connections, username) { + + var count = Object.keys(connections).length; + var known = $scope.userCounts[username] || 0; + + if (count > known) + notifyUserJoined(username); + else if (count < known) + notifyUserLeft(username); + + $scope.userCounts[username] = count; + + }); + + // Display leave notifications for users who are no longer connected + angular.forEach($scope.userCounts, function removeOldUsers(count, username) { + if (!$scope.client.users[username]) { + notifyUserLeft(username); + delete $scope.userCounts[username]; + } + }); + + }); + + }]; + + return directive; + +}]); diff --git a/guacamole/src/main/frontend/src/app/client/styles/tiled-client-grid.css b/guacamole/src/main/frontend/src/app/client/styles/tiled-client-grid.css index 6d768e132..86c90470d 100644 --- a/guacamole/src/main/frontend/src/app/client/styles/tiled-client-grid.css +++ b/guacamole/src/main/frontend/src/app/client/styles/tiled-client-grid.css @@ -180,6 +180,32 @@ } +.tiled-client-grid .client-user-count .client-user-count-users, +.tiled-client-grid .client-user-count .client-user-count-messages { + + position: absolute; + right: 0; + + margin: 0; + padding: 0; + margin-top: 0.5em; + list-style: none; + +} + +.tiled-client-grid .client-user-count .client-user-count-users, +.tiled-client-grid .client-user-count .client-user-count-message { + border-radius: 0.25em; + background: black; + color: white; + padding: 0.5em; +} + +.tiled-client-grid .client-user-count .client-user-count-message { + white-space: nowrap; + animation: 1s linear 3s fadeout; +} + .tiled-client-grid .client-tile-header .client-user-count { display: inline-block; position: relative; @@ -196,3 +222,51 @@ .tiled-client-grid .joined .client-user-count { visibility: visible; } + +.tiled-client-grid .client-user-count .client-user-count-users { + display: none; +} + +.tiled-client-grid .client-user-count:hover .client-user-count-users { + display: block; +} + +.tiled-client-grid .client-user-count .client-user-count-user::after { + content: ', '; + margin-right: 0.25em; +} + +.tiled-client-grid .client-user-count .client-user-count-user:last-child::after { + content: none; +} + +.tiled-client-grid .client-user-count .client-user-count-user { + display: inline-block; +} + +.tiled-client-grid .client-user-count .client-user-count-users { + width: 256px; + max-width: 75vw; + white-space: normal; + border: 1px solid #333; +} + +.tiled-client-grid .client-user-count .client-user-count-users::before { + + content: ' '; + display: block; + + position: absolute; + right: 0.5em; + top: -0.5em; + + width: 1em; + height: 1em; + + background: black; + border: 1px solid #333; + border-right: none; + border-bottom: none; + transform: rotate(45deg); + +} diff --git a/guacamole/src/main/frontend/src/app/client/templates/guacClientUserCount.html b/guacamole/src/main/frontend/src/app/client/templates/guacClientUserCount.html new file mode 100644 index 000000000..c03abbf01 --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/templates/guacClientUserCount.html @@ -0,0 +1,9 @@ +
+ {{ client.userCount }} + + +
diff --git a/guacamole/src/main/frontend/src/app/client/templates/guacTiledClients.html b/guacamole/src/main/frontend/src/app/client/templates/guacTiledClients.html index e9b12cf24..ad24ca292 100644 --- a/guacamole/src/main/frontend/src/app/client/templates/guacTiledClients.html +++ b/guacamole/src/main/frontend/src/app/client/templates/guacTiledClients.html @@ -12,7 +12,7 @@

{{ client.title }} - {{ client.userCount }} + -
- {{ client.userCount }} -
+ diff --git a/guacamole/src/main/frontend/src/translations/en.json b/guacamole/src/main/frontend/src/translations/en.json index 208b4588b..77b59d19d 100644 --- a/guacamole/src/main/frontend/src/translations/en.json +++ b/guacamole/src/main/frontend/src/translations/en.json @@ -128,10 +128,7 @@ "INFO_CONNECTION_SHARED" : "This connection is now shared.", "INFO_NO_FILE_TRANSFERS" : "No file transfers.", - - "MESSAGE_DEFAULT" : "", - "MESSAGE_USER_JOINED" : "User {ARGS_1} has joined the connection.", - "MESSAGE_USER_LEFT" : "User {ARGS_1} has left the connection.", + "INFO_USER_COUNT" : "{USERNAME}{COUNT, plural, one{} other{ (#)}}", "NAME_INPUT_METHOD_NONE" : "None", "NAME_INPUT_METHOD_OSK" : "On-screen keyboard", @@ -158,6 +155,8 @@ "TEXT_CLIENT_STATUS_DISCONNECTED" : "You have been disconnected.", "TEXT_CLIENT_STATUS_UNSTABLE" : "The network connection to the Guacamole server appears unstable.", "TEXT_CLIENT_STATUS_WAITING" : "Connected to Guacamole. Waiting for response...", + "TEXT_USER_JOINED" : "{USERNAME} has joined the connection.", + "TEXT_USER_LEFT" : "{USERNAME} has left the connection.", "TEXT_RECONNECT_COUNTDOWN" : "Reconnecting in {REMAINING} {REMAINING, plural, one{second} other{seconds}}...", "TEXT_FILE_TRANSFER_PROGRESS" : "{PROGRESS} {UNIT, select, b{B} kb{KB} mb{MB} gb{GB} other{}}",