diff --git a/src/app/Http/Controllers/API/V4/OpenViduController.php b/src/app/Http/Controllers/API/V4/OpenViduController.php index ed3c7f67..0678d895 100644 --- a/src/app/Http/Controllers/API/V4/OpenViduController.php +++ b/src/app/Http/Controllers/API/V4/OpenViduController.php @@ -1,174 +1,261 @@ first(); // This isn't a room, bye bye if (!$room) { 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) { return $this->errorResponse(403); } if (!$room->deleteSession()) { return $this->errorResponse(500, \trans('meet.session-close-error')); } return response()->json([ 'status' => 'success', 'message' => __('meet.session-close-success'), ]); } /** * Listing of rooms that belong to the current user. * * @return \Illuminate\Http\JsonResponse */ public function index() { $user = Auth::guard()->user(); $rooms = Room::where('user_id', $user->id)->orderBy('name')->get(); if (count($rooms) == 0) { // Create a room for the user (with a random and unique name) while (true) { $name = strtolower(\App\Utils::randStr(3, 3, '-')); if (!Room::where('name', $name)->count()) { break; } } $room = Room::create([ 'name' => $name, 'user_id' => $user->id ]); $rooms = collect([$room]); } $result = [ 'list' => $rooms, 'count' => count($rooms), ]; return response()->json($result); } /** * Join the room session. Each room has one owner, and the room isn't open until the owner * joins (and effectively creates the session). * * @param string $id Room identifier (name) * * @return \Illuminate\Http\JsonResponse */ public function joinRoom($id) { $room = Room::where('name', $id)->first(); // Room does not exist, or the owner is deleted if (!$room || !$room->owner) { return $this->errorResponse(404, \trans('meet.room-not-found')); } // Check if there's still a valid beta entitlement for the room owner $sku = \App\Sku::where('title', 'meet')->first(); if ($sku && !$room->owner->entitlements()->where('sku_id', $sku->id)->first()) { return $this->errorResponse(404, \trans('meet.room-not-found')); } $user = Auth::guard()->user(); + $isOwner = $user && $user->id == $room->user_id; // There's no existing session if (!$room->hasSession()) { // Participants can't join the room until the session is created by the owner - if (!$user || $user->id != $room->user_id) { + if (!$isOwner) { return $this->errorResponse(423, \trans('meet.session-not-found')); } // The room owner can create the session on request if (empty(request()->input('init'))) { return $this->errorResponse(424, \trans('meet.session-not-found')); } $session = $room->createSession(); if (empty($session)) { return $this->errorResponse(500, \trans('meet.session-create-error')); } } + $password = (string) $room->getSetting('password'); + + $config = [ + 'locked' => $room->getSetting('locked') === 'true', + 'password' => $isOwner ? $password : '', + 'requires_password' => !$isOwner && strlen($password), + ]; + + // Validate room password + if (!$isOwner && strlen($password)) { + $request_password = request()->input('password'); + if ($request_password !== $password) { + // Note: We send the config to the client so it knows to display the password field + $response = [ + 'config' => $config, + 'message' => \trans('meet.session-password-error'), + 'status' => 'error', + ]; + + return response()->json($response, 425); + } + } + // Create session token for the current user/connection $response = $room->getSessionToken('PUBLISHER'); if (empty($response)) { return $this->errorResponse(500, \trans('meet.session-join-error')); } // Create session token for screen sharing connection if (!empty(request()->input('screenShare'))) { $add_token = $room->getSessionToken('PUBLISHER'); $response['shareToken'] = $add_token['token']; } // Tell the UI who's the room owner - $response['owner'] = $user && $user->id == $room->user_id; + $response['owner'] = $isOwner; + + // Append the room configuration + + $response['config'] = $config; return response()->json($response); } + /** + * Set the domain configuration. + * + * @param string $id Room identifier (name) + * + * @return \Illuminate\Http\JsonResponse|void + */ + public function setRoomConfig($id) + { + $room = Room::where('name', $id)->first(); + + // Room does not exist, or the owner is deleted + if (!$room || !$room->owner) { + return $this->errorResponse(404); + } + + $user = Auth::guard()->user(); + + // Only room owner can configure the room + if ($user->id != $room->user_id) { + return $this->errorResponse(403); + } + + $input = request()->input(); + $errors = []; + + foreach ($input as $key => $value) { + switch ($key) { + case 'password': + if ($value === null || $value === '') { + $input[$key] = null; + } else { + // TODO: Do we have to validate the password in any way? + } + break; + + case 'locked': + $input[$key] = $value ? 'true' : null; + break; + + default: + $errors[$key] = \trans('meet.room-unsupported-option-error'); + } + } + + if (!empty($errors)) { + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } + + if (!empty($input)) { + $room->setSettings($input); + } + + return response()->json([ + 'status' => 'success', + 'message' => \trans('meet.room-setconfig-success'), + ]); + } + /** * Webhook as triggered from OpenVidu server * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\Response The response */ public function webhook(Request $request) { \Log::debug($request->getContent()); switch ((string) $request->input('event')) { case 'sessionDestroyed': // When all participants left the room OpenVidu dispatches sessionDestroyed // event. We'll remove the session reference from the database. $sessionId = $request->input('sessionId'); $room = Room::where('session_id', $sessionId)->first(); if ($room) { $room->session_id = null; $room->save(); } break; } return response('Success', 200); } } diff --git a/src/app/OpenVidu/Room.php b/src/app/OpenVidu/Room.php index da291736..3579fab5 100644 --- a/src/app/OpenVidu/Room.php +++ b/src/app/OpenVidu/Room.php @@ -1,166 +1,174 @@ false, // No exceptions from Guzzle 'base_uri' => \config('openvidu.api_url'), 'verify' => \config('openvidu.api_verify_tls'), 'auth' => [ \config('openvidu.api_username'), \config('openvidu.api_password') ] ] ); } return self::$client; } /** * Create a OpenVidu session * * @return array|null Session data on success, NULL otherwise */ public function createSession(): ?array { $response = $this->client()->request( 'POST', "sessions", [ 'json' => [ 'mediaMode' => 'ROUTED', 'recordingMode' => 'MANUAL' ] ] ); if ($response->getStatusCode() !== 200) { $this->session_id = null; $this->save(); } $session = json_decode($response->getBody(), true); $this->session_id = $session['id']; $this->save(); return $session; } /** * Delete a OpenVidu session * * @return bool */ public function deleteSession(): bool { if (!$this->session_id) { return true; } $response = $this->client()->request( 'DELETE', "sessions/" . $this->session_id, ); if ($response->getStatusCode() == 204) { $this->session_id = null; $this->save(); return true; } return false; } /** * Create a OpenVidu session (connection) token * * @return array|null Token data on success, NULL otherwise */ public function getSessionToken($role = 'PUBLISHER'): ?array { $response = $this->client()->request( 'POST', 'tokens', [ 'json' => [ 'session' => $this->session_id, 'role' => $role ] ] ); if ($response->getStatusCode() == 200) { $json = json_decode($response->getBody(), true); return $json; } return null; } /** * Check if the room has an active session * * @return bool True when the session exists, False otherwise */ public function hasSession(): bool { if (!$this->session_id) { return false; } $response = $this->client()->request('GET', "sessions/{$this->session_id}"); return $response->getStatusCode() == 200; } /** * The room owner. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function owner() { return $this->belongsTo('\App\User', 'user_id', 'id'); } /** * Any (additional) properties of this room. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\OpenVidu\RoomSetting', 'room_id'); } } diff --git a/src/resources/js/meet.js b/src/resources/js/meet.js index 4665f826..c52aed93 100644 --- a/src/resources/js/meet.js +++ b/src/resources/js/meet.js @@ -1,38 +1,40 @@ /** * Application code for the Meet UI */ import routes from './routes-meet.js' window.routes = routes window.isAdmin = false require('./app') // Register additional icons import { library } from '@fortawesome/fontawesome-svg-core' import { faAlignLeft, faCompress, faDesktop, faExpand, faMicrophone, faPowerOff, faUser, + faShieldAlt, faVideo, faVolumeMute } from '@fortawesome/free-solid-svg-icons' // Register only these icons we need library.add( faAlignLeft, faCompress, faDesktop, faExpand, faMicrophone, faPowerOff, faUser, + faShieldAlt, faVideo, faVolumeMute ) diff --git a/src/resources/lang/en/meet.php b/src/resources/lang/en/meet.php index 7958bd85..6f0341c7 100644 --- a/src/resources/lang/en/meet.php +++ b/src/resources/lang/en/meet.php @@ -1,22 +1,26 @@ 'The room does not exist.', + 'room-setconfig-success' => 'Room configuration updated successfully.', + 'room-unsupported-option-error' => 'Invalid room configuration option.', 'session-not-found' => 'The session does not exist.', 'session-create-error' => 'Failed to create the session.', 'session-join-error' => 'Failed to join the session.', 'session-close-error' => 'Failed to close the session.', 'session-close-success' => 'The session has been closed successfully.', + 'session-password-error' => 'Failed to join the session. Invalid password.', + ]; diff --git a/src/resources/themes/forms.scss b/src/resources/themes/forms.scss index 5690b8ed..f8bd1d75 100644 --- a/src/resources/themes/forms.scss +++ b/src/resources/themes/forms.scss @@ -1,47 +1,77 @@ .list-input { & > div { &:not(:last-child) { margin-bottom: -1px; input, a.btn { border-bottom-right-radius: 0; border-bottom-left-radius: 0; } } &:not(:first-child) { input, a.btn { border-top-right-radius: 0; border-top-left-radius: 0; } } } input.is-invalid { z-index: 2; } .btn svg { vertical-align: middle; } } .range-input { display: flex; label { margin-right: 0.5em; } } +.input-group-activable { + &.active { + :not(.input-group-append):not(.activable) { + display: none; + } + } + + &:not(.active) { + .activable { + display: none; + } + } + + // Label is always visible + .label { + color: $body-color; + display: initial !important; + } + + .input-group-text { + border-color: transparent; + background: transparent; + padding-left: 0; + + &:not(.label) { + flex: 1; + } + } +} + .form-control-plaintext .btn-sm { margin-top: -0.25rem; } form.read-only { .row { margin-bottom: 0; } } diff --git a/src/resources/themes/meet.scss b/src/resources/themes/meet.scss index ed13c224..00bdbf11 100644 --- a/src/resources/themes/meet.scss +++ b/src/resources/themes/meet.scss @@ -1,251 +1,255 @@ .meet-video { position: relative; background: $menu-bg-color; // Use flexbox for centering .watermark display: flex; align-items: center; justify-content: center; .watermark { color: darken($menu-bg-color, 20%); width: 50%; height: 50%; } video { // To make object-fit:cover working we have to set the height in pixels // on the wrapper element. This is what javascript method will do. object-fit: cover; width: 100%; height: 100%; background: #000; & + .watermark { display: none; } } &.fullscreen { video { // We don't want the video to be cut in fullscreen // This will preserve the aspect ratio of the video stream object-fit: contain; } } .controls { position: absolute; bottom: 0; right: 0; margin: 0.5em; padding: 0 0.05em; line-height: 2em; border-radius: 1em; background: rgba(#000, 0.7); button { line-height: 2; border-radius: 50%; padding: 0; width: 2em; } } .status { position: absolute; bottom: 0; left: 0; margin: 0.5em; line-height: 2em; span { display: inline-block; color: #fff; border-radius: 50%; width: 2em; text-align: center; margin-right: 0.25em; } } .nickname { position: absolute; top: 0; left: 0; margin: 0.5em; padding: 0 1em; line-height: 2em; border-radius: 1em; max-width: calc(100% - 1em); background: rgba(#fff, 0.8); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; button { display: none; } span { outline: none; } } &.publisher .nickname { cursor: pointer; background: rgba($main-color, 0.9); &:focus-within { box-shadow: $btn-focus-box-shadow; } span:empty { display: block; height: 2em; &:not(:focus) + button { display: block; position: absolute; top: 0; left: 0; width: 2em; height: 2em; border-radius: 50%; padding: 0; color: $menu-gray; } } } } #meet-component { flex-grow: 1; display: flex; flex-direction: column; & + .filler { display: none; } } #app.meet { height: 100%; #meet-component { overflow: hidden; } } #meet-setup { max-width: 720px; + + .input-group svg { + width: 1em; + } } #meet-auth { margin-top: 2rem; margin-bottom: 2rem; flex: 1; } #meet-session-toolbar { display: flex; justify-content: center; } #meet-session-menu { button { font-size: 1.3em; padding: 0 0.25em; margin: 0.5em; position: relative; .badge { font-size: 0.5em; position: absolute; right: -0.5em; &:empty { display: none; } } } } #meet-session-layout { flex: 1; overflow: hidden; } #meet-session { display: flex; justify-content: center; flex-wrap: wrap; flex: 1; //overflow: hidden; } #meet-chat { width: 0; display: none; flex-direction: column; &.open { width: 30%; display: flex !important; .mobile & { width: 100%; z-index: 1; background: $body-bg; } } .chat { flex: 1; overflow-y: auto; } .message { margin: 0 0.5em 0.5em 0.5em; padding: 0.25em 0.5em; border-radius: 1em; background: $menu-bg-color; overflow-wrap: break-word; &.self { background: lighten($main-color, 30%); } } .nickname { font-size: 80%; color: $secondary; text-align: right; } // TODO: mobile mode } #setup-preview { display: flex; video { width: 100%; transform: rotateY(180deg); background: #000; } .volume { height: 50%; position: absolute; bottom: 1em; right: 2em; width: 0.5em; background: rgba(0, 0, 0, 0.5); .bar { width: 100%; position: absolute; bottom: 0; } } } diff --git a/src/resources/vue/Meet/Room.vue b/src/resources/vue/Meet/Room.vue index 549755eb..f5951b11 100644 --- a/src/resources/vue/Meet/Room.vue +++ b/src/resources/vue/Meet/Room.vue @@ -1,352 +1,397 @@ diff --git a/src/resources/vue/Meet/SessionSecurityOptions.vue b/src/resources/vue/Meet/SessionSecurityOptions.vue new file mode 100644 index 00000000..8d6a2b78 --- /dev/null +++ b/src/resources/vue/Meet/SessionSecurityOptions.vue @@ -0,0 +1,110 @@ + + + diff --git a/src/routes/api.php b/src/routes/api.php index ae56d9b7..86421a64 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,149 +1,150 @@ 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('login', 'API\AuthController@login'); Route::group( ['middleware' => 'auth:api'], function ($router) { Route::get('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/auth' ], function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/init', 'API\SignupController@init'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'auth:api', 'prefix' => $prefix . 'api/v4' ], function () { Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::apiResource('entitlements', API\V4\EntitlementsController::class); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::get('users/{id}/skus', 'API\V4\SkusController@userSkus'); Route::get('users/{id}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); Route::post('payments', 'API\V4\PaymentsController@store'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); Route::get('openvidu/rooms', 'API\V4\OpenViduController@index'); Route::post('openvidu/rooms/{id}/close', 'API\V4\OpenViduController@closeRoom'); + Route::post('openvidu/rooms/{id}/config', 'API\V4\OpenViduController@setRoomConfig'); } ); // Note: In Laravel 7.x we could just use withoutMiddleware() instead of a separate group Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/v4' ], function () { - Route::get('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); + Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => $prefix . 'api/v4' ], function ($router) { Route::post('support/request', 'API\V4\SupportController@request'); } ); Route::group( [ 'domain' => \config('app.domain'), 'prefix' => $prefix . 'api/webhooks', ], function () { Route::post('payment/{provider}', 'API\V4\PaymentsController@webhook'); Route::post('meet/openvidu', 'API\V4\OpenViduController@webhook'); } ); Route::group( [ 'domain' => 'admin.' . \config('app.domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => $prefix . 'api/v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\Admin\DomainsController@confirm'); Route::post('domains/{id}/suspend', 'API\V4\Admin\DomainsController@suspend'); Route::post('domains/{id}/unsuspend', 'API\V4\Admin\DomainsController@unsuspend'); Route::apiResource('entitlements', API\V4\Admin\EntitlementsController::class); Route::apiResource('packages', API\V4\Admin\PackagesController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::post('users/{id}/reset2FA', 'API\V4\Admin\UsersController@reset2FA'); Route::get('users/{id}/skus', 'API\V4\Admin\SkusController@userSkus'); Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff'); Route::get('wallets/{id}/transactions', 'API\V4\Admin\WalletsController@transactions'); Route::apiResource('discounts', API\V4\Admin\DiscountsController::class); Route::get('stats/chart/{chart}', 'API\V4\Admin\StatsController@chart'); } ); diff --git a/src/routes/websocket.php b/src/routes/websocket.php index 64240f04..3fd6d85b 100644 --- a/src/routes/websocket.php +++ b/src/routes/websocket.php @@ -1,41 +1,37 @@ emit( - 'message', - 'welcome' - ); + return; } ); Websocket::on( 'open', function ($websocket, Request $request) { - \Log::debug("socket opened"); + return; } ); Websocket::on( 'disconnect', function ($websocket) { - \Log::debug("someone disconnected"); + return; } ); -Websocket::on('message', 'App\Http\Controllers\WebsocketController@message'); -Websocket::on('ping', 'App\Http\Controllers\WebsocketController@ping'); +//Websocket::on('message', 'App\Http\Controllers\WebsocketController@message'); +//Websocket::on('ping', 'App\Http\Controllers\WebsocketController@ping'); diff --git a/src/tests/Browser/Meet/RoomControlsTest.php b/src/tests/Browser/Meet/RoomControlsTest.php index 7be87eeb..67f1f0c0 100644 --- a/src/tests/Browser/Meet/RoomControlsTest.php +++ b/src/tests/Browser/Meet/RoomControlsTest.php @@ -1,341 +1,342 @@ clearBetaEntitlements(); } public function tearDown(): void { $this->clearBetaEntitlements(); parent::tearDown(); } /** * Test fullscreen buttons * * @group openvidu */ public function testFullscreen(): void { // TODO: This test does not work in headless mode $this->markTestIncomplete(); // Make sure there's no session yet $room = Room::where('name', 'john')->first(); if ($room->session_id) { $room->session_id = null; $room->save(); } $this->assignBetaEntitlement('john@kolab.org', 'meet'); $this->browse(function (Browser $browser) { // Join the room as an owner (authenticate) $browser->visit(new RoomPage('john')) ->click('@setup-button') ->assertMissing('@toolbar') ->assertMissing('@menu') ->assertMissing('@session') ->assertMissing('@chat') ->assertMissing('@setup-form') ->assertVisible('@login-form') ->submitLogon('john@kolab.org', 'simple123') ->waitFor('@setup-form') ->assertMissing('@login-form') ->waitUntilMissing('@setup-status-message.loading') ->click('@setup-button') ->waitFor('@session') // Test fullscreen for the whole room ->click('@menu button.link-fullscreen.closed') ->assertVisible('@toolbar') ->assertVisible('@session') ->assertMissing('nav') ->assertMissing('@menu button.link-fullscreen.closed') ->click('@menu button.link-fullscreen.open') ->assertVisible('nav') // Test fullscreen for the participant video ->click('@session button.link-fullscreen.closed') ->assertVisible('@session') ->assertMissing('@toolbar') ->assertMissing('nav') ->assertMissing('@session button.link-fullscreen.closed') ->click('@session button.link-fullscreen.open') ->assertVisible('nav') ->assertVisible('@toolbar'); }); } /** * Test nickname and muting audio/video * * @group openvidu */ public function testNicknameAndMuting(): void { // Make sure there's no session yet $room = Room::where('name', 'john')->first(); if ($room->session_id) { $room->session_id = null; $room->save(); } $this->assignBetaEntitlement('john@kolab.org', 'meet'); $this->browse(function (Browser $owner, Browser $guest) { // 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') ->type('@setup-nickname-input', 'john') ->click('@setup-button') ->waitFor('@session'); // In another browser act as a guest $guest->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', '') ->click('@setup-button') ->waitFor('@session'); // Assert current UI state $owner->assertToolbar([ 'audio' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED, 'video' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED, - 'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED, + 'screen' => 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, 'logout' => RoomPage::BUTTON_ACTIVE | RoomPage::BUTTON_ENABLED, ]) ->whenAvailable('div.meet-video.publisher', function (Browser $browser) { $browser->assertVisible('video') ->assertAudioMuted('video', true) ->assertSeeIn('.nickname', 'john') ->assertMissing('.nickname button') ->assertVisible('.controls button.link-fullscreen') ->assertMissing('.controls button.link-audio') ->assertMissing('.status .status-audio') ->assertMissing('.status .status-video'); }) ->whenAvailable('div.meet-video:not(.publisher)', function (Browser $browser) { $browser->assertMissing('video') ->assertMissing('.nickname') ->assertVisible('.controls button.link-fullscreen') ->assertVisible('.controls button.link-audio') ->assertVisible('.status .status-audio') ->assertVisible('.status .status-video'); }) ->assertElementsCount('@session div.meet-video', 2); // Assert current UI state $guest->assertToolbar([ 'audio' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED, 'video' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED, - 'screen' => RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_DISABLED, + 'screen' => 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, ]) ->whenAvailable('div.meet-video.publisher', function (Browser $browser) { $browser->assertVisible('video') //->assertAudioMuted('video', true) ->assertVisible('.nickname button') ->assertMissing('.nickname span') ->assertVisible('.controls button.link-fullscreen') ->assertMissing('.controls button.link-audio') ->assertVisible('.status .status-audio') ->assertVisible('.status .status-video'); }) ->whenAvailable('div.meet-video:not(.publisher)', function (Browser $browser) { $browser->assertVisible('video') ->assertSeeIn('.nickname', 'john') ->assertMissing('.nickname button') ->assertVisible('.controls button.link-fullscreen') ->assertVisible('.controls button.link-audio') ->assertMissing('.status .status-audio') ->assertMissing('.status .status-video'); }) ->assertElementsCount('@session div.meet-video', 2); // Test nickname change propagation // Use script() because type() does not work with this contenteditable widget $guest->setNickname('div.meet-video.publisher', 'guest'); $owner->waitFor('div.meet-video:not(.publisher) .nickname') ->assertSeeIn('div.meet-video:not(.publisher) .nickname', 'guest'); // Test muting audio $owner->click('@menu button.link-audio') ->assertToolbarButtonState('audio', RoomPage::BUTTON_INACTIVE | RoomPage::BUTTON_ENABLED) ->assertVisible('div.meet-video.publisher .status .status-audio'); // FIXME: It looks that we can't just check the