Page MenuHomePhorge

D2233.1775270847.diff
No OneTemporary

Authored By
Unknown
Size
24 KB
Referenced Files
None
Subscribers
None

D2233.1775270847.diff

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 @@
<button class="btn btn-link link-screen text-danger" @click="switchScreen" :disabled="!canShareScreen || !isPublisher()" title="Share screen">
<svg-icon icon="desktop"></svg-icon>
</button>
+ <button class="btn btn-link link-hand text-danger" v-if="!isPublisher()" @click="switchHand" title="Raise hand">
+ <svg-icon icon="hand-paper"></svg-icon>
+ </button>
<button class="btn btn-link link-chat text-danger" @click="switchChat" title="Chat">
<svg-icon icon="align-left"></svg-icon>
</button>
@@ -88,6 +91,9 @@
</div>
<div id="meet-session-layout" class="d-flex hidden">
+ <div id="meet-queue">
+ <div class="head" title="Q &amp; A"><svg-icon icon="microphone-alt"></svg-icon></div>
+ </div>
<div id="meet-session"></div>
<div id="meet-chat">
<div class="chat"></div>
@@ -177,7 +183,9 @@
faCrown,
faDesktop,
faExpand,
+ faHandPaper,
faMicrophone,
+ faMicrophoneAlt,
faPowerOff,
faUser,
faShieldAlt,
@@ -193,7 +201,9 @@
faCrown,
faDesktop,
faExpand,
+ faHandPaper,
faMicrophone,
+ faMicrophoneAlt,
faPowerOff,
faUser,
faShieldAlt,
@@ -459,6 +469,7 @@
this.session.nickname = this.nickname
this.session.menuElement = $('#meet-session-menu')[0]
this.session.chatElement = $('#meet-chat')[0]
+ this.session.queueElement = $('#meet-queue')[0]
this.session.onSuccess = () => {
$('#app').addClass('meet')
$('#meet-setup').addClass('hidden')
@@ -632,6 +643,10 @@
element.requestFullscreen()
}
},
+ switchHand() {
+ let enabled = $('#meet-session-menu').find('.link-hand').is('.text-danger')
+ this.updateSelf({ hand: enabled }, () => { this.setMenuItem('hand', enabled) })
+ },
switchSound() {
const enabled = this.meet.switchAudio()
this.setMenuItem('audio', enabled)
@@ -667,6 +682,14 @@
axios.put('/api/v4/openvidu/rooms/' + this.room + '/connections/' + connId, params)
}
},
+ updateSelf(params, onSuccess) {
+ axios.put('/api/v4/openvidu/rooms/' + this.room + '/connections/' + this.session.connectionId, params)
+ .then(response => {
+ if (onSuccess) {
+ onSuccess(response)
+ }
+ })
+ },
updateSession(data) {
let params = {}
@@ -677,6 +700,10 @@
// merge new params into the object
this.session = Object.assign({}, this.session, params)
+ if ('hand' in data) {
+ this.setMenuItem('hand', data.hand)
+ }
+
// update some buttons state e.g. when switching from publisher to subscriber
if (!this.isPublisher()) {
this.setMenuItem('audio', false)
diff --git a/src/tests/Browser/Meet/RoomQATest.php b/src/tests/Browser/Meet/RoomQATest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Meet/RoomQATest.php
@@ -0,0 +1,139 @@
+<?php
+
+namespace Tests\Browser\Meet;
+
+use App\OpenVidu\Room;
+use Tests\Browser;
+use Tests\Browser\Pages\Meet\Room as RoomPage;
+use Tests\TestCaseDusk;
+
+class RoomQATest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ $this->setupTestRoom();
+ }
+
+ public function tearDown(): void
+ {
+ $this->resetTestRoom();
+ parent::tearDown();
+ }
+
+ /**
+ * Test Q&A queue
+ *
+ * @group openvidu
+ */
+ public function testQA(): void
+ {
+ $this->browse(function (Browser $owner, Browser $guest1, Browser $guest2) {
+ // Join the room as an owner (authenticate)
+ $owner->visit(new RoomPage('john'))
+ ->click('@setup-button')
+ ->submitLogon('john@kolab.org', 'simple123')
+ ->waitFor('@setup-form')
+ ->waitUntilMissing('@setup-status-message.loading')
+ ->select('@setup-mic-select', '')
+ ->select('@setup-cam-select', '')
+ ->type('@setup-nickname-input', 'John')
+ ->clickWhenEnabled('@setup-button')
+ ->waitFor('@session');
+
+ // In another browser act as a guest (1)
+ $guest1->visit(new RoomPage('john'))
+ ->waitFor('@setup-form')
+ ->waitUntilMissing('@setup-status-message.loading')
+ ->assertMissing('@setup-status-message')
+ ->assertSeeIn('@setup-button', "JOIN")
+ // Join the room, disable cam/mic
+ ->select('@setup-mic-select', '')
+ ->select('@setup-cam-select', '')
+ ->type('@setup-nickname-input', 'Guest1')
+ ->clickWhenEnabled('@setup-button')
+ ->waitFor('@session');
+
+ // Assert current UI state
+ $owner->assertToolbarButtonState('hand', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
+ ->waitFor('div.meet-subscriber.self')
+ ->assertMissing('@queue')
+ ->click('@menu button.link-hand')
+ ->waitFor('@queue .dropdown.self.moderated')
+ ->assertSeeIn('@queue .dropdown.self.moderated', 'John')
+ ->assertToolbarButtonState('hand', RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED);
+
+ // Assert current UI state
+ $guest1->waitFor('@queue .dropdown')
+ ->assertSeeIn('@queue .dropdown', 'John')
+ ->assertElementsCount('@queue .dropdown', 1)
+ ->waitFor('div.meet-subscriber.self')
+ ->click('@menu button.link-hand')
+ ->waitFor('@queue .dropdown.self')
+ ->assertSeeIn('@queue .dropdown.self', 'Guest1')
+ ->assertElementsCount('@queue .dropdown', 2)
+ ->click('@menu button.link-hand')
+ ->waitUntilMissing('@queue .dropdown.self')
+ ->assertElementsCount('@queue .dropdown', 1)
+ ->assertToolbarButtonState('hand', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED);
+
+ // In another browser act as a guest (2)
+ $guest2->visit(new RoomPage('john'))
+ ->waitFor('@setup-form')
+ ->waitUntilMissing('@setup-status-message.loading')
+ ->assertMissing('@setup-status-message')
+ ->assertSeeIn('@setup-button', "JOIN")
+ ->type('@setup-nickname-input', 'Guest2')
+ ->clickWhenEnabled('@setup-button')
+ ->waitFor('@queue .dropdown')
+ ->assertSeeIn('@queue .dropdown', 'John')
+ ->assertElementsCount('@queue .dropdown', 1)
+ ->assertMissing('@menu button.link-hand');
+
+ // Demote the guest (2) to subscriber, assert Hand button in toolbar
+ $owner->click('@session div.meet-video .meet-nickname')
+ ->whenAvailable('@session div.meet-video .dropdown-menu', function ($browser) {
+ $browser->click('.action-role-publisher input');
+ });
+
+ // Guest (2) rises his hand
+ $guest2->waitUntilMissing('@session .meet-video')
+ ->waitFor('@menu button.link-hand')
+ ->assertToolbarButtonState('hand', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED)
+ ->click('@menu button.link-hand')
+ ->waitFor('@queue .dropdown.self')
+ ->assertElementsCount('@queue .dropdown', 2);
+
+ // Promote guest (2) to publisher
+ $owner->waitFor('@queue .dropdown:not(.self)')
+ ->pause(8000) // wait until it's not moving, otherwise click() will be possible
+ ->click('@queue .dropdown:not(.self)')
+ ->whenAvailable('@queue .dropdown:not(.self) .dropdown-menu', function ($browser) {
+ $browser->click('.action-role-publisher input');
+ })
+ ->waitUntilMissing('@queue .dropdown:not(.self)')
+ ->waitFor('@session .meet-video');
+
+ $guest1->waitFor('@session .meet-video')
+ ->assertElementsCount('@queue .dropdown', 1);
+
+ $guest2->waitFor('@session .meet-video')
+ ->waitUntilMissing('@queue .dropdown.self')
+ ->assertElementsCount('@queue .dropdown', 1);
+
+ // Finally, do the same with the owner (last in the queue)
+ $owner->click('@queue .dropdown.self')
+ ->whenAvailable('@queue .dropdown.self .dropdown-menu', function ($browser) {
+ $browser->click('.action-role-publisher input');
+ })
+ ->waitUntilMissing('@queue')
+ ->waitFor('@session .meet-video.self');
+
+ $guest1->waitUntilMissing('@queue');
+ $guest2->waitUntilMissing('@queue');
+ });
+ }
+}
diff --git a/src/tests/Browser/Meet/RoomSetupTest.php b/src/tests/Browser/Meet/RoomSetupTest.php
--- a/src/tests/Browser/Meet/RoomSetupTest.php
+++ b/src/tests/Browser/Meet/RoomSetupTest.php
@@ -304,6 +304,7 @@
'audio' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED,
'video' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED,
'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED,
+ 'hand' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'chat' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'fullscreen' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
'security' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
@@ -334,6 +335,7 @@
'audio' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED,
'video' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED,
'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED,
+ 'hand' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'chat' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED,
'fullscreen' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
'logout' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED,
diff --git a/src/tests/Browser/Pages/Meet/Room.php b/src/tests/Browser/Pages/Meet/Room.php
--- a/src/tests/Browser/Pages/Meet/Room.php
+++ b/src/tests/Browser/Pages/Meet/Room.php
@@ -76,6 +76,7 @@
'@session' => '#meet-session',
'@subscribers' => '#meet-subscribers',
+ '@queue' => '#meet-queue',
'@chat' => '#meet-chat',
'@chat-input' => '#meet-chat textarea',
diff --git a/src/tests/Feature/Controller/OpenViduTest.php b/src/tests/Feature/Controller/OpenViduTest.php
--- a/src/tests/Feature/Controller/OpenViduTest.php
+++ b/src/tests/Feature/Controller/OpenViduTest.php
@@ -684,8 +684,13 @@
$this->assertSame('error', $json['status']);
$this->assertSame('The connection does not exist.', $json['message']);
- // Non-owner access
+ // Non-owner access (empty post)
$response = $this->actingAs($jack)->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", []);
+ $response->assertStatus(200);
+
+ // Non-owner access (role update)
+ $post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_MODERATOR];
+ $response = $this->actingAs($jack)->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
$response->assertStatus(403);
// Expected success

File Metadata

Mime Type
text/plain
Expires
Sat, Apr 4, 2:47 AM (4 h, 34 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18827827
Default Alt Text
D2233.1775270847.diff (24 KB)

Event Timeline