Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117795243
D2197.1775261724.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
94 KB
Referenced Files
None
Subscribers
None
D2197.1775261724.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
@@ -11,8 +11,10 @@
class OpenViduController extends Controller
{
+ public const AUTH_HEADER = 'X-Meet-Auth-Token';
+
/**
- * Accepting the room join request.
+ * Accept the room join request.
*
* @param string $id Room identifier (name)
* @param string $reqid Request identifier
@@ -28,10 +30,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 +43,7 @@
}
/**
- * Denying the room join request.
+ * Deny the room join request.
*
* @param string $id Room identifier (name)
* @param string $reqid Request identifier
@@ -59,10 +59,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 +105,38 @@
}
/**
- * Accepting the room join request.
+ * Create a connection for screen sharing.
+ *
+ * @param string $id Room identifier (name)
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function createConnection($id)
+ {
+ $room = Room::where('name', $id)->first();
+
+ // This isn't a room, bye bye
+ if (!$room) {
+ return $this->errorResponse(404, \trans('meet.room-not-found'));
+ }
+
+ $connection = $this->getConnectionFromRequest();
+
+ if (
+ !$connection
+ || $connection->session_id != $room->session_id
+ || ($connection->role & Room::ROLE_PUBLISHER) == 0
+ ) {
+ return $this->errorResponse(403);
+ }
+
+ $response = $room->getSessionToken(Room::ROLE_SCREEN);
+
+ return response()->json(['status' => 'success', 'token' => $response['token']]);
+ }
+
+ /**
+ * Dismiss the participant/connection from the session.
*
* @param string $id Room identifier (name)
* @param string $conn Connection identifier
@@ -123,10 +152,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);
}
@@ -138,7 +165,7 @@
}
/**
- * Listing of rooms that belong to the current user.
+ * Listing of rooms that belong to the authenticated user.
*
* @return \Illuminate\Http\JsonResponse
*/
@@ -295,12 +322,8 @@
return $this->errorResponse(500, \trans('meet.session-join-error'));
}
- // Create session token for screen sharing connection
- if (($role & Room::ROLE_PUBLISHER) && !empty(request()->input('screenShare'))) {
- $add_token = $room->getSessionToken(Room::ROLE_SCREEN);
-
- $response['shareToken'] = $add_token['token'];
- }
+ // Get up-to-date connections metadata
+ $response['connections'] = $room->getSessionConnections();
$response_code = 200;
$response['role'] = $role;
@@ -372,6 +395,55 @@
]);
}
+ /**
+ * 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':
+ // The 'owner' role is not assignable
+ if (
+ ($value & Room::ROLE_OWNER && !($connection->role & Room::ROLE_OWNER))
+ || (!($value & Room::ROLE_OWNER) && ($connection->role & Room::ROLE_OWNER))
+ ) {
+ return $this->errorResponse(403);
+ }
+
+ // The room owner has always a 'moderator' role
+ if (!($value & Room::ROLE_MODERATOR) && $connection->role & Room::ROLE_OWNER) {
+ $value |= Room::ROLE_MODERATOR;
+ }
+
+ $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 +478,55 @@
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;
+ }
+
+ // Moderator's authentication via the extra request header
+ if (
+ ($connection = $this->getConnectionFromRequest())
+ && $connection->session_id === $room->session_id
+ && $connection->role & Room::ROLE_MODERATOR
+ ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the connection object for the token in current request headers.
+ * It will also validate the token.
+ *
+ * @return \App\OpenVidu\Connection|null Connection (if exists and the token is valid)
+ */
+ protected function getConnectionFromRequest()
+ {
+ // Authenticate the user via the extra request header
+ if ($token = request()->header(self::AUTH_HEADER)) {
+ list($connId, ) = explode(':', base64_decode($token), 2);
+
+ if (
+ ($connection = Connection::find($connId))
+ && $connection->metadata['authToken'] === $token
+ ) {
+ return $connection;
+ }
+ }
+
+ return null;
+ }
}
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 @@
+<?php
+
+namespace App\Observers\OpenVidu;
+
+use App\OpenVidu\Connection;
+
+class ConnectionObserver
+{
+ /**
+ * Handle the OpenVidu connection "updated" event.
+ *
+ * @param \App\OpenVidu\Connection $connection The connection.
+ *
+ * @return void
+ */
+ public function updated(Connection $connection)
+ {
+ if ($connection->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/OpenVidu/Room.php b/src/app/OpenVidu/Room.php
--- a/src/app/OpenVidu/Room.php
+++ b/src/app/OpenVidu/Room.php
@@ -172,6 +172,31 @@
return false;
}
+ /**
+ * Returns metadata for every connection in a session.
+ *
+ * @return array Connections metadata, indexed by connection identifier
+ * @throws \Exception if session does not exist
+ */
+ public function getSessionConnections(): array
+ {
+ if (!$this->session_id) {
+ throw new \Exception("The room session does not exist");
+ }
+
+ return Connection::where('session_id', $this->session_id)
+ // Ignore screen sharing connection for now
+ ->whereRaw("(role & " . self::ROLE_SCREEN . ") = 0")
+ ->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];
+ })
+ ->all();
+ }
+
/**
* Create a OpenVidu session (connection) token
*
@@ -205,6 +230,8 @@
if ($response->getStatusCode() == 200) {
$json = json_decode($response->getBody(), true);
+ $authToken = base64_encode($json['id'] . ':' . \random_bytes(16));
+
// Extract the 'token' part of the token, it will be used to authenticate the connection.
// It will be needed in next iterations e.g. to authenticate moderators that aren't
// Kolab4 users (or are just not logged in to Kolab4).
@@ -217,12 +244,13 @@
$conn->session_id = $this->session_id;
$conn->room_id = $this->id;
$conn->role = $role;
- $conn->metadata = ['token' => $url['token']];
+ $conn->metadata = ['token' => $url['token'], 'authToken' => $authToken];
$conn->save();
return [
'session' => $this->session_id,
'token' => $json['token'],
+ 'authToken' => $authToken,
'connectionId' => $json['id'],
'role' => $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
@@ -42,11 +41,9 @@
let containerHeight
let chatCount = 0
let volumeElement
- let setupProps
let subscribersContainer
OV = new OpenVidu()
- screenOV = new OpenVidu()
// If there's anything to do, do it here.
//OV.setAdvancedConfiguration(config)
@@ -65,7 +62,8 @@
this.isScreenSharingSupported = isScreenSharingSupported
this.joinRoom = joinRoom
this.leaveRoom = leaveRoom
- this.setup = setup
+ this.setupStart = setupStart
+ this.setupStop = setupStop
this.setupSetAudioDevice = setupSetAudioDevice
this.setupSetVideoDevice = setupSetVideoDevice
this.switchAudio = switchAudio
@@ -76,8 +74,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, connections,
+ * chatElement, menuElement, onDestroy, onJoinRequest, onDismiss, onConnectionChange,
+ * onSessionDataUpdate, onMediaSetup)
*/
function joinRoom(data) {
resize();
@@ -108,18 +107,22 @@
// 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)
+ const connId = metadata.connectionId
+
+ // The connection metadata here is the initial metadata set on
+ // connection initialization. There's no way to update it via OpenVidu API.
+ // So, we merge the initial connection metadata with up-to-dated one that
+ // we got from our database.
+ if (sessionData.connections && connId in sessionData.connections) {
+ Object.assign(metadata, sessionData.connections[connId])
+ delete sessionData.connections[connId]
+ }
- connections[connectionId] = { element }
+ metadata.element = participantCreate(metadata)
- resize()
+ connections[connId] = metadata
// Send the current user status to the connecting user
// otherwise e.g. nickname might be not up to date
@@ -127,30 +130,28 @@
})
session.on('connectionDestroyed', event => {
- let conn = connections[event.connection.connectionId]
+ let connectionId = event.connection.connectionId
+ let conn = connections[connectionId]
+
if (conn) {
- if ($(conn.element).is('.meet-video')) {
- numOfVideos--
- }
$(conn.element).remove()
- delete connections[event.connection.connectionId]
+ delete connections[connectionId]
}
+
resize()
})
// 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 +160,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 +198,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 +216,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);
@@ -251,8 +268,16 @@
*
* @param props Setup properties (videoElement, volumeElement, onSuccess, onError)
*/
- function setup(props) {
- setupProps = props
+ function setupStart(props) {
+ // Note: After changing media permissions in Chrome/Firefox a page refresh is required.
+ // That means that in a scenario where you first blocked access to media devices
+ // and then allowed it we can't ask for devices list again and expect a different
+ // result than before.
+ // That's why we do not bother, and return ealy when we open the media setup dialog.
+ if (publisher) {
+ volumeMeterStart()
+ return
+ }
publisher = OV.initPublisher(undefined, publisherDefaults)
@@ -301,6 +326,13 @@
})
}
+ /**
+ * Stop the setup "process", cleanup after it.
+ */
+ function setupStop() {
+ volumeMeterStop()
+ }
+
/**
* Change the publisher audio device
*
@@ -317,9 +349,7 @@
audioActive = true
} else {
const mediaStream = publisher.stream.mediaStream
- const oldTrack = mediaStream.getAudioTracks()[0]
-
- let properties = Object.assign({}, publisherDefaults, {
+ const properties = Object.assign({}, publisherDefaults, {
publishAudio: true,
publishVideo: videoActive,
audioSource: deviceId,
@@ -328,19 +358,17 @@
volumeMeterStop()
- // Note: We're not using publisher.replaceTrack() as it wasn't working for me
-
- // Stop and remove the old track
- if (oldTrack) {
- oldTrack.stop()
- mediaStream.removeTrack(oldTrack)
- }
+ // Stop and remove the old track, otherwise you get "Concurrent mic process limit." error
+ mediaStream.getAudioTracks().forEach(track => {
+ track.stop()
+ mediaStream.removeTrack(track)
+ })
// TODO: Handle errors
await OV.getUserMedia(properties)
.then(async (newMediaStream) => {
- publisher.stream.mediaStream = newMediaStream
+ await replaceTrack(newMediaStream.getAudioTracks()[0])
volumeMeterStart()
audioActive = true
audioSource = deviceId
@@ -364,9 +392,7 @@
videoActive = true
} else {
const mediaStream = publisher.stream.mediaStream
- const oldTrack = mediaStream.getAudioTracks()[0]
-
- let properties = Object.assign({}, publisherDefaults, {
+ const properties = Object.assign({}, publisherDefaults, {
publishAudio: audioActive,
publishVideo: true,
audioSource: audioSource,
@@ -375,17 +401,17 @@
volumeMeterStop()
- // Stop and remove the old track
- if (oldTrack) {
- oldTrack.stop()
- mediaStream.removeTrack(oldTrack)
- }
+ // Stop and remove the old track, otherwise you get "Concurrent mic process limit." error
+ mediaStream.getVideoTracks().forEach(track => {
+ track.stop()
+ mediaStream.removeTrack(track)
+ })
// TODO: Handle errors
await OV.getUserMedia(properties)
.then(async (newMediaStream) => {
- publisher.stream.mediaStream = newMediaStream
+ await replaceTrack(newMediaStream.getVideoTracks()[0])
volumeMeterStart()
videoActive = true
videoSource = deviceId
@@ -395,6 +421,53 @@
return videoActive
}
+ /**
+ * A way to switch tracks in a stream.
+ * Note: This is close to what publisher.replaceTrack() does but it does not
+ * require the session.
+ * Note: The old track needs to be removed before OV.getUserMedia() call,
+ * otherwise we get "Concurrent mic process limit" error.
+ */
+ function replaceTrack(track) {
+ const stream = publisher.stream
+
+ const replaceMediaStreamTrack = () => {
+ stream.mediaStream.addTrack(track);
+
+ if (session) {
+ session.sendVideoData(publisher.stream.streamManager, 5, true, 5);
+ }
+ }
+
+ return new Promise((resolve, reject) => {
+ if (stream.isLocalStreamPublished) {
+ // Only if the Publisher has been published it is necessary to call the native
+ // Web API RTCRtpSender.replaceTrack()
+ const senders = stream.getRTCPeerConnection().getSenders()
+ let sender
+
+ if (track.kind === 'video') {
+ sender = senders.find(s => !!s.track && s.track.kind === 'video')
+ } else {
+ sender = senders.find(s => !!s.track && s.track.kind === 'audio')
+ }
+
+ if (!sender) return
+
+ sender.replaceTrack(track).then(() => {
+ replaceMediaStreamTrack()
+ resolve()
+ }).catch(error => {
+ reject(error)
+ })
+ } else {
+ // Publisher not published. Simply modify local MediaStream tracks
+ replaceMediaStreamTrack()
+ resolve()
+ }
+ })
+ }
+
/**
* Setup the chat UI
*/
@@ -433,10 +506,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 +523,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 +627,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 +638,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 +658,6 @@
try {
publisher.publishAudio(!audioActive)
audioActive = !audioActive
- participantUpdate(sessionData.wrapper, { audioActive })
- signalUserUpdate()
} catch (e) {
console.error(e)
}
@@ -602,8 +678,6 @@
try {
publisher.publishVideo(!videoActive)
videoActive = !videoActive
- participantUpdate(sessionData.wrapper, { videoActive })
- signalUserUpdate()
} catch (e) {
console.error(e)
}
@@ -617,13 +691,16 @@
*/
function switchScreen(callback) {
if (screenPublisher) {
+ // Note: This is what the original openvidu-call app does.
+ // It is probably better for performance reasons to close the connection,
+ // than to use unpublish() and keep the connection open.
screenSession.disconnect()
screenSession = null
screenPublisher = null
if (callback) {
- // Note: Disconnecting invalidates the token. The callback should request
- // a new token for the next screen sharing session.
+ // Note: Disconnecting invalidates the token, we have to inform the vue component
+ // to update UI state (and be prepared to request a new token).
callback(false)
}
@@ -640,6 +717,78 @@
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 roleModerator = data.role && data.role & Roles.MODERATOR
+ const isPublisher = sessionData.role & Roles.PUBLISHER
+ const isModerator = sessionData.role & Roles.MODERATOR
+
+ // 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/demoted to/from a moderator
+ if ('role' in data) {
+ if ((!isModerator && roleModerator) || (isModerator && !roleModerator)) {
+ // Update all participants, to enable/disable the popup menu
+ Object.keys(connections).forEach(key => {
+ const conn = connections[key]
+ participantUpdate(conn.element, conn)
+ })
+ }
+ }
+
+ // 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 +816,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
}
/**
@@ -685,6 +842,7 @@
'<div class="meet-video">'
+ svgIcon('user', 'fas', 'watermark')
+ '<div class="controls">'
+ + '<button type="button" class="btn btn-link link-setup hidden" title="Media setup">' + svgIcon('cog') + '</button>'
+ '<button type="button" class="btn btn-link link-audio hidden" title="Mute audio">' + svgIcon('volume-mute') + '</button>'
+ '<button type="button" class="btn btn-link link-fullscreen closed hidden" title="Full screen">' + svgIcon('expand') + '</button>'
+ '<button type="button" class="btn btn-link link-fullscreen open hidden" title="Full screen">' + svgIcon('compress') + '</button>'
@@ -699,7 +857,12 @@
// Append the nickname widget
wrapper.find('.controls').before(nicknameWidget(params))
- if (!params.self) {
+ if (params.isSelf) {
+ if (sessionData.onMediaSetup) {
+ wrapper.find('.link-setup').removeClass('hidden')
+ .click(() => sessionData.onMediaSetup())
+ }
+ } else {
// Enable audio mute button
wrapper.find('.link-audio').removeClass('hidden')
.on('click', e => {
@@ -731,12 +894,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 +909,63 @@
* @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
+ const rolePublisher = params.role & Roles.PUBLISHER
+ const roleModerator = params.role & Roles.MODERATOR
+ const roleScreen = params.role & Roles.SCREEN
+ const roleOwner = params.role & Roles.OWNER
+
+ // Handle publisher-to-subscriber and subscriber-to-publisher change
+ if (!roleScreen) {
+ const isPublisher = element.is('.meet-video')
+
+ if ((rolePublisher && !isPublisher) || (!rolePublisher && isPublisher)) {
+ element.remove()
+ return participantCreate(params)
+ }
+ }
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 (params.self) {
- $element.addClass('self')
+ if (isSelf) {
+ element.addClass('self')
}
- if (sessionData.role & Roles.MODERATOR) {
- $element.addClass('moderated')
+ if (isModerator) {
+ element.addClass('moderated')
+ }
+
+ const withPerm = isModerator && !roleScreen && !(roleOwner && !isSelf);
+ const withMenu = isSelf || (isModerator && !roleOwner)
+
+ let elements = {
+ '.dropdown-menu': withMenu,
+ '.permissions': withPerm,
+ 'svg.moderator': roleModerator,
+ 'svg.user': !roleModerator
}
+
+ Object.keys(elements).forEach(key => {
+ element.find(key)[elements[key] ? 'removeClass' : 'addClass']('hidden')
+ })
+
+ element.find('.action-role-publisher input').prop('checked', rolePublisher)
+ element.find('.action-role-moderator input').prop('checked', roleModerator)
+ .prop('disabled', roleOwner)
+
+ return wrapper
}
/**
@@ -780,8 +979,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 +993,38 @@
// Create the element
let element = $(
'<div class="dropdown">'
- + '<a href="#" class="meet-nickname btn" title="Nickname" aria-haspopup="true" aria-expanded="false" role="button">'
+ + '<a href="#" class="meet-nickname btn" aria-haspopup="true" aria-expanded="false" role="button">'
+ '<span class="content"></span>'
- + '<span class="icon">' + svgIcon('user') + '</span>'
+ + '<span class="icon">'
+ + svgIcon('user', null, 'user')
+ + svgIcon('crown', null, 'moderator hidden')
+ + '</span>'
+ '</a>'
+ '<div class="dropdown-menu">'
+ + '<a class="dropdown-item action-nickname" href="#">Nickname</a>'
+ '<a class="dropdown-item action-dismiss" href="#">Dismiss</a>'
+ + '<div class="dropdown-divider permissions"></div>'
+ + '<div class="permissions">'
+ + '<h6 class="dropdown-header">Permissions</h6>'
+ + '<label class="dropdown-item action-role-publisher custom-control custom-switch">'
+ + '<input type="checkbox" class="custom-control-input">'
+ + ' <span class="custom-control-label">Audio & Video publishing</span>'
+ + '</label>'
+ + '<label class="dropdown-item action-role-moderator custom-control custom-switch">'
+ + '<input type="checkbox" class="custom-control-input">'
+ + ' <span class="custom-control-label">Moderation</span>'
+ + '</label>'
+ + '</div>'
+ '</div>'
+ '</div>'
)
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 +1038,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 +1049,60 @@
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)
}
})
}
+ let connectionRole = () => {
+ if (params.isSelf) {
+ return sessionData.role
+ }
+ if (params.connectionId in connections) {
+ return connections[params.connectionId].role
+ }
+ return 0
+ }
+
+ // 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 = connectionRole()
+
+ 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 = connectionRole()
+
+ if (enabled) {
+ role |= Roles.MODERATOR
+ } else if (role & Roles.MODERATOR) {
+ role ^= Roles.MODERATOR
+ }
+
+ sessionData.onConnectionChange(params.connectionId, { role })
+ })
+ }
+
return element.get(0)
}
@@ -864,6 +1125,7 @@
* Update the room "matrix" layout
*/
function updateLayout() {
+ let numOfVideos = $(container).find('.meet-video').length
if (!numOfVideos) {
return
}
@@ -944,6 +1206,10 @@
let gotSession = !!screenSession
+ if (!screenOV) {
+ screenOV = new OpenVidu()
+ }
+
// Init screen sharing session
if (!gotSession) {
screenSession = screenOV.initSession();
@@ -951,6 +1217,13 @@
let successFunc = function() {
screenSession.publish(screenPublisher)
+
+ screenSession.on('sessionDisconnected', event => {
+ callback(false)
+ screenSession = null
+ screenPublisher = null
+ })
+
if (callback) {
callback(true)
}
@@ -959,7 +1232,7 @@
let errorFunc = function() {
screenPublisher = null
if (callback) {
- callback(false)
+ callback(false, true)
}
}
@@ -1071,7 +1344,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/app.scss b/src/resources/themes/app.scss
--- a/src/resources/themes/app.scss
+++ b/src/resources/themes/app.scss
@@ -293,6 +293,10 @@
}
}
+#logon-form {
+ flex-basis: auto; // Bootstrap issue? See logon page with width < 992
+}
+
#logon-form-footer {
a:not(:first-child) {
margin-left: 2em;
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 {
@@ -151,10 +158,6 @@
#meet-setup {
max-width: 720px;
-
- .input-group svg {
- width: 1em;
- }
}
#meet-auth {
@@ -271,12 +274,18 @@
// TODO: mobile mode
}
-#setup-preview {
+.media-setup-form {
+ .input-group svg {
+ width: 1em;
+ }
+}
+
+.media-setup-preview {
display: flex;
+ position: relative;
video {
width: 100%;
- transform: rotateY(180deg);
background: #000;
}
@@ -293,6 +302,10 @@
position: absolute;
bottom: 0;
}
+
+ #media-setup-dialog & {
+ right: 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
@@ -33,8 +33,8 @@
<div class="card-body">
<div class="card-title">Set up your session</div>
<div class="card-text">
- <form class="setup-form row" @submit.prevent="joinSession">
- <div id="setup-preview" class="col-sm-6 mb-3 mb-sm-0">
+ <form class="media-setup-form row" @submit.prevent="joinSession">
+ <div class="media-setup-preview col-sm-6 mb-3 mb-sm-0">
<video class="rounded"></video>
<div class="volume"><div class="bar"></div></div>
</div>
@@ -118,6 +118,45 @@
</div>
</div>
+ <div id="media-setup-dialog" class="modal" tabindex="-1" role="dialog">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">Media setup</h5>
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+ <span aria-hidden="true">×</span>
+ </button>
+ </div>
+ <div class="modal-body">
+ <form class="media-setup-form">
+ <div class="media-setup-preview"></div>
+ <div class="input-group mt-2">
+ <label for="setup-microphone" class="input-group-prepend mb-0">
+ <span class="input-group-text" title="Microphone"><svg-icon icon="microphone"></svg-icon></span>
+ </label>
+ <select class="custom-select" id="setup-microphone" v-model="microphone" @change="setupMicrophoneChange">
+ <option value="">None</option>
+ <option v-for="mic in setup.microphones" :value="mic.deviceId" :key="mic.deviceId">{{ mic.label }}</option>
+ </select>
+ </div>
+ <div class="input-group mt-2">
+ <label for="setup-camera" class="input-group-prepend mb-0">
+ <span class="input-group-text" title="Camera"><svg-icon icon="video"></svg-icon></span>
+ </label>
+ <select class="custom-select" id="setup-camera" v-model="camera" @change="setupCameraChange">
+ <option value="">None</option>
+ <option v-for="cam in setup.cameras" :value="cam.deviceId" :key="cam.deviceId">{{ cam.label }}</option>
+ </select>
+ </div>
+ </form>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary modal-action" data-dismiss="modal">Close</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
<session-security-options v-if="session.config" :config="session.config" :room="room" @config-update="configUpdate"></session-security-options>
</div>
</template>
@@ -128,37 +167,42 @@
import LogonForm from '../Login'
import SessionSecurityOptions from './SessionSecurityOptions'
-// 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
-)
+ // Register additional icons
+ import { library } from '@fortawesome/fontawesome-svg-core'
+
+ import {
+ faAlignLeft,
+ faCog,
+ faCompress,
+ faCrown,
+ faDesktop,
+ faExpand,
+ faMicrophone,
+ faPowerOff,
+ faUser,
+ faShieldAlt,
+ faVideo,
+ faVolumeMute
+ } from '@fortawesome/free-solid-svg-icons'
+
+ // Register only these icons we need
+ library.add(
+ faAlignLeft,
+ faCog,
+ faCompress,
+ faCrown,
+ faDesktop,
+ faExpand,
+ faMicrophone,
+ faPowerOff,
+ faUser,
+ faShieldAlt,
+ faVideo,
+ faVolumeMute
+ )
let roomRequest
+ const authHeader = 'X-Meet-Auth-Token'
export default {
components: {
@@ -212,6 +256,8 @@
if (this.meet) {
this.meet.leaveRoom()
}
+
+ delete axios.defaults.headers.common[authHeader]
},
methods: {
authSuccess() {
@@ -257,6 +303,10 @@
if (init) {
this.joinSession()
}
+
+ if (this.session.authToken) {
+ axios.defaults.headers.common[authHeader] = this.session.authToken
+ }
})
.catch(error => {
if (!error.response) {
@@ -316,6 +366,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 +467,11 @@
}).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.session.onMediaSetup = () => { this.setupMedia() }
this.meet.joinRoom(this.session)
},
@@ -437,7 +489,7 @@
}
},
makePicture() {
- const video = $("#setup-preview video")[0];
+ const video = $("#meet-setup video")[0];
// Skip if video is not "playing"
if (!video.videoWidth || !this.camera) {
@@ -494,10 +546,21 @@
button.prop('disabled', disabled)
}
},
+ setupMedia() {
+ let dialog = $('#media-setup-dialog')
+
+ if (!dialog.find('video').length) {
+ $('#meet-setup').find('video,div.volume').appendTo(dialog.find('.media-setup-preview'))
+ }
+
+ dialog.on('show.bs.modal', () => { this.meet.setupStart() })
+ .on('hide.bs.modal', () => { this.meet.setupStop() })
+ .modal()
+ },
setupSession() {
- this.meet.setup({
- videoElement: $('#setup-preview video')[0],
- volumeElement: $('#setup-preview .volume')[0],
+ this.meet.setupStart({
+ videoElement: $('#meet-setup video')[0],
+ volumeElement: $('#meet-setup .volume')[0],
onSuccess: setup => {
this.setup = setup
this.microphone = setup.audioSource
@@ -562,22 +625,54 @@
this.setMenuItem('video', enabled)
},
switchScreen() {
- this.meet.switchScreen(enabled => {
- this.setMenuItem('screen', enabled)
-
- // After one screen sharing session ended request a new token
- // for the next screen sharing session
- if (!enabled) {
- // TODO: This might need to be a different route. E.g. the room password might have
- // changed since user joined the session
- 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.meet.updateSession(this.session)
- })
+ const switchScreenAction = () => {
+ this.meet.switchScreen((enabled, error) => {
+ this.setMenuItem('screen', enabled)
+ if (!enabled && !error) {
+ // Closing a screen sharing connection invalidates the token
+ delete this.session.shareToken
+ }
+ })
+ }
+
+ if (this.session.shareToken || !$('#meet-session-menu').find('.link-screen').is('.text-danger')) {
+ switchScreenAction()
+ } else {
+ axios.post('/api/v4/openvidu/rooms/' + this.room + '/connections')
+ .then(response => {
+ this.session.shareToken = response.data.token
+ this.meet.updateSession(this.session)
+ switchScreenAction()
+ })
+ }
+ },
+ 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
@@ -84,10 +84,6 @@
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');
- // 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::post('openvidu/rooms/{id}/request/{reqid}/accept', 'API\V4\OpenViduController@acceptJoinRequest');
- Route::post('openvidu/rooms/{id}/request/{reqid}/deny', 'API\V4\OpenViduController@denyJoinRequest');
}
);
@@ -99,6 +95,12 @@
],
function () {
Route::post('openvidu/rooms/{id}', 'API\V4\OpenViduController@joinRoom');
+ Route::post('openvidu/rooms/{id}/connections', 'API\V4\OpenViduController@createConnection');
+ // 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');
@@ -274,6 +273,7 @@
->assertElementsCount('@chat-list .message', 0)
->keys('@chat-input', 'test1', '{enter}')
->assertValue('@chat-input', '')
+ ->waitFor('@chat-list .message')
->assertElementsCount('@chat-list .message', 1)
->assertSeeIn('@chat-list .message .nickname', 'john')
->assertSeeIn('@chat-list .message div:last-child', 'test1');
diff --git a/src/tests/Browser/Meet/RoomModeratorTest.php b/src/tests/Browser/Meet/RoomModeratorTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Meet/RoomModeratorTest.php
@@ -0,0 +1,185 @@
+<?php
+
+namespace Tests\Browser\Meet;
+
+use App\OpenVidu\Room;
+use Tests\Browser;
+use Tests\Browser\Components\Dialog;
+use Tests\Browser\Components\Menu;
+use Tests\Browser\Pages\Meet\Room as RoomPage;
+use Tests\TestCaseDusk;
+
+class RoomModeratorTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+ $this->clearMeetEntitlements();
+ $this->assignMeetEntitlement('john@kolab.org');
+
+ $room = Room::where('name', 'john')->first();
+ $room->setSettings(['password' => null, 'locked' => null]);
+ if ($room->session_id) {
+ $room->session_id = null;
+ $room->save();
+ }
+ }
+
+ public function tearDown(): void
+ {
+ $this->clearMeetEntitlements();
+
+ $room = Room::where('name', 'john')->first();
+ $room->setSettings(['password' => null, 'locked' => null]);
+ if ($room->session_id) {
+ $room->session_id = null;
+ $room->save();
+ }
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test three users in a room, one will be promoted/demoted to/from a moderator
+ *
+ * @group openvidu
+ */
+ public function testModeratorPromotion(): void
+ {
+ $this->browse(function (Browser $browser, Browser $guest1, Browser $guest2) {
+ // In one browser window join as a room owner
+ $browser->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')
+ ->select('@setup-mic-select', '')
+ ->select('@setup-cam-select', '')
+ ->clickWhenEnabled('@setup-button')
+ ->waitFor('@session');
+
+ // In one browser window join as a guest (to be promoted)
+ $guest1->visit(new RoomPage('john'))
+ ->waitFor('@setup-form')
+ ->waitUntilMissing('@setup-status-message.loading')
+ ->assertMissing('@setup-status-message')
+ ->assertSeeIn('@setup-button', "JOIN")
+ ->type('@setup-nickname-input', 'Guest1')
+ // Join the room, disable cam/mic
+ ->select('@setup-mic-select', '')
+ ->select('@setup-cam-select', '')
+ ->clickWhenEnabled('@setup-button')
+ ->waitFor('@session');
+
+ // In one browser window join as a guest
+ $guest2->visit(new RoomPage('john'))
+ ->waitFor('@setup-form')
+ ->waitUntilMissing('@setup-status-message.loading')
+ ->assertMissing('@setup-status-message')
+ ->assertSeeIn('@setup-button', "JOIN")
+ // Join the room, disable mic
+ ->select('@setup-mic-select', '')
+ ->clickWhenEnabled('@setup-button')
+ ->waitFor('@session');
+
+ // Assert that only the owner is a moderator right now
+ $guest1->waitFor('@session video')
+ ->assertMissing('@session div.meet-video .meet-nickname') // guest2
+ ->assertVisible('@session div.meet-subscriber.self svg.user') // self
+ ->assertMissing('@session div.meet-subscriber.self svg.moderator') // self
+ ->assertMissing('@session div.meet-subscriber:not(.self) svg.user') // owner
+ ->assertVisible('@session div.meet-subscriber:not(.self) svg.moderator') // owner
+ ->click('@session div.meet-subscriber.self .meet-nickname')
+ ->whenAvailable('@session .dropdown-menu', function (Browser $browser) {
+ $browser->assertMissing('.permissions');
+ })
+ ->click('@session div.meet-subscriber:not(.self) .meet-nickname')
+ ->assertMissing('.dropdown-menu');
+
+ $guest2->waitFor('@session video')
+ ->assertVisible('@session div.meet-video svg.user') // self
+ ->assertMissing('@session div.meet-video svg.moderator'); // self
+ /*
+ it does not work because the order is different all the time
+
+ ->assertMissing('@session div.meet-subscriber:nth-child(1) svg.user') // owner
+ ->assertVisible('@session div.meet-subscriber:nth-child(1) svg.moderator') // owner
+ ->assertVisible('@session div.meet-subscriber:nth-child(2) svg.user') // guest1
+ ->assertMissing('@session div.meet-subscriber:nth-child(2) svg.moderator'); // guest1
+ */
+
+ // Promote guest1 to a moderator
+ $browser->waitFor('@session video')
+ ->assertMissing('@session div.meet-subscriber.self svg.user') // self
+ ->assertVisible('@session div.meet-subscriber.self svg.moderator') // self
+ ->click('@session div.meet-subscriber.self .meet-nickname')
+ ->whenAvailable('@session .dropdown-menu', function (Browser $browser) {
+ $browser->assertChecked('.action-role-moderator input')
+ ->assertDisabled('.action-role-moderator input');
+ })
+ ->click('@session div.meet-subscriber:not(.self) .meet-nickname')
+ ->whenAvailable('@session div.meet-subscriber:not(.self) .dropdown-menu', function (Browser $browser) {
+ $browser->assertNotChecked('.action-role-moderator input')
+ ->click('.action-role-moderator input');
+ });
+
+ // Assert that we have two moderators now
+ $guest2->waitFor('@session div.meet-subscriber:nth-child(2) svg.moderator')
+ ->assertMissing('@session div.meet-subscriber:nth-child(2) svg.user'); // guest1
+
+ $guest1->waitFor('@session div.meet-subscriber.self svg.moderator')
+ ->assertMissing('@session div.meet-subscriber.self svg.user') // self
+ ->assertVisible('@session div.meet-video svg.user') // guest2
+ ->assertMissing('@session div.meet-video svg.moderator') // guest2
+ ->assertMissing('@session div.meet-subscriber:not(.self) svg.user') // owner
+ ->assertVisible('@session div.meet-subscriber:not(.self) svg.moderator') // owner
+ ->click('@session div.meet-subscriber:not(.self) .meet-nickname') // owner
+ ->assertMissing('@session div.meet-subscriber:not(.self) .dropdown-menu')
+ ->click('@session div.meet-subscriber.self .meet-nickname')
+ ->whenAvailable('@session div.meet-subscriber.self .dropdown-menu', function (Browser $browser) {
+ $browser->assertChecked('.action-role-moderator input')
+ ->assertEnabled('.action-role-moderator input')
+ ->assertNotChecked('.action-role-publisher input')
+ ->assertEnabled('.action-role-publisher input');
+ });
+
+ $browser->waitFor('@session div.meet-subscriber:not(.self) svg.moderator')
+ ->assertMissing('@session div.meet-subscriber:not(.self) svg.user');
+
+ // Check if a moderator can unpublish another user
+ $guest1->click('@session div.meet-video .meet-nickname')
+ ->whenAvailable('@session div.meet-video .dropdown-menu', function (Browser $browser) {
+ $browser->assertNotChecked('.action-role-moderator input')
+ ->assertEnabled('.action-role-moderator input')
+ ->assertChecked('.action-role-publisher input')
+ ->assertEnabled('.action-role-publisher input')
+ ->click('.action-role-publisher input');
+ })
+ ->waitUntilMissing('@session div.meet-video');
+
+ $guest2->waitUntilMissing('@session div.meet-video');
+
+ // Demote guest1 back to a normal user
+ $browser->waitFor('@session div.meet-subscriber:nth-child(3)')
+ ->click('@session') // somehow needed to make the next line invoke the menu
+ ->click('@session div.meet-subscriber:nth-child(2) .meet-nickname')
+ ->whenAvailable('@session div.meet-subscriber:nth-child(2) .dropdown-menu', function ($browser) {
+ $browser->assertChecked('.action-role-moderator input')
+ ->click('.action-role-moderator input');
+ })
+ ->waitFor('@session div.meet-subscriber:nth-child(2) svg.user')
+ ->assertMissing('@session div.meet-subscriber:nth-child(2) svg.moderator');
+
+ $guest1->waitFor('@session div.meet-subscriber.self svg.user')
+ ->assertMissing('@session div.meet-subscriber.self svg.moderator')
+ ->click('@session div.meet-subscriber.self .meet-nickname')
+ ->whenAvailable('@session .dropdown-menu', function (Browser $browser) {
+ $browser->assertMissing('.permissions');
+ });
+ });
+ }
+}
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
@@ -74,7 +74,7 @@
$room->save();
}
- $this->assignMeetEntitlement('john@kolab.org', 'meet');
+ $this->assignMeetEntitlement('john@kolab.org');
$this->browse(function (Browser $browser) {
$browser->visit(new RoomPage('john'))
@@ -130,7 +130,7 @@
*/
public function testTwoUsersInARoom(): void
{
- $this->assignMeetEntitlement('john@kolab.org', 'meet');
+ $this->assignMeetEntitlement('john@kolab.org');
$this->browse(function (Browser $browser, Browser $guest) {
// In one browser window act as a guest
@@ -234,6 +234,7 @@
->assertVisible('.meet-nickname')
->assertVisible('.controls button.link-fullscreen')
->assertVisible('.controls button.link-audio')
+ ->assertMissing('.controls button.link-setup')
->assertVisible('.status .status-audio')
->assertMissing('.status .status-video');
})
@@ -292,7 +293,7 @@
*/
public function testSubscribers(): void
{
- $this->assignMeetEntitlement('john@kolab.org', 'meet');
+ $this->assignMeetEntitlement('john@kolab.org');
$this->browse(function (Browser $browser, Browser $guest) {
// Join the room as the owner
@@ -370,4 +371,213 @@
$browser->waitUntilMissing('@session .meet-subscriber:not(.self)');
});
}
+
+ /**
+ * Test demoting publisher to a subscriber
+ *
+ * @group openvidu
+ * @depends testSubscribers
+ */
+ public function testDemoteToSubscriber(): void
+ {
+ $this->assignMeetEntitlement('john@kolab.org');
+
+ $this->browse(function (Browser $browser, Browser $guest1, Browser $guest2) {
+ // 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
+ $guest1->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);
+
+ $guest1
+ ->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);
+
+ // Join as another user to make sure the role change is propagated to new connections
+ $guest2->visit(new RoomPage('john'))
+ ->waitUntilMissing('@setup-status-message', 10)
+ ->assertSeeIn('@setup-button', "JOIN")
+ ->select('@setup-mic-select', '')
+ ->select('@setup-cam-select', '')
+ ->clickWhenEnabled('@setup-button')
+ ->waitFor('@session')
+ ->assertMissing('@setup-form')
+ ->waitFor('div.meet-subscriber:not(.self)')
+ ->assertElementsCount('@session div.meet-video', 1)
+ ->assertElementsCount('@session video', 1)
+ ->assertElementsCount('@session div.meet-subscriber', 2)
+ ->click('@toolbar .link-logout');
+
+ // 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) video')
+ ->assertElementsCount('@session div.meet-video', 2)
+ ->assertElementsCount('@session video', 2)
+ ->assertElementsCount('@session div.meet-subscriber', 0);
+
+ $guest1
+ ->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);
+ });
+ }
+
+ /**
+ * Test the media setup dialog
+ *
+ * @group openvidu
+ * @depends testDemoteToSubscriber
+ */
+ public function testMediaSetupDialog(): 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->assignMeetEntitlement('john@kolab.org');
+
+ $this->browse(function (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');
+
+ // In one browser window act as a guest
+ $guest->visit(new RoomPage('john'))
+ ->waitUntilMissing('@setup-status-message', 10)
+ ->assertSeeIn('@setup-button', "JOIN")
+ ->select('@setup-mic-select', '')
+ ->select('@setup-cam-select', '')
+ ->clickWhenEnabled('@setup-button')
+ ->waitFor('@session')
+ ->assertMissing('@setup-form');
+
+ $browser->waitFor('@session video')
+ ->click('.controls button.link-setup')
+ ->with(new Dialog('#media-setup-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Media setup')
+ ->assertVisible('form video')
+ ->assertVisible('form > div:nth-child(1) video')
+ ->assertVisible('form > div:nth-child(1) .volume')
+ ->assertVisible('form > div:nth-child(2) svg')
+ ->assertAttribute('form > div:nth-child(2) .input-group-text', 'title', 'Microphone')
+ ->assertVisible('form > div:nth-child(2) select')
+ ->assertVisible('form > div:nth-child(3) svg')
+ ->assertAttribute('form > div:nth-child(3) .input-group-text', 'title', 'Camera')
+ ->assertVisible('form > div:nth-child(3) select')
+ ->assertMissing('@button-cancel')
+ ->assertSeeIn('@button-action', 'Close')
+ ->click('@button-action');
+ })
+ ->assertMissing('#media-setup-dialog')
+ // Test mute audio and video
+ ->click('.controls button.link-setup')
+ ->with(new Dialog('#media-setup-dialog'), function (Browser $browser) {
+ $browser->select('form > div:nth-child(2) select', '')
+ ->select('form > div:nth-child(3) select', '')
+ ->click('@button-action');
+ })
+ ->assertMissing('#media-setup-dialog')
+ ->assertVisible('@session .meet-video .status .status-audio')
+ ->assertVisible('@session .meet-video .status .status-video');
+
+ $guest->waitFor('@session video')
+ ->assertVisible('@session .meet-video .status .status-audio')
+ ->assertVisible('@session .meet-video .status .status-video');
+ });
+ }
}
diff --git a/src/tests/Browser/Meet/RoomsTest.php b/src/tests/Browser/Meet/RoomsTest.php
--- a/src/tests/Browser/Meet/RoomsTest.php
+++ b/src/tests/Browser/Meet/RoomsTest.php
@@ -72,7 +72,7 @@
// Goto user subscriptions, and enable 'meet' subscription
$browser->visit('/user/' . $john->id)
->on(new UserInfo())
- ->with('@skus', function ($browser) {
+ ->whenAvailable('@skus', function ($browser) {
$browser->click('#sku-input-meet');
})
->click('button[type=submit]')
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
@@ -64,9 +64,9 @@
'@setup-cam-select' => '#setup-camera',
'@setup-nickname-input' => '#setup-nickname',
'@setup-password-input' => '#setup-password',
- '@setup-preview' => '#setup-preview',
- '@setup-volume' => '#setup-preview .volume',
- '@setup-video' => '#setup-preview video',
+ '@setup-preview' => '#meet-setup .media-setup-preview',
+ '@setup-volume' => '#meet-setup .media-setup-preview .volume',
+ '@setup-video' => '#meet-setup .media-setup-preview video',
'@setup-status-message' => '#meet-setup div.status-message',
'@setup-button' => '#join-button',
@@ -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;
@@ -118,7 +119,6 @@
$this->assertSame($session_id, $json['session']);
$this->assertTrue(is_string($session_id) && !empty($session_id));
$this->assertTrue(strpos($json['token'], 'wss://') === 0);
- $this->assertTrue(!array_key_exists('shareToken', $json));
$john_token = $json['token'];
@@ -130,7 +130,6 @@
$this->assertSame(322, $json['code']);
$this->assertTrue(empty($json['token']));
- $this->assertTrue(empty($json['shareToken']));
// Non-owner, now the session exists, with 'init', but no 'canPublish' argument
$response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
@@ -142,7 +141,6 @@
$this->assertSame($session_id, $json['session']);
$this->assertTrue(strpos($json['token'], 'wss://') === 0);
$this->assertTrue($json['token'] != $john_token);
- $this->assertTrue(empty($json['shareToken']));
// Non-owner, now the session exists, with 'init', and with 'role=PUBLISHER'
$post = ['canPublish' => true, 'init' => 1];
@@ -155,7 +153,6 @@
$this->assertSame($session_id, $json['session']);
$this->assertTrue(strpos($json['token'], 'wss://') === 0);
$this->assertTrue($json['token'] != $john_token);
- $this->assertTrue(!array_key_exists('shareToken', $json));
$this->assertEmpty($json['config']['password']);
$this->assertEmpty($json['config']['requires_password']);
@@ -331,6 +328,7 @@
$this->assertTrue(strpos($json['token'], 'wss://') === 0);
// TODO: Test a scenario where both password and lock are enabled
+ // TODO: Test accepting/denying as a non-owner moderator
}
/**
@@ -357,8 +355,6 @@
$this->assertSame(Room::ROLE_PUBLISHER, $json['role']);
$this->assertSame($room->session_id, $json['session']);
$this->assertTrue(strpos($json['token'], 'wss://') === 0);
- $this->assertTrue(strpos($json['shareToken'], 'wss://') === 0);
- $this->assertTrue($json['shareToken'] != $json['token']);
}
/**
@@ -414,6 +410,76 @@
$this->assertCount(2, $json);
}
+ /**
+ * Test creating an extra connection for screen sharing
+ *
+ * @group openvidu
+ */
+ public function testCreateConnection(): 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->assignMeetEntitlement($john);
+
+ // First we create the session
+ $post = ['init' => 1, 'canPublish' => 1];
+ $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $owner_auth_token = $json['authToken'];
+
+ // 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'];
+ $auth_token = $json['authToken'];
+
+ // Non-existing room name
+ $response = $this->post("api/v4/openvidu/rooms/non-existing/connections", []);
+ $response->assertStatus(404);
+
+ // No connection token provided
+ $response = $this->post("api/v4/openvidu/rooms/{$room->name}/connections", []);
+ $response->assertStatus(403);
+
+ // Invalid token
+ $response = $this->actingAs($jack)
+ ->withHeaders([OpenViduController::AUTH_HEADER => '123'])
+ ->post("api/v4/openvidu/rooms/{$room->name}/connections", []);
+
+ $response->assertStatus(403);
+
+ // Subscriber can't get the screen-sharing connection
+ // Note: We're acting as Jack because there's no easy way to unset the 'actingAs' user
+ // throughout the test
+ $response = $this->actingAs($jack)
+ ->withHeaders([OpenViduController::AUTH_HEADER => $auth_token])
+ ->post("api/v4/openvidu/rooms/{$room->name}/connections", []);
+
+ $response->assertStatus(403);
+
+ // Publisher can get the connection
+ $response = $this->actingAs($jack)
+ ->withHeaders([OpenViduController::AUTH_HEADER => $owner_auth_token])
+ ->post("api/v4/openvidu/rooms/{$room->name}/connections", []);
+
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertTrue(strpos($json['token'], 'wss://') === 0);
+ $this->assertTrue(strpos($json['token'], 'role=PUBLISHER') !== false);
+ }
+
/**
* Test dismissing a participant (closing a connection)
*
@@ -475,6 +541,20 @@
$this->assertSame('success', $json['status']);
$this->assertNull($room->getOVConnection($conn_id));
+
+ // Test acting as a moderator
+ $response = $this->actingAs($jack)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
+ $response->assertStatus(200);
+ $json = $response->json();
+ $conn_id = $json['connectionId'];
+
+ // Note: We're acting as Jack because there's no easy way to unset a 'actingAs' user
+ // throughout the test
+ $response = $this->actingAs($jack)
+ ->withHeaders([OpenViduController::AUTH_HEADER => $this->getModeratorToken($room)])
+ ->post("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}/dismiss");
+
+ $response->assertStatus(200);
}
/**
@@ -554,4 +634,123 @@
$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->assignMeetEntitlement($john);
+
+ // First we create the session
+ $response = $this->actingAs($john)->post("api/v4/openvidu/rooms/{$room->name}", ['init' => 1]);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+ $owner_conn_id = $json['connectionId'];
+
+ // 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'];
+ $auth_token = $json['authToken'];
+ $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);
+
+ // Access as moderator
+ // Note: We're acting as Jack because there's no easy way to unset a 'actingAs' user
+ // throughout the test
+ $token = $this->getModeratorToken($room);
+ $post = ['role' => Room::ROLE_PUBLISHER];
+ $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token])
+ ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
+ $response->assertStatus(200);
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame($post['role'], Connection::find($conn_id)->role);
+
+ // Assert that it's not possible to add/remove the 'owner' role
+ $post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_OWNER];
+ $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token])
+ ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
+
+ $response->assertStatus(403);
+
+ $post = ['role' => Room::ROLE_PUBLISHER];
+ $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token])
+ ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$owner_conn_id}", $post);
+
+ $response->assertStatus(403);
+
+ // Assert that removing a 'moderator' role from the owner is not possible
+ $post = ['role' => Room::ROLE_PUBLISHER | Room::ROLE_OWNER];
+ $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $token])
+ ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$owner_conn_id}", $post);
+
+ $response->assertStatus(200);
+
+ $this->assertSame($post['role'] | Room::ROLE_MODERATOR, Connection::find($owner_conn_id)->role);
+
+ // Assert that non-moderator token does not allow access
+ $post = ['role' => Room::ROLE_SUBSCRIBER];
+ $response = $this->actingAs($jack)->withHeaders([OpenViduController::AUTH_HEADER => $auth_token])
+ ->put("api/v4/openvidu/rooms/{$room->name}/connections/{$conn_id}", $post);
+
+ $response->assertStatus(403);
+ }
+
+ /**
+ * Create a moderator connection to the room session.
+ *
+ * @param \App\OpenVidu\Room $room The room
+ *
+ * @return string The connection authentication token
+ */
+ private function getModeratorToken(Room $room): string
+ {
+ $result = $room->getSessionToken(Room::ROLE_MODERATOR);
+
+ return $result['authToken'];
+ }
}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 12:15 AM (17 h, 8 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18827392
Default Alt Text
D2197.1775261724.diff (94 KB)
Attached To
Mode
D2197: Improve handling of screen-sharing connection tokens
Attached
Detach File
Event Timeline