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 @@ -12,7 +12,7 @@ class OpenViduController extends Controller { /** - * Accepting the room join request. + * Accept the room join request. * * @param string $id Room identifier (name) * @param string $reqid Request identifier @@ -28,10 +28,8 @@ return $this->errorResponse(404, \trans('meet.room-not-found')); } - $user = Auth::guard()->user(); - - // Only the room owner can do it - if (!$user || $user->id != $room->user_id) { + // Only the moderator can do it + if (!$this->isModerator($room)) { return $this->errorResponse(403); } @@ -43,7 +41,7 @@ } /** - * Denying the room join request. + * Deny the room join request. * * @param string $id Room identifier (name) * @param string $reqid Request identifier @@ -59,10 +57,8 @@ return $this->errorResponse(404, \trans('meet.room-not-found')); } - $user = Auth::guard()->user(); - - // Only the room owner can do it - if (!$user || $user->id != $room->user_id) { + // Only the moderator can do it + if (!$this->isModerator($room)) { return $this->errorResponse(403); } @@ -107,7 +103,7 @@ } /** - * Accepting the room join request. + * Dismiss the participant/connection from the session. * * @param string $id Room identifier (name) * @param string $conn Connection identifier @@ -123,10 +119,8 @@ return $this->errorResponse(404, \trans('meet.connection-not-found')); } - $user = Auth::guard()->user(); - - // Only the room owner can do it (for now) - if (!$user || $user->id != $connection->room->user_id) { + // Only the moderator can do it + if (!$this->isModerator($connection->room)) { return $this->errorResponse(403); } @@ -372,6 +366,42 @@ ]); } + /** + * Update the participant/connection parameters (e.g. role). + * + * @param string $id Room identifier (name) + * @param string $conn Connection identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function updateConnection($id, $conn) + { + $connection = Connection::where('id', $conn)->first(); + + // There's no such connection, bye bye + if (!$connection || $connection->room->name != $id) { + 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 'role': + $connection->{$key} = $value; + break; + } + } + + // The connection observer will send a signal to everyone when needed + $connection->save(); + + return response()->json(['status' => 'success']); + } + /** * Webhook as triggered from OpenVidu server * @@ -406,4 +436,25 @@ return response('Success', 200); } + + /** + * Check if current user is a moderator for the specified room. + * + * @param \App\OpenVidu\Room $room The room + * + * @return bool True if the current user is the room moderator + */ + protected function isModerator(Room $room): bool + { + $user = Auth::guard()->user(); + + // The room owner is a moderator + if ($user && $user->id == $room->user_id) { + return true; + } + + // TODO: Moderators authentication + + return false; + } } diff --git a/src/app/Observers/OpenVidu/ConnectionObserver.php b/src/app/Observers/OpenVidu/ConnectionObserver.php new file mode 100644 --- /dev/null +++ b/src/app/Observers/OpenVidu/ConnectionObserver.php @@ -0,0 +1,32 @@ +role != $connection->getOriginal('role')) { + $params = [ + 'connectionId' => $connection->id, + 'role' => $connection->role + ]; + + // Send the signal to all participants + $connection->room->signal('connectionUpdate', $params); + + // 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. + } + } +} diff --git a/src/app/OpenVidu/Connection.php b/src/app/OpenVidu/Connection.php --- a/src/app/OpenVidu/Connection.php +++ b/src/app/OpenVidu/Connection.php @@ -54,4 +54,45 @@ { return $this->belongsTo(Room::class, 'room_id', 'id'); } + + /** + * Connection role mutator + * + * @throws \Exception + */ + public function setRoleAttribute($role) + { + $new_role = 0; + + $allowed_values = [ + Room::ROLE_SUBSCRIBER, + Room::ROLE_PUBLISHER, + Room::ROLE_MODERATOR, + Room::ROLE_SCREEN, + Room::ROLE_OWNER, + ]; + + foreach ($allowed_values as $value) { + if ($role & $value) { + $new_role |= $value; + $role ^= $value; + } + } + + if ($role > 0) { + throw new \Exception("Invalid connection role: {$role}"); + } + + // It is either screen sharing connection or publisher/subscriber connection + if ($new_role & Room::ROLE_SCREEN) { + if ($new_role & Room::ROLE_PUBLISHER) { + $new_role ^= Room::ROLE_PUBLISHER; + } + if ($new_role & Room::ROLE_SUBSCRIBER) { + $new_role ^= Room::ROLE_SUBSCRIBER; + } + } + + $this->attributes['role'] = $new_role; + } } diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -30,6 +30,7 @@ \App\Domain::observe(\App\Observers\DomainObserver::class); \App\Entitlement::observe(\App\Observers\EntitlementObserver::class); \App\Group::observe(\App\Observers\GroupObserver::class); + \App\OpenVidu\Connection::observe(\App\Observers\OpenVidu\ConnectionObserver::class); \App\Package::observe(\App\Observers\PackageObserver::class); \App\PackageSku::observe(\App\Observers\PackageSkuObserver::class); \App\Plan::observe(\App\Observers\PlanObserver::class); 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 @@ -17,7 +17,6 @@ let publisher // Publisher object which the user will publish let audioActive = false // True if the audio track of the publisher is active let videoActive = false // True if the video track of the publisher is active - let numOfVideos = 0 // Keeps track of the number of videos that are being shown let audioSource = '' // Currently selected microphone let videoSource = '' // Currently selected camera let sessionData // Room session metadata @@ -76,8 +75,9 @@ /** * Join the room session * - * @param data Session metadata and event handlers (session, token, shareToken, nickname, role, - * chatElement, menuElement, onDestroy, onJoinRequest) + * @param data Session metadata and event handlers (token, shareToken, nickname, role, + * chatElement, menuElement, onDestroy, onJoinRequest, onDismiss, onConnectionChange, + * onSessionDataUpdate) */ function joinRoom(data) { resize(); @@ -108,18 +108,12 @@ // This is the first event executed when a user joins in. // We'll create the video wrapper here, which can be re-used // in 'streamCreated' event handler. - // Note: For a user with a subscriber role 'streamCreated' event - // is not being dispatched at all let metadata = connectionData(event.connection) - let connectionId = event.connection.connectionId - metadata.connId = connectionId - let element = participantCreate(metadata) + metadata.element = participantCreate(metadata) - connections[connectionId] = { element } - - resize() + connections[metadata.connectionId] = metadata // Send the current user status to the connecting user // otherwise e.g. nickname might be not up to date @@ -129,9 +123,6 @@ session.on('connectionDestroyed', event => { let conn = connections[event.connection.connectionId] if (conn) { - if ($(conn.element).is('.meet-video')) { - numOfVideos-- - } $(conn.element).remove() delete connections[event.connection.connectionId] } @@ -140,17 +131,15 @@ // On every new Stream received... session.on('streamCreated', event => { - let connection = event.stream.connection - let connectionId = connection.connectionId - let metadata = connectionData(connection) - let wrapper = connections[connectionId].element + let connectionId = event.stream.connection.connectionId + let metadata = connections[connectionId] let props = { // Prepend the video element so it is always before the watermark element insertMode: 'PREPEND' } // Subscribe to the Stream to receive it - let subscriber = session.subscribe(event.stream, wrapper, props); + let subscriber = session.subscribe(event.stream, metadata.element, props); subscriber.on('videoElementCreated', event => { $(event.element).prop({ @@ -159,17 +148,29 @@ resize() }) -/* - subscriber.on('videoElementDestroyed', event => { - }) -*/ + + metadata.audioActive = event.stream.audioActive + metadata.videoActive = event.stream.videoActive + // Update the wrapper controls/status - participantUpdate(wrapper, event.stream) + participantUpdate(metadata.element, metadata) }) -/* - session.on('streamDestroyed', event => { + + // Stream properties changes e.g. audio/video muted/unmuted + session.on('streamPropertyChanged', event => { + let connectionId = event.stream.connection.connectionId + let metadata = connections[connectionId] + + if (session.connection.connectionId == connectionId) { + metadata = sessionData + } + + if (metadata) { + metadata[event.changedProperty] = event.newValue + participantUpdate(metadata.element, metadata) + } }) -*/ + // Handle session disconnection events session.on('sessionDisconnected', event => { if (data.onDestroy) { @@ -185,8 +186,13 @@ // Connect with the token session.connect(data.token, data.params) .then(() => { - let wrapper - let params = { self: true, role: data.role, audioActive, videoActive } + let params = { + connectionId: session.connection.connectionId, + role: data.role, + audioActive, + videoActive + } + params = Object.assign({}, data.params, params) publisher.on('videoElementCreated', event => { @@ -198,15 +204,14 @@ resize() }) - wrapper = participantCreate(params) + let wrapper = participantCreate(params) if (data.role & Roles.PUBLISHER) { publisher.createVideoElement(wrapper, 'PREPEND') session.publish(publisher) } - resize() - sessionData.wrapper = wrapper + sessionData.element = wrapper }) .catch(error => { console.error('There was an error connecting to the session: ', error.message); @@ -433,10 +438,12 @@ switch (signal.type) { case 'signal:userChanged': + // TODO: Use 'signal:connectionUpdate' for nickname updates? if (conn = connections[connId]) { data = JSON.parse(signal.data) - participantUpdate(conn.element, data) + conn.nickname = data.nickname + participantUpdate(conn.element, conn) nicknameUpdate(data.nickname, connId) } break @@ -448,10 +455,20 @@ break case 'signal:joinRequest': - if (sessionData.onJoinRequest) { + // accept requests from the server only + if (!connId && sessionData.onJoinRequest) { sessionData.onJoinRequest(JSON.parse(signal.data)) } - break; + break + + case 'signal:connectionUpdate': + // accept requests from the server only + if (!connId) { + data = JSON.parse(signal.data) + + connectionUpdate(data) + } + break } } @@ -542,14 +559,9 @@ */ function signalUserUpdate(connection) { let data = { - audioActive, - videoActive, nickname: sessionData.params.nickname } - // Note: StreamPropertyChangedEvent might be more standard way - // to propagate the audio/video state change to other users. - // It looks there's no other way to propagate nickname changes. session.signal({ data: JSON.stringify(data), type: 'userChanged', @@ -558,8 +570,6 @@ // The same nickname for screen sharing session if (screenSession) { - data.audioActive = false - data.videoActive = true screenSession.signal({ data: JSON.stringify(data), type: 'userChanged', @@ -580,8 +590,6 @@ try { publisher.publishAudio(!audioActive) audioActive = !audioActive - participantUpdate(sessionData.wrapper, { audioActive }) - signalUserUpdate() } catch (e) { console.error(e) } @@ -602,8 +610,6 @@ try { publisher.publishVideo(!videoActive) videoActive = !videoActive - participantUpdate(sessionData.wrapper, { videoActive }) - signalUserUpdate() } catch (e) { console.error(e) } @@ -640,6 +646,65 @@ return !!OV.checkScreenSharingCapabilities(); } + /** + * Update participant connection state + */ + function connectionUpdate(data) { + let conn = connections[data.connectionId] + + // It's me + if (session.connection.connectionId == data.connectionId) { + const rolePublisher = data.role && data.role & Roles.PUBLISHER + const isPublisher = sessionData.role & Roles.PUBLISHER + + // Inform the vue component, so it can update some UI controls + let update = () => { + if (sessionData.onSessionDataUpdate) { + sessionData.onSessionDataUpdate(data) + } + } + + // demoted to a subscriber + if ('role' in data && isPublisher && !rolePublisher) { + session.unpublish(publisher) + // FIXME: There's a reference in OpenVidu to a video element that should not + // exist anymore. It causes issues when we try to do publish/unpublish + // sequence multiple times in a row. So, we're clearing the reference here. + let videos = publisher.stream.streamManager.videos + publisher.stream.streamManager.videos = videos.filter(video => video.video.parentNode != null) + } + + // merge the changed data into internal session metadata object + Object.keys(data).forEach(key => { sessionData[key] = data[key] }) + + // update the participant element + sessionData.element = participantUpdate(sessionData.element, sessionData) + + // promoted to a publisher + if ('role' in data && !isPublisher && rolePublisher) { + publisher.createVideoElement(sessionData.element, 'PREPEND') + session.publish(publisher).then(() => { + data.audioActive = publisher.stream.audioActive + data.videoActive = publisher.stream.videoActive + update() + }) + + // TODO: Here the user is asked for media permissions again + // 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) { + // merge the changed data into internal session metadata object + Object.keys(data).forEach(key => { conn[key] = data[key] }) + + conn.element = participantUpdate(conn.element, conn) + } + } + /** * Update nickname in chat * @@ -667,11 +732,19 @@ * @return The element */ function participantCreate(params) { + let element + + params.isSelf = params.isSelf || session.connection.connectionId == params.connectionId + if (params.role & Roles.PUBLISHER || params.role & Roles.SCREEN) { - return publisherCreate(params) + element = publisherCreate(params) + } else { + element = subscriberCreate(params) } - return subscriberCreate(params) + setTimeout(resize, 50); + + return element } /** @@ -699,7 +772,7 @@ // Append the nickname widget wrapper.find('.controls').before(nicknameWidget(params)) - if (!params.self) { + if (!params.isSelf) { // Enable audio mute button wrapper.find('.link-audio').removeClass('hidden') .on('click', e => { @@ -731,12 +804,12 @@ }) } - numOfVideos++ - // Remove the subscriber element, if exists - $('#subscriber-' + params.connId).remove() + $('#subscriber-' + params.connectionId).remove() - return wrapper[params.self ? 'prependTo' : 'appendTo'](container).get(0) + return wrapper[params.isSelf ? 'prependTo' : 'appendTo'](container) + .attr('id', 'publisher-' + params.connectionId) + .get(0) } /** @@ -746,27 +819,51 @@ * @param params Connection metadata/params */ function participantUpdate(wrapper, params) { - const $element = $(wrapper) + const element = $(wrapper) + const isModerator = sessionData.role & Roles.MODERATOR + const isSelf = session.connection.connectionId == params.connectionId + + // Handle publisher-to-subscriber and subscriber-to-publisher change + if ('role' in params && !(params.role & Roles.SCREEN)) { + const rolePublisher = params.role & Roles.PUBLISHER + const isPublisher = element.is('.meet-video') + + if ((rolePublisher && !isPublisher) || (!rolePublisher && isPublisher)) { + element.remove() + return participantCreate(params) + } + + element.find('.action-role-publisher input').prop('checked', params.role & Roles.PUBLISHER) + } if ('audioActive' in params) { - $element.find('.status-audio')[params.audioActive ? 'addClass' : 'removeClass']('hidden') + element.find('.status-audio')[params.audioActive ? 'addClass' : 'removeClass']('hidden') } if ('videoActive' in params) { - $element.find('.status-video')[params.videoActive ? 'addClass' : 'removeClass']('hidden') + element.find('.status-video')[params.videoActive ? 'addClass' : 'removeClass']('hidden') } if ('nickname' in params) { - $element.find('.meet-nickname > .content').text(params.nickname) + element.find('.meet-nickname > .content').text(params.nickname) + } + + if (isSelf) { + element.addClass('self') } - if (params.self) { - $element.addClass('self') + if (isModerator) { + element.addClass('moderated') } - if (sessionData.role & Roles.MODERATOR) { - $element.addClass('moderated') + element.find('.dropdown-menu')[isSelf || isModerator ? 'removeClass' : 'addClass']('hidden') + element.find('.permissions')[isModerator ? 'removeClass' : 'addClass']('hidden') + + if ('role' in params && params.role & Roles.SCREEN) { + element.find('.permissions').addClass('hidden') } + + return wrapper } /** @@ -780,8 +877,8 @@ participantUpdate(wrapper, params) - return wrapper[params.self ? 'prependTo' : 'appendTo'](subscribersContainer) - .attr('id', 'subscriber-' + params.connId) + return wrapper[params.isSelf ? 'prependTo' : 'appendTo'](subscribersContainer) + .attr('id', 'subscriber-' + params.connectionId) .get(0) } @@ -794,20 +891,35 @@ // Create the element let element = $( '' ) let nickname = element.find('.meet-nickname') - .addClass('btn btn-outline-' + (params.self ? 'primary' : 'secondary')) + .addClass('btn btn-outline-' + (params.isSelf ? 'primary' : 'secondary')) + .attr({title: 'Options', 'data-toggle': 'dropdown'}) + .dropdown({boundary: container}) - if (params.self) { + if (params.isSelf) { // Add events for nickname change let editable = element.find('.content')[0] let editableEnable = () => { @@ -821,7 +933,8 @@ nicknameUpdate(editable.innerText, session.connection.connectionId) } - nickname.on('click', editableEnable) + element.find('.action-nickname').on('click', editableEnable) + element.find('.action-dismiss').remove() $(editable).on('blur', editableUpdate) .on('keydown', e => { @@ -831,17 +944,50 @@ return false } }) - } else if (sessionData.role & Roles.MODERATOR) { - nickname.attr({title: 'Options', 'data-toggle': 'dropdown'}) - .dropdown({boundary: container}) + } else { + element.find('.action-nickname').remove() element.find('.action-dismiss').on('click', e => { if (sessionData.onDismiss) { - sessionData.onDismiss(params.connId) + sessionData.onDismiss(params.connectionId) } }) } + // Don't close the menu on permission change + element.find('.dropdown-menu > label').on('click', e => { e.stopPropagation() }) + + if (sessionData.onConnectionChange) { + element.find('.action-role-publisher input').on('change', e => { + const enabled = e.target.checked + let role = params.role + + if (enabled) { + role |= Roles.PUBLISHER + } else { + role |= Roles.SUBSCRIBER + if (role & Roles.PUBLISHER) { + role ^= Roles.PUBLISHER + } + } + + sessionData.onConnectionChange(params.connectionId, { role }) + }) + + element.find('.action-role-moderator input').on('change', e => { + const enabled = e.target.checked + let role = params.role + + if (enabled) { + role |= Roles.MODERATOR + } else if (role & Roles.MODERATOR) { + role ^= Roles.MODERATOR + } + + sessionData.onConnectionChange(params.connectionId, { role }) + }) + } + return element.get(0) } @@ -864,6 +1010,7 @@ * Update the room "matrix" layout */ function updateLayout() { + let numOfVideos = $(container).find('.meet-video').length if (!numOfVideos) { return } @@ -1071,7 +1218,11 @@ // OpenVidu is unable to merge these two objects into one, for it it is only // two strings, so it puts a "%/%" separator in between, we'll replace it with comma // to get one parseable json object - return JSON.parse(connection.data.replace('}%/%{', ',')) + let data = JSON.parse(connection.data.replace('}%/%{', ',')) + + data.connectionId = connection.connectionId + + return data } } 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 @@ -35,6 +35,13 @@ } } } + + & + .dropdown-menu { + .permissions > label { + margin: 0; + padding-left: 3.75rem; + } + } } .meet-video { 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 @@ -316,6 +316,9 @@ $('#meet-session-menu').find('.link-fullscreen.closed').removeClass('hidden') } }, + isModerator() { + return this.isRoomOwner() || (!!this.session.role && (this.session.role & Roles.MODERATOR) > 0) + }, isPublisher() { return !!this.session.role && (this.session.role & Roles.PUBLISHER) > 0 }, @@ -414,12 +417,10 @@ }).modal() } } - this.session.onDismiss = connId => { this.dismissParticipant(connId) } - - if (this.isRoomOwner()) { - this.session.onJoinRequest = data => { this.joinRequest(data) } - } + this.session.onSessionDataUpdate = data => { this.updateSession(data) } + this.session.onConnectionChange = (connId, data) => { this.updateParticipant(connId, data) } + this.session.onJoinRequest = data => { this.joinRequest(data) } this.meet.joinRoom(this.session) }, @@ -570,14 +571,43 @@ if (!enabled) { // TODO: This might need to be a different route. E.g. the room password might have // changed since user joined the session + // Also because it creates a redundant connection (token) axios.post('/api/v4/openvidu/rooms/' + this.room, this.post, { ignoreErrors: true }) .then(response => { // Response data contains: session, token and shareToken - this.session.shareToken = response.data.token + this.session.shareToken = response.data.shareToken this.meet.updateSession(this.session) }) } }) + }, + updateParticipant(connId, params) { + if (this.isModerator()) { + axios.put('/api/v4/openvidu/rooms/' + this.room + '/connections/' + connId, params) + } + }, + updateSession(data) { + let params = {} + + if ('role' in data) { + params.role = data.role + } + + // merge new params into the object + this.session = Object.assign({}, this.session, params) + + // update some buttons state e.g. when switching from publisher to subscriber + if (!this.isPublisher()) { + this.setMenuItem('audio', false) + this.setMenuItem('video', false) + } else { + if ('videoActive' in data) { + this.setMenuItem('video', data.videoActive) + } + if ('audioActive' in data) { + this.setMenuItem('audio', data.audioActive) + } + } } } } diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -86,6 +86,7 @@ Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); // FIXME: I'm not sure about this one, should we use DELETE request maybe? Route::post('openvidu/rooms/{id}/connections/{conn}/dismiss', 'API\V4\OpenViduController@dismissConnection'); + Route::put('openvidu/rooms/{id}/connections/{conn}', 'API\V4\OpenViduController@updateConnection'); Route::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest'); Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest'); } diff --git a/src/tests/Browser/Meet/RoomControlsTest.php b/src/tests/Browser/Meet/RoomControlsTest.php --- a/src/tests/Browser/Meet/RoomControlsTest.php +++ b/src/tests/Browser/Meet/RoomControlsTest.php @@ -178,7 +178,6 @@ // Test nickname change propagation - // Use script() because type() does not work with this contenteditable widget $guest->setNickname('div.meet-video.self', 'guest'); $owner->waitFor('div.meet-video:not(.self) .meet-nickname') ->assertSeeIn('div.meet-video:not(.self) .meet-nickname', 'guest'); 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 @@ -370,4 +370,125 @@ $browser->waitUntilMissing('@session .meet-subscriber:not(.self)'); }); } + + /** + * Test demoting publisher to a subscriber + * + * @group openvidu + * @depends testSubscribers + */ + public function testDemoteToSubscriber(): void + { + $this->assignBetaEntitlement('john@kolab.org', 'meet'); + + $this->browse(function (Browser $browser, Browser $guest) { + // Join the room as the owner + $browser->visit(new RoomPage('john')) + ->waitFor('@setup-form') + ->waitUntilMissing('@setup-status-message.loading') + ->waitFor('@setup-status-message') + ->type('@setup-nickname-input', 'john') + ->clickWhenEnabled('@setup-button') + ->waitFor('@session') + ->assertMissing('@setup-form') + ->waitFor('@session video'); + + // In one browser window act as a guest + $guest->visit(new RoomPage('john')) + ->waitUntilMissing('@setup-status-message', 10) + ->assertSeeIn('@setup-button', "JOIN") + ->clickWhenEnabled('@setup-button') + ->waitFor('@session') + ->assertMissing('@setup-form') + ->waitFor('div.meet-video.self') + ->waitFor('div.meet-video:not(.self)') + ->assertElementsCount('@session div.meet-video', 2) + ->assertElementsCount('@session video', 2) + ->assertElementsCount('@session div.meet-subscriber', 0) + // assert there's no moderator-related features for this guess available + ->click('@session .meet-video.self .meet-nickname') + ->whenAvailable('@session .meet-video.self .dropdown-menu', function (Browser $browser) { + $browser->assertMissing('.permissions'); + }) + ->click('@session .meet-video:not(.self) .meet-nickname') + ->pause(50) + ->assertMissing('.dropdown-menu'); + + // Demote the guest to a subscriber + $browser + ->waitFor('div.meet-video.self') + ->waitFor('div.meet-video:not(.self)') + ->assertElementsCount('@session div.meet-video', 2) + ->assertElementsCount('@session video', 2) + ->assertElementsCount('@session .meet-subscriber', 0) + ->click('@session .meet-video:not(.self) .meet-nickname') + ->whenAvailable('@session .meet-video:not(.self) .dropdown-menu', function (Browser $browser) { + $browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing') + ->click('.action-role-publisher') + ->waitUntilMissing('.dropdown-menu'); + }) + ->waitUntilMissing('@session .meet-video:not(.self)') + ->waitFor('@session div.meet-subscriber') + ->assertElementsCount('@session div.meet-video', 1) + ->assertElementsCount('@session video', 1) + ->assertElementsCount('@session div.meet-subscriber', 1); + + $guest + ->waitUntilMissing('@session .meet-video.self') + ->waitFor('@session div.meet-subscriber') + ->assertElementsCount('@session div.meet-video', 1) + ->assertElementsCount('@session video', 1) + ->assertElementsCount('@session div.meet-subscriber', 1); + + // Promote the guest back to a publisher + $browser + ->click('@session .meet-subscriber .meet-nickname') + ->whenAvailable('@session .meet-subscriber .dropdown-menu', function (Browser $browser) { + $browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing') + ->assertNotChecked('.action-role-publisher input') + ->click('.action-role-publisher') + ->waitUntilMissing('.dropdown-menu'); + }) + ->waitFor('@session .meet-video:not(.self)') + ->assertElementsCount('@session div.meet-video', 2) + ->assertElementsCount('@session video', 2) + ->assertElementsCount('@session div.meet-subscriber', 0); + + $guest + ->waitFor('@session .meet-video.self') + ->assertElementsCount('@session div.meet-video', 2) + ->assertElementsCount('@session video', 2) + ->assertElementsCount('@session div.meet-subscriber', 0); + + // Demote the owner to a subscriber + $browser + ->click('@session .meet-video.self .meet-nickname') + ->whenAvailable('@session .meet-video.self .dropdown-menu', function (Browser $browser) { + $browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing') + ->assertChecked('.action-role-publisher input') + ->click('.action-role-publisher') + ->waitUntilMissing('.dropdown-menu'); + }) + ->waitUntilMissing('@session .meet-video.self') + ->waitFor('@session div.meet-subscriber.self') + ->assertElementsCount('@session div.meet-video', 1) + ->assertElementsCount('@session video', 1) + ->assertElementsCount('@session div.meet-subscriber', 1); + + // Promote the owner to a publisher + $browser + ->click('@session .meet-subscriber.self .meet-nickname') + ->whenAvailable('@session .meet-subscriber.self .dropdown-menu', function (Browser $browser) { + $browser->assertSeeIn('.action-role-publisher', 'Audio & Video publishing') + ->assertNotChecked('.action-role-publisher input') + ->click('.action-role-publisher') + ->waitUntilMissing('.dropdown-menu'); + }) + ->waitUntilMissing('@session .meet-subscriber.self') + ->waitFor('@session div.meet-video.self') + ->assertElementsCount('@session div.meet-video', 2) + ->assertElementsCount('@session video', 2) + ->assertElementsCount('@session div.meet-subscriber', 0); + }); + } } 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 @@ -161,14 +161,19 @@ */ public function setNickname($browser, $selector, $nickname): void { - // Use script() because type() does not work with this contenteditable widget - $selector = $selector . ' .meet-nickname .content'; - $browser->script( - "var element = document.querySelector('$selector');" - . "element.focus();" - . "element.innerText = '$nickname';" - . "element.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 27 }))" - ); + $element = "$selector .meet-nickname .content"; + + $browser->click("$selector .meet-nickname") + ->waitFor("$selector .dropdown-menu") + ->assertSeeIn("$selector .dropdown-menu > .action-nickname", 'Nickname') + ->click("$selector .dropdown-menu > .action-nickname") + ->waitUntilMissing('.dropdown-menu') + // Use script() because type() does not work with this contenteditable widget + ->script( + "var element = document.querySelector('$element');" + . "element.innerText = '$nickname';" + . "element.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 27 }))" + ); } /** 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 @@ -3,6 +3,7 @@ namespace Tests\Feature\Controller; use App\Http\Controllers\API\V4\OpenViduController; +use App\OpenVidu\Connection; use App\OpenVidu\Room; use Tests\TestCase; @@ -554,4 +555,66 @@ $room->refresh(); $this->assertSame(null, $room->getSetting('password')); } + + /** + * Test updating a participant (connection) + * + * @group openvidu + */ + public function testUpdateConnection(): void + { + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $room = Room::where('name', 'john')->first(); + $room->session_id = null; + $room->save(); + + $this->assignBetaEntitlement($john, 'meet'); + + // First we create the session + $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]); + $response->assertStatus(200); + + $json = $response->json(); + + // And the other user connection + $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]); + $response->assertStatus(200); + + $json = $response->json(); + + $conn_id = $json['connectionId']; + $room->refresh(); + $conn_data = $room->getOVConnection($conn_id); + + $this->assertSame($conn_id, $conn_data['connectionId']); + + // Non-existing room name + $response = $this->actingAs($john)->put("api/v4/openvidu/rooms/non-existing/connections/{$conn_id}", []); + $response->assertStatus(404); + + // Non-existing connection + $response = $this->actingAs($john)->put("api/v4/openvidu/rooms/{$room->name}/connections/123", []); + $response->assertStatus(404); + + $json = $response->json(); + + $this->assertCount(2, $json); + $this->assertSame('error', $json['status']); + $this->assertSame('The connection does not exist.', $json['message']); + + // Non-owner access + $response = $this->actingAs($jack)->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", []); + $response->assertStatus(403); + + // Expected success + $post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_MODERATOR]; + $response = $this->actingAs($john)->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame($post['role'], Connection::find($conn_id)->role); + } }