diff --git a/guacamole-common-js/src/main/webapp/modules/Client.js b/guacamole-common-js/src/main/webapp/modules/Client.js index dc77739e6..bd59d651a 100644 --- a/guacamole-common-js/src/main/webapp/modules/Client.js +++ b/guacamole-common-js/src/main/webapp/modules/Client.js @@ -712,7 +712,10 @@ Guacamole.Client = function(tunnel) { /** * Fired when an arbitrary message is received from the tunnel that should - * be processed by the client. + * be processed by the client. By default, additional message-specific + * events such as "onjoin" and "onleave" will fire for the received message + * after this event has been processed. An event handler for "onmsg" need + * not be supplied if "onjoin" and/or "onleave" will be used. * * @event * @param {!number} msgcode @@ -722,9 +725,49 @@ Guacamole.Client = function(tunnel) { * @param {string[]} args * An array of arguments to be processed with the message sent to the * client. + * + * @return {boolean} + * true if message-specific events such as "onjoin" and + * "onleave" should be fired for this message, false otherwise. If + * no value is returned, message-specific events will be allowed to + * fire. */ this.onmsg = null; + /** + * Fired when a user joins a shared connection. + * + * @event + * @param {!string} userID + * A unique value representing this specific user's connection to the + * shared connection. This value is generated by the server and is + * guaranteed to be unique relative to other users of the connection. + * + * @param {!string} name + * A human-readable name representing the user that joined, such as + * their username. This value is provided by the web application during + * the connection handshake and is not necessarily unique relative to + * other users of the connection. + */ + this.onjoin = null; + + /** + * Fired when a user leaves a shared connection. + * + * @event + * @param {!string} userID + * A unique value representing this specific user's connection to the + * shared connection. This value is generated by the server and is + * guaranteed to be unique relative to other users of the connection. + * + * @param {!string} name + * A human-readable name representing the user that left, such as their + * username. This value is provided by the web application during the + * connection handshake and is not necessarily unique relative to other + * users of the connection. + */ + this.onleave = null; + /** * Fired when a audio stream is created. The stream provided to this event * handler will contain its own event handlers for received data. @@ -1444,8 +1487,40 @@ Guacamole.Client = function(tunnel) { }, "msg" : function(parameters) { - - if (guac_client.onmsg) guac_client.onmsg(parseInt(parameters[0]), parameters.slice(1)); + + var userID; + var username; + + // Fire general message handling event first + var allowDefault = true; + var msgid = parseInt(parameters[0]); + if (guac_client.onmsg) { + allowDefault = guac_client.onmsg(msgid, parameters.slice(1)); + if (allowDefault === undefined) + allowDefault = true; + } + + // Fire message-specific convenience events if not prevented by the + // "onmsg" handler + if (allowDefault) { + switch (msgid) { + + case Guacamole.Client.Message.USER_JOINED: + userID = parameters[1]; + username = parameters[2]; + if (guac_client.onjoin) + guac_client.onjoin(userID, username); + break; + + case Guacamole.Client.Message.USER_LEFT: + userID = parameters[1]; + username = parameters[2]; + if (guac_client.onleave) + guac_client.onleave(userID, username); + break; + + } + } }, diff --git a/guacamole/src/main/frontend/src/app/client/controllers/clientController.js b/guacamole/src/main/frontend/src/app/client/controllers/clientController.js index 3a0df8c11..2821146f8 100644 --- a/guacamole/src/main/frontend/src/app/client/controllers/clientController.js +++ b/guacamole/src/main/frontend/src/app/client/controllers/clientController.js @@ -792,24 +792,6 @@ angular.module('client').controller('clientController', ['$scope', '$routeParams }; - /** - * Determines whether the attached client group has any associated client - * messages to display. - * - * @returns {Boolean} - * true if there are messages to display; otherwise false. - */ - $scope.hasMessages = function hasMessages() { - - // No client group means no messages - if (!$scope.clientGroup) - return false; - - // Otherwise, find messages within the clients in the group. - return _.findIndex($scope.clientGroup.clients, ManagedClient.hasMessages) !== -1; - - }; - /** * Determines whether the attached client group has any associated file * transfers, regardless of those file transfers' state. diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacClientMessage.js b/guacamole/src/main/frontend/src/app/client/directives/guacClientMessage.js deleted file mode 100644 index 5348179d0..000000000 --- a/guacamole/src/main/frontend/src/app/client/directives/guacClientMessage.js +++ /dev/null @@ -1,87 +0,0 @@ -/* - * 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. - */ - -/** - * Directive which displays a message for the client. - */ -angular.module('client').directive('guacClientMessage', [function guacClientMessage() { - - return { - restrict: 'E', - replace: true, - scope: { - - /** - * The message to display to the client. - * - * @type {!ManagedClientMessage} - */ - message : '=' - - }, - - templateUrl: 'app/client/templates/guacClientMessage.html', - - controller: ['$scope', '$injector', '$element', - function guacClientMessageController($scope, $injector, $element) { - - // Required types - const ManagedClientMessage = $injector.get('ManagedClientMessage'); - - // Required services - var translationStringService = $injector.get('translationStringService'); - - /** - * Uses the msgcode to retrieve the correct translation key for - * the client message. - * - * @returns {string} - */ - $scope.getMessageKey = function getMessageKey() { - - let msgString = "DEFAULT"; - if (Object.values(Guacamole.Client.Message).includes($scope.message.msgcode)) - msgString = Object.keys(Guacamole.Client.Message).find(key => Guacamole.Client.Message[key] === $scope.message.msgcode); - - return "CLIENT.MESSAGE_" + translationStringService.canonicalize(msgString); - }; - - /** - * Returns a set of key/value object pairs that represent the - * arguments provided as part of the message in the form - * "ARGS_0 = value". Guacamole's translation system relies on - * the arguments being available in this format in order to be able - * to handle substituting values for an arbitrary list of arguments. - * - * @returns {Object} - */ - $scope.getMessageArgs = function getMessageArgs() { - return $scope.message.args.reduce( - function(acc, value, index) { - acc[`ARGS_${index}`] = value; - return acc; - }, - {} - ); - }; - - }] - - }; -}]); 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/directives/guacMessageDialog.js b/guacamole/src/main/frontend/src/app/client/directives/guacMessageDialog.js deleted file mode 100644 index 16ca81d4a..000000000 --- a/guacamole/src/main/frontend/src/app/client/directives/guacMessageDialog.js +++ /dev/null @@ -1,76 +0,0 @@ -/* - * 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. - */ - -/** - * Directive which displays all client messages. - */ -angular.module('client').directive('guacMessageDialog', [function guacMessageDialog() { - - return { - restrict: 'E', - replace: true, - scope: { - - /** - * The client group whose messages should be managed by this - * directive. - * - * @type ManagedClientGroup - */ - clientGroup : '=' - - }, - - templateUrl: 'app/client/templates/guacMessageDialog.html', - controller: ['$scope', '$injector', function guacMessageDialogController($scope, $injector) { - - // Required types - const ManagedClient = $injector.get('ManagedClient'); - const ManagedClientGroup = $injector.get('ManagedClientGroup'); - - /** - * Removes all messages. - */ - $scope.clearAllMessages = function clearAllMessages() { - - // Nothing to clear if no client group attached - if (!$scope.clientGroup) - return; - - // Remove each client's messages - $scope.clientGroup.clients.forEach(client => { - client.messages = []; - }); - - }; - - /** - * @borrows ManagedClientGroup.hasMultipleClients - */ - $scope.hasMultipleClients = ManagedClientGroup.hasMultipleClients; - - /** - * @borrows ManagedClient.hasMessages - */ - $scope.hasMessages = ManagedClient.hasMessages; - - }] - - }; -}]); diff --git a/guacamole/src/main/frontend/src/app/client/styles/client-message.css b/guacamole/src/main/frontend/src/app/client/styles/client-message.css deleted file mode 100644 index 21b905672..000000000 --- a/guacamole/src/main/frontend/src/app/client/styles/client-message.css +++ /dev/null @@ -1,23 +0,0 @@ -/* - * 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. - */ - -p.client-message-text { - margin: 5px; -} - diff --git a/guacamole/src/main/frontend/src/app/client/styles/message-dialog.css b/guacamole/src/main/frontend/src/app/client/styles/message-dialog.css deleted file mode 100644 index 2a90c672d..000000000 --- a/guacamole/src/main/frontend/src/app/client/styles/message-dialog.css +++ /dev/null @@ -1,122 +0,0 @@ -/* - * 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. - */ - -#message-dialog { - - position: absolute; - right: 0; - bottom: 0; - z-index: 20; - - font-size: 0.8em; - - width: 3in; - max-width: 100%; - max-height: 3in; - - background: white; - opacity: 0.75; - -} - -#message-dialog .message-dialog-box { - - /* IE10 */ - display: -ms-flexbox; - -ms-flex-align: stretch; - -ms-flex-direction: column; - - /* Ancient Mozilla */ - display: -moz-box; - -moz-box-align: stretch; - -moz-box-orient: vertical; - - /* Ancient WebKit */ - display: -webkit-box; - -webkit-box-align: stretch; - -webkit-box-orient: vertical; - - /* Old WebKit */ - display: -webkit-flex; - -webkit-align-items: stretch; - -webkit-flex-direction: column; - - /* W3C */ - display: flex; - align-items: stretch; - flex-direction: column; - - max-width: inherit; - max-height: inherit; - - border: 1px solid rgba(0, 0, 0, 0.5); - box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.25); - -} - -#message-dialog .message-dialog-box .header { - -ms-flex: 0 0 auto; - -moz-box-flex: 0; - -webkit-box-flex: 0; - -webkit-flex: 0 0 auto; - flex: 0 0 auto; - margin-bottom: 5px; -} - -#message-dialog .message-dialog-box .client-message-body { - - -ms-flex: 1 1 auto; - -moz-box-flex: 1; - -webkit-box-flex: 1; - -webkit-flex: 1 1 auto; - flex: 1 1 auto; - - overflow: auto; - -} - -/* - * Shrink maximum height if viewport is too small for default 3in dialog. - */ -@media all and (max-height: 3in) { - - #message-dialog { - max-height: 1.5in; - } - -} - -/* - * If viewport is too small for even the 1.5in dialog, fit all available space. - */ -@media all and (max-height: 1.5in) { - - #message-dialog { - height: 100%; - } - - #message-dialog .message-dialog-box { - position: absolute; - left: 0.5em; - top: 0.5em; - right: 0.5em; - bottom: 0.5em; - } - -} 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 209ab8e59..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 @@ -110,10 +110,15 @@ } .tiled-client-grid .client-tile .client-tile-header .client-tile-name { + -webkit-box-flex: 1; -webkit-flex: 1; -ms-flex: 1; flex: 1; + + padding: 0 0.5em; + margin-bottom: -0.125em; + } .tiled-client-grid .client-tile .main { @@ -136,3 +141,132 @@ .tiled-client-grid .shared .client-tile-shared-indicator { display: inline; } + +.tiled-client-grid .client-user-count { + + visibility: hidden; + + display: block; + position: absolute; + right: 0; + top: 0; + z-index: 1; + + border-radius: 0.25em; + padding: 0.125em 0.75em; + margin: 0.5em; + + background: #055; + color: white; + font-weight: bold; + font-size: 0.8em; + +} + +.tiled-client-grid .client-user-count::before { + + content: ' '; + display: inline-block; + + margin-bottom: -0.2em; + padding-right: 0.25em; + width: 1em; + height: 1em; + + background: center / contain no-repeat url('images/user-icons/guac-user-white.svg'); + background-size: contain; + background-position: center; + background-repeat: no-repeat; + +} + +.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; + white-space: nowrap; + background: black; + padding-left: 0.5em; + padding-right: 0.75em; +} + +.tiled-client-grid .client-tile-header .client-user-count::before { + padding-right: 0.75em; +} + +.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/client.html b/guacamole/src/main/frontend/src/app/client/templates/client.html index 7cf976c0a..829d5ef7d 100644 --- a/guacamole/src/main/frontend/src/app/client/templates/client.html +++ b/guacamole/src/main/frontend/src/app/client/templates/client.html @@ -45,11 +45,6 @@ {{'CLIENT.TEXT_CLIENT_STATUS_UNSTABLE' | translate}} - -
- -
-