Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117817264
D2233.1775287279.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
24 KB
Referenced Files
None
Subscribers
None
D2233.1775287279.diff
View Options
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;
}
@@ -506,6 +526,19 @@
return false;
}
+ /**
+ * 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);
@@ -770,6 +782,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
@@ -794,6 +816,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] })
@@ -811,6 +835,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')
@@ -824,11 +851,10 @@
// should we rather start the stream without asking the user?
// Or maybe we want to display the media setup/preview form?
// Need to find a way to do this.
- } 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] })
@@ -837,7 +863,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
@@ -850,6 +905,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 & 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
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 7:21 AM (22 s ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18828500
Default Alt Text
D2233.1775287279.diff (24 KB)
Attached To
Mode
D2233: Meet: Q&A queue (raise hand feature)
Attached
Detach File
Event Timeline