diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php --- a/src/app/Http/Controllers/API/V4/OpenViduController.php +++ b/src/app/Http/Controllers/API/V4/OpenViduController.php @@ -412,14 +412,29 @@ return $this->errorResponse(404, \trans('meet.connection-not-found')); } - // Only the moderator can do it - if (!$this->isModerator($connection->room)) { - return $this->errorResponse(403); - } - foreach (request()->input() as $key => $value) { switch ($key) { + case 'hand': + // Only possible on user's own connection(s) + if (!$this->isSelfConnection($connection)) { + return $this->errorResponse(403); + } + + if ($value) { + // Store current time, so we know the order in the queue + $connection->metadata = ['hand' => time()] + $connection->metadata; + } else { + $connection->metadata = array_diff_key($connection->metadata, ['hand' => 0]); + } + + break; + case 'role': + // Only the moderator can do it + if (!$this->isModerator($connection->room)) { + return $this->errorResponse(403); + } + // The 'owner' role is not assignable if ($value & Room::ROLE_OWNER && !($connection->role & Room::ROLE_OWNER)) { return $this->errorResponse(403); @@ -432,6 +447,11 @@ $value |= Room::ROLE_MODERATOR; } + // Promotion to publisher? Put the user hand down + if ($value & Room::ROLE_PUBLISHER && !($connection->role & Room::ROLE_PUBLISHER)) { + $connection->metadata = array_diff_key($connection->metadata, ['hand' => 0]); + } + $connection->{$key} = $value; break; } @@ -507,6 +527,19 @@ } /** + * Check if current user "owns" the specified connection. + * + * @param \App\OpenVidu\Connection $connection The connection + * + * @return bool + */ + protected function isSelfConnection(Connection $connection): bool + { + return ($conn = $this->getConnectionFromRequest()) + && $conn->id === $connection->id; + } + + /** * Get the connection object for the token in current request headers. * It will also validate the token. * diff --git a/src/app/Observers/OpenVidu/ConnectionObserver.php b/src/app/Observers/OpenVidu/ConnectionObserver.php --- a/src/app/Observers/OpenVidu/ConnectionObserver.php +++ b/src/app/Observers/OpenVidu/ConnectionObserver.php @@ -15,18 +15,50 @@ */ public function updated(Connection $connection) { - if ($connection->role != $connection->getOriginal('role')) { - $params = [ - 'connectionId' => $connection->id, - 'role' => $connection->role - ]; + $params = []; - // Send the signal to all participants - $connection->room->signal('connectionUpdate', $params); + // Role change + if ($connection->role != $connection->getOriginal('role')) { + $params['role'] = $connection->role; // TODO: When demoting publisher to subscriber maybe we should // destroy all streams using REST API. For now we trust the // participant browser to do this. } + + // Rised hand state change + $newState = $connection->metadata['hand'] ?? null; + $oldState = $this->getOriginal($connection, 'metadata')['hand'] ?? null; + + if ($newState !== $oldState) { + $params['hand'] = !empty($newState); + } + + // Send the signal to all participants + if (!empty($params)) { + $params['connectionId'] = $connection->id; + $connection->room->signal('connectionUpdate', $params); + } + } + + /** + * A wrapper to getOriginal() on an object + * + * @param \App\OpenVidu\Connection $connection The connection. + * @param string $property The property name + * + * @return mixed + */ + private function getOriginal($connection, $property) + { + $original = $connection->getOriginal($property); + + // The original value for a property is in a format stored in database + // I.e. for 'metadata' it is a JSON string instead of an array + if ($property == 'metadata') { + $original = json_decode($original, true); + } + + return $original; } } diff --git a/src/app/OpenVidu/Room.php b/src/app/OpenVidu/Room.php --- a/src/app/OpenVidu/Room.php +++ b/src/app/OpenVidu/Room.php @@ -190,9 +190,15 @@ ->get() ->keyBy('id') ->map(function ($item) { - // For now we need only 'role' property, it might change in the future. - // Make sure to not return all metadata here as it might contain sensitive data. - return ['role' => $item->role]; + // Warning: Make sure to not return all metadata here as it might contain sensitive data. + return [ + 'role' => $item->role, + 'hand' => $item->metadata['hand'] ?? 0, + ]; + }) + // Sort by order in the queue, so UI can re-build the existing queue in order + ->sort(function ($a, $b) { + return $a['hand'] <=> $b['hand']; }) ->all(); } diff --git a/src/resources/js/meet/app.js b/src/resources/js/meet/app.js --- a/src/resources/js/meet/app.js +++ b/src/resources/js/meet/app.js @@ -93,6 +93,7 @@ * connections - Optional metadata for other users connections (current state), * chatElement - DOM element for the chat widget, * menuElement - DOM element of the room toolbar, + * queueElement - DOM element for the Q&A queue (users with a raised hand) * onSuccess - Callback for session connection (join) success * onError - Callback for session connection (join) error * onDestroy - Callback for session disconnection event, @@ -141,7 +142,6 @@ // we got from our database. if (sessionData.connections && connId in sessionData.connections) { Object.assign(metadata, sessionData.connections[connId]) - delete sessionData.connections[connId] } metadata.element = participantCreate(metadata) @@ -158,6 +158,8 @@ let conn = connections[connectionId] if (conn) { + // Remove elements related to the participant + connectionHandDown(connectionId) $(conn.element).remove() delete connections[connectionId] } @@ -252,6 +254,16 @@ } sessionData.element = wrapper + + // Create Q&A queue from the existing connections with rised hand. + // Here we expect connections in a proper queue order + Object.keys(data.connections || {}).forEach(key => { + let conn = data.connections[key] + if (conn.hand) { + conn.connectionId = key + connectionHandUp(conn) + } + }) }) .catch(error => { console.error('There was an error connecting to the session: ', error.message); @@ -774,6 +786,16 @@ function connectionUpdate(data) { let conn = connections[data.connectionId] + let handUpdate = conn => { + if ('hand' in data && data.hand != conn.hand) { + if (data.hand) { + connectionHandUp(conn) + } else { + connectionHandDown(data.connectionId) + } + } + } + // It's me if (session.connection.connectionId == data.connectionId) { const rolePublisher = data.role && data.role & Roles.PUBLISHER @@ -798,6 +820,8 @@ publisher.stream.streamManager.videos = videos.filter(video => video.video.parentNode != null) } + handUpdate(sessionData) + // merge the changed data into internal session metadata object Object.keys(data).forEach(key => { sessionData[key] = data[key] }) @@ -815,6 +839,9 @@ } } + // Inform the vue component, so it can update some UI controls + update() + // promoted to a publisher if ('role' in data && !isPublisher && rolePublisher) { publisher.createVideoElement(sessionData.element, 'PREPEND') @@ -837,11 +864,10 @@ if (sessionData.onMediaSetup) { sessionData.onMediaSetup() } - } else { - // Inform the vue component, so it can update some UI controls - update() } } else if (conn) { + handUpdate(conn) + // merge the changed data into internal session metadata object Object.keys(data).forEach(key => { conn[key] = data[key] }) @@ -850,7 +876,36 @@ } /** - * Update nickname in chat + * Handler for Hand-Up "signal" + */ + function connectionHandUp(connection) { + connection.isSelf = session.connection.connectionId == connection.connectionId + + let element = $(nicknameWidget(connection)) + + participantUpdate(element, connection) + + element.attr('id', 'qa' + connection.connectionId) + .appendTo($(sessionData.queueElement).show()) + + setTimeout(() => element.addClass('widdle'), 50) + } + + /** + * Handler for Hand-Down "signal" + */ + function connectionHandDown(connectionId) { + let list = $(sessionData.queueElement) + + list.find('#qa' + connectionId).remove(); + + if (!list.find('.meet-nickname').length) { + list.hide(); + } + } + + /** + * Update participant nickname in the UI * * @param nickname Nickname * @param connectionId Connection identifier of the user @@ -863,6 +918,8 @@ elem.find('.nickname').text(nickname || '') } }) + + $(sessionData.queueElement).find('#qa' + connectionId + ' .content').text(nickname || '') } } diff --git a/src/resources/themes/meet.scss b/src/resources/themes/meet.scss --- a/src/resources/themes/meet.scss +++ b/src/resources/themes/meet.scss @@ -279,6 +279,45 @@ // TODO: mobile mode } +#meet-queue { + display: none; + width: 150px; + + .head { + text-align: center; + font-size: 1.75em; + background: $menu-bg-color; + } + + .dropdown { + margin: 0.2em; + display: flex; + position: relative; + transition: top 10s ease; + top: 15em; + + .meet-nickname { + width: 100%; + } + + &.widdle { + top: 0; + animation-name: wiggle; + animation-duration: 1s; + animation-timing-function: ease-in-out; + animation-iteration-count: 8; + } + } +} + +@keyframes wiggle { + 0% { transform: rotate(0deg); } + 25% { transform: rotate(10deg); } + 50% { transform: rotate(0deg); } + 75% { transform: rotate(-10deg); } + 100% { transform: rotate(0deg); } +} + .media-setup-form { .input-group svg { width: 1em; diff --git a/src/resources/vue/Meet/Room.vue b/src/resources/vue/Meet/Room.vue --- a/src/resources/vue/Meet/Room.vue +++ b/src/resources/vue/Meet/Room.vue @@ -11,6 +11,9 @@ + @@ -88,6 +91,9 @@