Merge branch 'develop' into origin/jdk-17-upgrade

This commit is contained in:
root 2021-12-03 14:04:02 +00:00
commit ce7362380b
71 changed files with 1661 additions and 1023 deletions

View File

@ -17,6 +17,27 @@ object BreakoutHdlrHelpers extends SystemConfiguration {
roomSequence: String,
breakoutId: String
) {
for {
(redirectToHtml5JoinURL, redirectJoinURL) <- getRedirectUrls(liveMeeting, userId, externalMeetingId, roomSequence)
} yield {
sendJoinURLMsg(
outGW,
liveMeeting.props.meetingProp.intId,
breakoutId,
externalMeetingId,
userId,
redirectJoinURL,
redirectToHtml5JoinURL
)
}
}
def getRedirectUrls(
liveMeeting: LiveMeeting,
userId: String,
externalMeetingId: String,
roomSequence: String
): Option[(String, String)] = {
for {
user <- Users2x.findWithIntId(liveMeeting.users2x, userId)
apiCall = "join"
@ -31,15 +52,7 @@ object BreakoutHdlrHelpers extends SystemConfiguration {
redirectToHtml5JoinURL = BreakoutRoomsUtil.createJoinURL(bbbWebAPI, apiCall, redirectToHtml5BaseString,
BreakoutRoomsUtil.calculateChecksum(apiCall, redirectToHtml5BaseString, bbbWebSharedSecret))
} yield {
sendJoinURLMsg(
outGW,
liveMeeting.props.meetingProp.intId,
breakoutId,
externalMeetingId,
userId,
redirectJoinURL,
redirectToHtml5JoinURL
)
(redirectToHtml5JoinURL, redirectJoinURL)
}
}

View File

@ -25,7 +25,6 @@ trait BreakoutRoomCreatedMsgHdlr {
if (updatedModel.hasAllStarted()) {
updatedModel = updatedModel.copy(startedOn = Some(System.currentTimeMillis()))
updatedModel = sendBreakoutRoomsList(updatedModel)
updatedModel = sendBreakoutInvitations(updatedModel)
}
updatedModel
}
@ -36,25 +35,6 @@ trait BreakoutRoomCreatedMsgHdlr {
}
}
def sendBreakoutInvitations(breakoutModel: BreakoutModel): BreakoutModel = {
log.debug("Sending breakout invitations")
breakoutModel.rooms.values.foreach { room =>
log.debug("Sending invitations for room {} with num users {}", room.name, room.assignedUsers.toVector.length)
room.assignedUsers.foreach { user =>
BreakoutHdlrHelpers.sendJoinURL(
liveMeeting,
outGW,
user,
room.externalId,
room.sequence.toString(),
room.id
)
}
}
breakoutModel
}
def buildBreakoutRoomsListEvtMsg(meetingId: String, rooms: Vector[BreakoutRoomInfo], roomsReady: Boolean): BbbCommonEnvCoreMsg = {
val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, meetingId, "not-used")
val envelope = BbbCoreEnvelope(BreakoutRoomsListEvtMsg.NAME, routing)
@ -63,12 +43,16 @@ trait BreakoutRoomCreatedMsgHdlr {
val body = BreakoutRoomsListEvtMsgBody(meetingId, rooms, roomsReady)
val event = BreakoutRoomsListEvtMsg(header, body)
BbbCommonEnvCoreMsg(envelope, event)
}
def sendBreakoutRoomsList(breakoutModel: BreakoutModel): BreakoutModel = {
val breakoutRooms = breakoutModel.rooms.values.toVector map { r =>
new BreakoutRoomInfo(r.name, r.externalId, r.id, r.sequence, r.shortName, r.isDefaultName, r.freeJoin)
val html5JoinUrls = for {
user <- r.assignedUsers
(redirectToHtml5JoinURL, redirectJoinURL) <- BreakoutHdlrHelpers.getRedirectUrls(liveMeeting, user, r.externalId, r.sequence.toString())
} yield (user -> redirectToHtml5JoinURL)
new BreakoutRoomInfo(r.name, r.externalId, r.id, r.sequence, r.shortName, r.isDefaultName, r.freeJoin, html5JoinUrls.toMap)
}
log.info("Sending breakout rooms list to {} with containing {} room(s)", liveMeeting.props.meetingProp.intId, breakoutRooms.length)
@ -95,7 +79,7 @@ trait BreakoutRoomCreatedMsgHdlr {
BbbCommonEnvCoreMsg(envelope, event)
}
val breakoutInfo = BreakoutRoomInfo(room.name, room.externalId, room.id, room.sequence, room.shortName, room.isDefaultName, room.freeJoin)
val breakoutInfo = BreakoutRoomInfo(room.name, room.externalId, room.id, room.sequence, room.shortName, room.isDefaultName, room.freeJoin, Map())
val event = build(liveMeeting.props.meetingProp.intId, breakoutInfo)
outGW.send(event)

View File

@ -3,7 +3,7 @@ package org.bigbluebutton.core.apps.breakout
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.api.BreakoutRoomUsersUpdateInternalMsg
import org.bigbluebutton.core.domain.{ BreakoutRoom2x, MeetingState2x }
import org.bigbluebutton.core.models.Users2x
import org.bigbluebutton.core.models.{ RegisteredUsers, Users2x }
import org.bigbluebutton.core.running.{ MeetingActor, OutMsgRouter }
trait BreakoutRoomUsersUpdateMsgHdlr {
@ -38,6 +38,16 @@ trait BreakoutRoomUsersUpdateMsgHdlr {
user <- Users2x.findWithBreakoutRoomId(liveMeeting.users2x, breakoutRoomUser.id)
} yield Users2x.updateLastUserActivity(liveMeeting.users2x, user)
//Update lastBreakout in registeredUsers to avoid lose this info when the user leaves
for {
breakoutRoomUser <- updatedRoom.users
u <- RegisteredUsers.findWithBreakoutRoomId(breakoutRoomUser.id, liveMeeting.registeredUsers)
} yield {
if (room != null && (u.lastBreakoutRoom == null || u.lastBreakoutRoom.id != room.id)) {
RegisteredUsers.updateUserLastBreakoutRoom(liveMeeting.registeredUsers, u, room)
}
}
model.update(updatedRoom)
}

View File

@ -28,7 +28,7 @@ trait BreakoutRoomsListMsgHdlr {
breakoutModel <- state.breakout
} yield {
val rooms = breakoutModel.rooms.values.toVector map { r =>
new BreakoutRoomInfo(r.name, r.externalId, r.id, r.sequence, r.shortName, r.isDefaultName, r.freeJoin)
new BreakoutRoomInfo(r.name, r.externalId, r.id, r.sequence, r.shortName, r.isDefaultName, r.freeJoin, Map())
}
val ready = breakoutModel.hasAllStarted()
broadcastEvent(rooms, ready)

View File

@ -12,13 +12,22 @@ case class MeetingState2x(
groupChats: GroupChats,
presentationPodManager: PresentationPodManager,
breakout: Option[BreakoutModel],
lastBreakout: Option[BreakoutModel],
expiryTracker: MeetingExpiryTracker,
recordingTracker: MeetingRecordingTracker
) {
def update(groupChats: GroupChats): MeetingState2x = copy(groupChats = groupChats)
def update(presPodManager: PresentationPodManager): MeetingState2x = copy(presentationPodManager = presPodManager)
def update(breakout: Option[BreakoutModel]): MeetingState2x = copy(breakout = breakout)
def update(breakout: Option[BreakoutModel]): MeetingState2x = {
breakout match {
case Some(b) => {
if (b.hasAllStarted()) copy(breakout = breakout, lastBreakout = breakout)
else copy(breakout = breakout)
}
case None => copy(breakout = breakout)
}
}
def update(expiry: MeetingExpiryTracker): MeetingState2x = copy(expiryTracker = expiry)
def update(recordingTracker: MeetingRecordingTracker): MeetingState2x = copy(recordingTracker = recordingTracker)
}

View File

@ -1,6 +1,7 @@
package org.bigbluebutton.core.models
import com.softwaremill.quicklens._
import org.bigbluebutton.core.domain.BreakoutRoom2x
object RegisteredUsers {
def create(userId: String, extId: String, name: String, roles: String,
@ -45,6 +46,13 @@ object RegisteredUsers {
users.toVector.filter(u => u.joined == false && u.markAsJoinTimedOut == false)
}
def findWithBreakoutRoomId(breakoutRoomId: String, users: RegisteredUsers): Vector[RegisteredUser] = {
//userId + "-" + roomSequence
val userIdParts = breakoutRoomId.split("-")
val userExtId = userIdParts(0)
users.toVector.filter(ru => userExtId == ru.externId)
}
def getRegisteredUserWithToken(token: String, userId: String, regUsers: RegisteredUsers): Option[RegisteredUser] = {
def isSameUserId(ru: RegisteredUser, userId: String): Option[RegisteredUser] = {
if (userId.startsWith(ru.id)) {
@ -122,6 +130,13 @@ object RegisteredUsers {
u
}
def updateUserLastBreakoutRoom(users: RegisteredUsers, user: RegisteredUser,
lastBreakoutRoom: BreakoutRoom2x): RegisteredUser = {
val u = user.modify(_.lastBreakoutRoom).setTo(lastBreakoutRoom)
users.save(u)
u
}
def updateUserJoin(users: RegisteredUsers, user: RegisteredUser): RegisteredUser = {
val u = user.copy(joined = true)
users.save(u)
@ -182,6 +197,7 @@ case class RegisteredUser(
joined: Boolean,
markAsJoinTimedOut: Boolean,
banned: Boolean,
loggedOut: Boolean
loggedOut: Boolean,
lastBreakoutRoom: BreakoutRoom2x = null
)

View File

@ -162,6 +162,7 @@ class MeetingActor(
new GroupChats(Map.empty),
new PresentationPodManager(Map.empty),
None,
None,
expiryTracker,
recordingTracker
)

View File

@ -101,8 +101,6 @@ class FromAkkaAppsMsgSenderActor(msgSender: MessageSender)
msgSender.send(fromAkkaAppsPresRedisChannel, json)
case BreakoutRoomsListEvtMsg.NAME =>
msgSender.send(fromAkkaAppsPresRedisChannel, json)
case BreakoutRoomJoinURLEvtMsg.NAME =>
msgSender.send(fromAkkaAppsPresRedisChannel, json)
case BreakoutRoomsTimeRemainingUpdateEvtMsg.NAME =>
msgSender.send(fromAkkaAppsPresRedisChannel, json)
case BreakoutRoomStartedEvtMsg.NAME =>

View File

@ -1,6 +1,7 @@
package org.bigbluebutton.endpoint.redis
import akka.actor.{Actor, ActorLogging, ActorSystem, Props}
import org.bigbluebutton.common2.domain.PresentationVO
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.common2.util.JsonUtil
import org.bigbluebutton.core.OutMessageGateway
@ -23,6 +24,7 @@ case class Meeting(
users: Map[String, User] = Map(),
polls: Map[String, Poll] = Map(),
screenshares: Vector[Screenshare] = Vector(),
presentationSlides: Vector[PresentationSlide] = Vector(),
createdOn: Long = System.currentTimeMillis(),
endedOn: Long = 0,
)
@ -72,6 +74,12 @@ case class Screenshare(
stoppedOn: Long = 0,
)
case class PresentationSlide(
presentationId: String,
pageNum: Long,
setOn: Long = System.currentTimeMillis(),
)
object LearningDashboardActor {
def props(
@ -92,6 +100,7 @@ class LearningDashboardActor(
private var meetings: Map[String, Meeting] = Map()
private var meetingsLastJsonHash : Map[String,String] = Map()
private var meetingPresentations : Map[String,Map[String,PresentationVO]] = Map()
system.scheduler.schedule(10.seconds, 10.seconds, self, SendPeriodicReport)
@ -108,6 +117,12 @@ class LearningDashboardActor(
// Chat
case m: GroupChatMessageBroadcastEvtMsg => handleGroupChatMessageBroadcastEvtMsg(m)
// Presentation
case m: PresentationConversionCompletedEvtMsg => handlePresentationConversionCompletedEvtMsg(m)
case m: SetCurrentPageEvtMsg => handleSetCurrentPageEvtMsg(m)
case m: RemovePresentationEvtMsg => handleRemovePresentationEvtMsg(m)
case m: SetCurrentPresentationEvtMsg => handleSetCurrentPresentationEvtMsg(m)
// User
case m: UserJoinedMeetingEvtMsg => handleUserJoinedMeetingEvtMsg(m)
case m: UserLeftMeetingEvtMsg => handleUserLeftMeetingEvtMsg(m)
@ -151,6 +166,77 @@ class LearningDashboardActor(
}
}
private def handlePresentationConversionCompletedEvtMsg(msg: PresentationConversionCompletedEvtMsg) {
for {
meeting <- meetings.values.find(m => m.intId == msg.header.meetingId)
} yield {
val updatedPresentations = meetingPresentations.get(meeting.intId).getOrElse(Map()) + (msg.body.presentation.id -> msg.body.presentation)
meetingPresentations += (meeting.intId -> updatedPresentations)
if(msg.body.presentation.current == true) {
for {
page <- msg.body.presentation.pages.find(p => p.current == true)
} yield {
this.setPresentationSlide(meeting.intId, msg.body.presentation.id,page.num)
}
}
}
}
private def handleSetCurrentPageEvtMsg(msg: SetCurrentPageEvtMsg) {
for {
meeting <- meetings.values.find(m => m.intId == msg.header.meetingId)
presentations <- meetingPresentations.get(meeting.intId)
presentation <- presentations.get(msg.body.presentationId)
page <- presentation.pages.find(p => p.id == msg.body.pageId)
} yield {
this.setPresentationSlide(meeting.intId, msg.body.presentationId,page.num)
}
}
private def handleRemovePresentationEvtMsg(msg: RemovePresentationEvtMsg) {
for {
meeting <- meetings.values.find(m => m.intId == msg.header.meetingId)
} yield {
if(meeting.presentationSlides.last.presentationId == msg.body.presentationId) {
this.setPresentationSlide(meeting.intId, "",0)
}
}
}
private def handleSetCurrentPresentationEvtMsg(msg: SetCurrentPresentationEvtMsg) {
for {
meeting <- meetings.values.find(m => m.intId == msg.header.meetingId)
} yield {
val presPreviousSlides: Vector[PresentationSlide] = meeting.presentationSlides.filter(p => p.presentationId == msg.body.presentationId);
if(presPreviousSlides.length > 0) {
//Set last page showed for this presentation
this.setPresentationSlide(meeting.intId, msg.body.presentationId,presPreviousSlides.last.pageNum)
} else {
//If none page was showed yet, set the current page (page 1 by default)
for {
presentations <- meetingPresentations.get(meeting.intId)
presentation <- presentations.get(msg.body.presentationId)
page <- presentation.pages.find(s => s.current == true)
} yield {
this.setPresentationSlide(meeting.intId, msg.body.presentationId,page.num)
}
}
}
}
private def setPresentationSlide(meetingId: String, presentationId: String, pageNum: Long) {
for {
meeting <- meetings.values.find(m => m.intId == meetingId)
} yield {
if (meeting.presentationSlides.length == 0 ||
meeting.presentationSlides.last.presentationId != presentationId ||
meeting.presentationSlides.last.pageNum != pageNum) {
val updatedMeeting = meeting.copy(presentationSlides = meeting.presentationSlides :+ PresentationSlide(presentationId, pageNum))
meetings += (updatedMeeting.intId -> updatedMeeting)
}
}
}
private def handleUserJoinedMeetingEvtMsg(msg: UserJoinedMeetingEvtMsg): Unit = {
for {
meeting <- meetings.values.find(m => m.intId == msg.header.meetingId)
@ -403,6 +489,7 @@ class LearningDashboardActor(
//Send report one last time
sendReport(updatedMeeting)
meetingPresentations = meetingPresentations.-(updatedMeeting.intId)
meetings = meetings.-(updatedMeeting.intId)
log.info(" removed for meeting {}.",updatedMeeting.intId)
}
@ -426,7 +513,7 @@ class LearningDashboardActor(
meetingsLastJsonHash += (meeting.intId -> activityJsonHash)
log.info("Activity Report sent for meeting {}",meeting.intId)
log.info("Learning Dashboard data sent for meeting {}",meeting.intId)
}
}

View File

@ -1,243 +0,0 @@
<!--
BigBlueButton - http://www.bigbluebutton.org
Copyright (c) 2008-2018 by respective authors (see below). All rights reserved.
BigBlueButton is free software; you can redistribute it and/or modify it under the
terms of the GNU Lesser General Public License as published by the Free Software
Foundation; either version 3 of the License, or (at your option) any later
version.
BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License along
with BigBlueButton; if not, If not, see <http://www.gnu.org/licenses/>.
Authors: James Jung
Anton Georgiev
-->
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Join Meeting via HTML5 Client (API)</title>
<style>
#controls {
width:50%;
height:200px;
float:left;
}
#client {
width:100%;
height:700px;
float:left;
}
#client-content {
width:100%;
height:100%;
}
</style>
</head>
<body>
<p>You must have the BigBlueButton HTML5 client installed to use this API demo.</p>
<%@ include file="bbb_api.jsp"%>
<%
if (request.getParameterMap().isEmpty()) {
//
// Assume we want to create a meeting
//
%>
<%@ include file="demo_header.jsp"%>
<h2>Join Meeting via HTML5 Client (API)</h2>
<FORM NAME="form1" METHOD="GET">
<table cellpadding="5" cellspacing="5" style="width: 400px; ">
<tbody>
<tr>
<td>&nbsp;</td>
<td style="text-align: right; ">Full Name:</td>
<td style="width: 5px; ">&nbsp;</td>
<td style="text-align: left "><input type="text" autofocus required name="username" /></td>
</tr>
<tr>
<td>&nbsp;</td>
<td style="text-align: right; ">Meeting Name:</td>
<td style="width: 5px; ">&nbsp;</td>
<td style="text-align: left "><input type="text" required name="meetingname" value="Demo Meeting" /></td>
<tr>
<tr>
<td>&nbsp;</td>
<td style="text-align: right; ">Moderator Role:</td>
<td style="width: 5px; ">&nbsp;</td>
<td style="text-align: left "><input type=checkbox name=isModerator value="true" checked></td>
<tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
<td>&nbsp;</td>
<td><input type="submit" value="Join" /></td>
<tr>
</tbody>
</table>
<INPUT TYPE=hidden NAME=action VALUE="create">
</FORM>
<%
} else if (request.getParameter("action").equals("create")) {
String username = request.getParameter("username");
// set defaults and overwrite them if custom values exist
String meetingname = "Demo Meeting";
if (request.getParameter("meetingname") != null) {
meetingname = request.getParameter("meetingname");
}
Boolean isModerator = new Boolean(false);
Boolean isHTML5 = new Boolean(true);
Boolean isRecorded = new Boolean(true);
if (request.getParameter("isModerator") != null) {
isModerator = Boolean.parseBoolean(request.getParameter("isModerator"));
}
String joinURL = getJoinURLExtended(username, meetingname, isRecorded.toString(), null, null, null, isHTML5.toString(), isModerator.toString());
if (joinURL.startsWith("http://") || joinURL.startsWith("https://")) {
%>
<script language="javascript" type="text/javascript">
const recButton = document.createElement('button');
recButton.id = 'recButton';
const muteButton = document.createElement('button');
muteButton.id = 'muteButton';
function getInitialState() {
document.getElementById('client-content').contentWindow.postMessage('c_recording_status', '*');
document.getElementById('client-content').contentWindow.postMessage('get_audio_joined_status', '*');
}
function handleMessage(e) {
switch (e) {
case 'readyToConnect': {
// get initial state
getInitialState(); break; }
case 'recordingStarted': {
recButton.innerHTML = 'Stop Recording';
break;
}
case 'recordingStopped': {
recButton.innerHTML = 'Start Recording';
break;
}
case 'selfMuted': {
muteButton.innerHTML = 'Unmute me';
break;
}
case 'selfUnmuted': {
muteButton.innerHTML = 'Mute me';
break;
}
case 'notInAudio': {
muteButton.innerHTML = 'Not in audio';
document.getElementById('muteButton').disabled = true;
break;
}
case 'joinedAudio': {
muteButton.innerHTML = '';
document.getElementById('muteButton').disabled = false;
document.getElementById('client-content').contentWindow.postMessage('c_mute_status', '*');
break;
}
default: console.log('neither', { e });
}
}
// EventListener(Getting message from iframe)
window.addEventListener('message', function(e) {
handleMessage(e.data.response);
});
// Clean up the body node before loading controls and the client
document.body.innerHTML = '';
// Node for the Client
const client = document.createElement('div');
client.setAttribute('id', 'client');
const clientContent = document.createElement('iframe');
clientContent.setAttribute('id', 'client-content');
clientContent.setAttribute('src','<%=joinURL%>');
// // in case your iframe is on a different domain MYDOMAIN.com
// clientContent.setAttribute('src','https://MYDOMAIN.com/demo/demoHTML5.jsp');
// to enable microphone or camera use allow your iframe domain explicitly
// clientContent.setAttribute('allow','microphone https://MYDOMAIN.com; camera https://MYDOMAIN.com');
client.appendChild(clientContent);
// Node for the Controls
const controls = document.createElement('div');
controls.setAttribute('id', 'controls');
controls.setAttribute('align', 'middle');
controls.setAttribute('float', 'left');
// ****************** Controls *****************************/
function recToggle(){
document.getElementById("client-content").contentWindow.postMessage('c_record', '*');
}
function muteToggle(){
document.getElementById("client-content").contentWindow.postMessage('c_mute', '*');
}
// Node for the control which controls recording functionality of the html5Client
recButton.setAttribute('onClick', 'recToggle();');
controls.appendChild(recButton);
muteButton.setAttribute('onClick', 'muteToggle();');
controls.appendChild(muteButton);
// Append the nodes of contents to the body node
document.body.appendChild(controls);
document.body.appendChild(client);
</script>
<%
} else {
%>
Error: getJoinURL() failed
<p/>
<%=joinURL %>
<%
}
}
%>
<%@ include file="demo_footer.jsp"%>
</body>
</html>

View File

@ -50,6 +50,12 @@ case class SystemProps(
html5InstanceId: Int
)
case class GroupProps(
groupId: String,
name: String,
usersExtId: Vector[String]
)
case class DefaultProps(
meetingProp: MeetingProp,
breakoutProps: BreakoutProps,
@ -62,7 +68,8 @@ case class DefaultProps(
metadataProp: MetadataProp,
screenshareProps: ScreenshareProps,
lockSettingsProps: LockSettingsProps,
systemProps: SystemProps
systemProps: SystemProps,
groups: Vector[GroupProps]
)
case class StartEndTimeStatus(startTime: Long, endTime: Long)

View File

@ -13,7 +13,7 @@ case class BreakoutRoomJoinURLEvtMsgBody(parentId: String, breakoutId: String, e
object BreakoutRoomsListEvtMsg { val NAME = "BreakoutRoomsListEvtMsg" }
case class BreakoutRoomsListEvtMsg(header: BbbClientMsgHeader, body: BreakoutRoomsListEvtMsgBody) extends BbbCoreMsg
case class BreakoutRoomsListEvtMsgBody(meetingId: String, rooms: Vector[BreakoutRoomInfo], roomsReady: Boolean)
case class BreakoutRoomInfo(name: String, externalId: String, breakoutId: String, sequence: Int, shortName: String, isDefaultName: Boolean, freeJoin: Boolean)
case class BreakoutRoomInfo(name: String, externalId: String, breakoutId: String, sequence: Int, shortName: String, isDefaultName: Boolean, freeJoin: Boolean, html5JoinUrls: Map[String, String])
object BreakoutRoomsListMsg { val NAME = "BreakoutRoomsListMsg" }
case class BreakoutRoomsListMsg(header: BbbClientMsgHeader, body: BreakoutRoomsListMsgBody) extends StandardMsg
@ -81,14 +81,6 @@ object RequestBreakoutJoinURLReqMsg { val NAME = "RequestBreakoutJoinURLReqMsg"
case class RequestBreakoutJoinURLReqMsg(header: BbbClientMsgHeader, body: RequestBreakoutJoinURLReqMsgBody) extends StandardMsg
case class RequestBreakoutJoinURLReqMsgBody(meetingId: String, breakoutId: String, userId: String)
/**
* Response sent to client for a join url for a user.
*/
object RequestBreakoutJoinURLRespMsg { val NAME = "RequestBreakoutJoinURLRespMsg" }
case class RequestBreakoutJoinURLRespMsg(header: BbbClientMsgHeader, body: RequestBreakoutJoinURLRespMsgBody) extends BbbCoreMsg
case class RequestBreakoutJoinURLRespMsgBody(parentId: String, breakoutId: String,
userId: String, redirectJoinURL: String, redirectToHtml5JoinURL: String)
object TransferUserToMeetingEvtMsg { val NAME = "TransferUserToMeetingEvtMsg" }
case class TransferUserToMeetingEvtMsg(header: BbbClientMsgHeader, body: TransferUserToMeetingEvtMsgBody) extends BbbCoreMsg
case class TransferUserToMeetingEvtMsgBody(fromVoiceConf: String, toVoiceConf: String, userId: String)

View File

@ -61,6 +61,7 @@ public class ApiParams {
public static final String WELCOME = "welcome";
public static final String HTML5_INSTANCE_ID = "html5InstanceId";
public static final String ROLE = "role";
public static final String GROUPS = "groups";
public static final String BREAKOUT_ROOMS_ENABLED = "breakoutRoomsEnabled";
public static final String BREAKOUT_ROOMS_RECORD = "breakoutRoomsRecord";

View File

@ -418,7 +418,7 @@ public class MeetingService implements MessageListener {
m.getUserActivitySignResponseDelayInMinutes(), m.getEndWhenNoModerator(), m.getEndWhenNoModeratorDelayInMinutes(),
m.getMuteOnStart(), m.getAllowModsToUnmuteUsers(), m.getMeetingKeepEvents(),
m.breakoutRoomsParams,
m.lockSettingsParams, m.getHtml5InstanceId());
m.lockSettingsParams, m.getHtml5InstanceId(), m.getGroups());
}
private String formatPrettyDate(Long timestamp) {

View File

@ -29,6 +29,10 @@ import java.text.DecimalFormatSymbols;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.RandomStringUtils;
@ -42,6 +46,7 @@ import org.apache.http.util.EntityUtils;
import org.bigbluebutton.api.domain.BreakoutRoomsParams;
import org.bigbluebutton.api.domain.LockSettingsParams;
import org.bigbluebutton.api.domain.Meeting;
import org.bigbluebutton.api.domain.Group;
import org.bigbluebutton.api.util.ParamsUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -338,6 +343,43 @@ public class ParamsProcessorUtil {
lockSettingsLockOnJoinConfigurable);
}
private ArrayList<Group> processGroupsParams(Map<String, String> params) {
ArrayList<Group> groups = new ArrayList<Group>();
String groupsParam = params.get(ApiParams.GROUPS);
if (!StringUtils.isEmpty(groupsParam)) {
JsonElement groupParamsJson = new Gson().fromJson(groupsParam, JsonElement.class);
if(groupParamsJson != null && groupParamsJson.isJsonArray()) {
JsonArray groupsJson = groupParamsJson.getAsJsonArray();
for (JsonElement groupJson : groupsJson) {
if(groupJson.isJsonObject()) {
JsonObject groupJsonObj = groupJson.getAsJsonObject();
if(groupJsonObj.has("id")) {
String groupId = groupJsonObj.get("id").getAsString();
String groupName = "";
if(groupJsonObj.has("name")) {
groupName = groupJsonObj.get("name").getAsString();
}
Vector<String> groupUsers = new Vector<>();
if(groupJsonObj.has("roster") && groupJsonObj.get("roster").isJsonArray()) {
for (JsonElement jsonElementUser : groupJsonObj.get("roster").getAsJsonArray()) {
if(jsonElementUser.isJsonObject() && jsonElementUser.getAsJsonObject().has("id")) {
groupUsers.add(jsonElementUser.getAsJsonObject().get("id").getAsString());
}
}
}
groups.add(new Group(groupId,groupName,groupUsers));
}
}
}
}
}
return groups;
}
public Meeting processCreateParams(Map<String, String> params) {
String meetingName = params.get(ApiParams.NAME);
@ -496,6 +538,8 @@ public class ParamsProcessorUtil {
String meetingLayout = defaultMeetingLayout;
ArrayList<Group> groups = processGroupsParams(params);
if (!StringUtils.isEmpty(params.get(ApiParams.MEETING_LAYOUT))) {
meetingLayout = params.get(ApiParams.MEETING_LAYOUT);
}
@ -559,6 +603,7 @@ public class ParamsProcessorUtil {
.withLearningDashboardEnabled(learningDashboardEn)
.withLearningDashboardCleanupDelayInMinutes(learningDashboardCleanupMins)
.withLearningDashboardAccessToken(learningDashboardAccessToken)
.withGroups(groups)
.build();
if (!StringUtils.isEmpty(params.get(ApiParams.MODERATOR_ONLY_MESSAGE))) {

View File

@ -0,0 +1,42 @@
package org.bigbluebutton.api.domain;
import java.util.Vector;
public class Group {
private String groupId = "";
private String name = "";
private Vector<String> usersExtId;
public Group(String groupId,
String name,
Vector<String> usersExtId) {
this.groupId = groupId;
this.name = name;
this.usersExtId = usersExtId;
}
public String getGroupId() {
return groupId;
}
public void setGroupId(String groupId) {
this.groupId = groupId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Vector<String> getUsersExtId() {
return usersExtId;
}
public void setUsersExtId(Vector<String> usersExtId) {
this.usersExtId = usersExtId;
}
}

View File

@ -82,6 +82,7 @@ public class Meeting {
private final ConcurrentMap<String, Long> enteredUsers;
private final Boolean isBreakout;
private final List<String> breakoutRooms = new ArrayList<>();
private ArrayList<Group> groups = new ArrayList<Group>();
private String customLogoURL = "";
private String customCopyright = "";
private Boolean muteOnStart = false;
@ -135,13 +136,14 @@ public class Meeting {
isBreakout = builder.isBreakout;
guestPolicy = builder.guestPolicy;
authenticatedGuest = builder.authenticatedGuest;
meetingLayout = builder.meetingLayout;
meetingLayout = builder.meetingLayout;
breakoutRoomsParams = builder.breakoutRoomsParams;
lockSettingsParams = builder.lockSettingsParams;
allowDuplicateExtUserid = builder.allowDuplicateExtUserid;
endWhenNoModerator = builder.endWhenNoModerator;
endWhenNoModeratorDelayInMinutes = builder.endWhenNoModeratorDelayInMinutes;
html5InstanceId = builder.html5InstanceId;
groups = builder.groups;
/*
* A pad is a pair of padId and readOnlyId that represents
@ -224,6 +226,10 @@ public class Meeting {
public void setHtml5InstanceId(int instanceId) { html5InstanceId = instanceId; }
public ArrayList<Group> getGroups() { return groups; }
public void setGroups(ArrayList<Group> groups) { this.groups = groups; }
public long getStartTime() {
return startTime;
}
@ -751,13 +757,14 @@ public class Meeting {
private boolean isBreakout;
private String guestPolicy;
private Boolean authenticatedGuest;
private String meetingLayout;
private String meetingLayout;
private BreakoutRoomsParams breakoutRoomsParams;
private LockSettingsParams lockSettingsParams;
private Boolean allowDuplicateExtUserid;
private Boolean endWhenNoModerator;
private Integer endWhenNoModeratorDelayInMinutes;
private int html5InstanceId;
private ArrayList<Group> groups;
public Builder(String externalId, String internalId, long createTime) {
this.externalId = externalId;
@ -930,7 +937,12 @@ public class Meeting {
return this;
}
public Meeting build() {
public Builder withGroups(ArrayList<Group> groups) {
this.groups = groups;
return this;
}
public Meeting build() {
return new Meeting(this);
}
}

View File

@ -1,9 +1,11 @@
package org.bigbluebutton.api2;
import java.util.ArrayList;
import java.util.Map;
import org.bigbluebutton.api.domain.BreakoutRoomsParams;
import org.bigbluebutton.api.domain.LockSettingsParams;
import org.bigbluebutton.api.domain.Group;
import org.bigbluebutton.api.messaging.converters.messages.DestroyMeetingMessage;
import org.bigbluebutton.api.messaging.converters.messages.EndMeetingMessage;
import org.bigbluebutton.api.messaging.converters.messages.PublishedRecordingMessage;
@ -33,7 +35,8 @@ public interface IBbbWebApiGWApp {
Boolean keepEvents,
BreakoutRoomsParams breakoutParams,
LockSettingsParams lockSettingsParams,
Integer html5InstanceId);
Integer html5InstanceId,
ArrayList<Group> groups);
void registerUser(String meetingID, String internalUserId, String fullname, String role,
String externUserID, String authToken, String avatarURL,

View File

@ -3,7 +3,8 @@ package org.bigbluebutton.api2
import scala.collection.JavaConverters._
import akka.actor.ActorSystem
import akka.event.Logging
import org.bigbluebutton.api.domain.{ BreakoutRoomsParams, LockSettingsParams }
import java.util
import org.bigbluebutton.api.domain.{ BreakoutRoomsParams, Group, LockSettingsParams }
import org.bigbluebutton.api.messaging.converters.messages._
import org.bigbluebutton.api2.bus._
import org.bigbluebutton.api2.endpoint.redis.WebRedisSubscriberActor
@ -143,7 +144,8 @@ class BbbWebApiGWApp(
keepEvents: java.lang.Boolean,
breakoutParams: BreakoutRoomsParams,
lockSettingsParams: LockSettingsParams,
html5InstanceId: java.lang.Integer): Unit = {
html5InstanceId: java.lang.Integer,
groups: java.util.ArrayList[Group]): Unit = {
val meetingProp = MeetingProp(name = meetingName, extId = extMeetingId, intId = meetingId,
isBreakout = isBreakout.booleanValue(), learningDashboardEnabled = learningDashboardEnabled.booleanValue())
@ -201,6 +203,8 @@ class BbbWebApiGWApp(
html5InstanceId
)
val groupsAsVector: Vector[GroupProps] = groups.asScala.toVector.map(g => GroupProps(g.getGroupId(), g.getName(), g.getUsersExtId().asScala.toVector))
val defaultProps = DefaultProps(
meetingProp,
breakoutProps,
@ -213,7 +217,8 @@ class BbbWebApiGWApp(
metadataProp,
screenshareProps,
lockSettingsProps,
systemProps
systemProps,
groupsAsVector
)
//meetingManagerActorRef ! new CreateMeetingMsg(defaultProps)

View File

@ -1,42 +1,3 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.col-text-left {
text-align: left;
}

View File

@ -1,12 +1,16 @@
import React from 'react';
import './App.css';
import './bbb-icons.css';
import { FormattedMessage, FormattedDate, injectIntl } from 'react-intl';
import {
FormattedMessage, FormattedDate, injectIntl, FormattedTime,
} from 'react-intl';
import { emojiConfigs } from './services/EmojiService';
import Card from './components/Card';
import UsersTable from './components/UsersTable';
import StatusTable from './components/StatusTable';
import PollsTable from './components/PollsTable';
import ErrorMessage from './components/ErrorMessage';
import { makeUserCSVData, tsToHHmmss } from './services/UserService';
class App extends React.Component {
constructor(props) {
@ -19,6 +23,7 @@ class App extends React.Component {
learningDashboardAccessToken: '',
ldAccessTokenCopied: false,
sessionToken: '',
lastUpdated: null,
};
}
@ -29,6 +34,34 @@ class App extends React.Component {
}, 10000);
}
handleSaveSessionData(e) {
const { target: downloadButton } = e;
const { intl } = this.props;
const { activitiesJson } = this.state;
const {
name: meetingName, createdOn, users, polls,
} = activitiesJson;
const link = document.createElement('a');
const data = makeUserCSVData(users, polls, intl);
const filename = `LearningDashboard_${meetingName}_${new Date(createdOn).toISOString().substr(0, 10)}.csv`.replace(/ /g, '-');
downloadButton.setAttribute('disabled', 'true');
downloadButton.style.cursor = 'not-allowed';
link.setAttribute('href', `data:application/octet-stream,${encodeURIComponent(data)}`);
link.setAttribute('download', filename);
link.style.display = 'none';
document.body.appendChild(link);
link.click();
downloadButton.innerHTML = intl.formatMessage({ id: 'app.learningDashboard.sessionDataDownloadedLabel', defaultMessage: 'Downloaded!' });
setTimeout(() => {
downloadButton.innerHTML = intl.formatMessage({ id: 'app.learningDashboard.downloadSessionDataLabel', defaultMessage: 'Download Session Data' });
downloadButton.removeAttribute('disabled');
downloadButton.style.cursor = 'pointer';
downloadButton.focus();
}, 3000);
document.body.removeChild(link);
}
setDashboardParams() {
let learningDashboardAccessToken = '';
let meetingId = '';
@ -67,6 +100,39 @@ class App extends React.Component {
this.fetchActivitiesJson);
}
fetchMostUsedEmojis() {
const { activitiesJson } = this.state;
if (!activitiesJson) { return []; }
// Icon elements
const emojis = [...Object.keys(emojiConfigs)];
const icons = {};
emojis.forEach((emoji) => {
icons[emoji] = (<i className={`${emojiConfigs[emoji].icon} bbb-icon-card`} />);
});
// Count each emoji
const emojiCount = {};
emojis.forEach((emoji) => {
emojiCount[emoji] = 0;
});
const allEmojisUsed = Object
.values(activitiesJson.users || {})
.map((user) => user.emojis || [])
.flat(1);
allEmojisUsed.forEach((emoji) => {
emojiCount[emoji.name] += 1;
});
// Get the three most used
const mostUsedEmojis = Object
.entries(emojiCount)
.sort(([, countA], [, countB]) => countA - countB)
.reverse()
.slice(0, 3);
return mostUsedEmojis.map(([emoji]) => icons[emoji]);
}
fetchActivitiesJson() {
const { learningDashboardAccessToken, meetingId, sessionToken } = this.state;
@ -74,7 +140,7 @@ class App extends React.Component {
fetch(`${meetingId}/${learningDashboardAccessToken}/learning_dashboard_data.json`)
.then((response) => response.json())
.then((json) => {
this.setState({ activitiesJson: json, loading: false });
this.setState({ activitiesJson: json, loading: false, lastUpdated: Date.now() });
document.title = `Learning Dashboard - ${json.name}`;
}).catch(() => {
this.setState({ loading: false });
@ -86,7 +152,7 @@ class App extends React.Component {
.then((json) => {
if (json.response.returncode === 'SUCCESS') {
const jsonData = JSON.parse(json.response.data);
this.setState({ activitiesJson: jsonData, loading: false });
this.setState({ activitiesJson: jsonData, loading: false, lastUpdated: Date.now() });
document.title = `Learning Dashboard - ${jsonData.name}`;
} else {
// When meeting is ended the sessionToken stop working, check for new cookies
@ -119,25 +185,21 @@ class App extends React.Component {
render() {
const {
activitiesJson, tab, sessionToken, loading,
activitiesJson, tab, sessionToken, loading, lastUpdated,
learningDashboardAccessToken, ldAccessTokenCopied,
} = this.state;
const { intl } = this.props;
document.title = `${intl.formatMessage({ id: 'app.learningDashboard.dashboardTitle', defaultMessage: 'Learning Dashboard' })} - ${activitiesJson.name}`;
function totalOfRaiseHand() {
function totalOfEmojis() {
if (activitiesJson && activitiesJson.users) {
return Object.values(activitiesJson.users)
.reduce((prevVal, elem) => prevVal + elem.emojis.filter((emoji) => emoji.name === 'raiseHand').length, 0);
.reduce((prevVal, elem) => prevVal + elem.emojis.length, 0);
}
return 0;
}
function tsToHHmmss(ts) {
return (new Date(ts).toISOString().substr(11, 8));
}
function totalOfActivity() {
const minTime = Object.values(activitiesJson.users || {}).reduce((prevVal, elem) => {
if (prevVal === 0 || elem.registeredOn < prevVal) return elem.registeredOn;
@ -257,7 +319,7 @@ class App extends React.Component {
</span>
)
: null
}
}
<br />
<span className="text-sm font-medium">{activitiesJson.name || ''}</span>
</h1>
@ -309,7 +371,7 @@ class App extends React.Component {
}
number={Object.values(activitiesJson.users || {})
.filter((u) => activitiesJson.endedOn > 0 || u.leftOn === 0).length}
cardClass="border-pink-500"
cardClass={tab === 'overview' ? 'border-pink-500' : 'hover:border-pink-500'}
iconClass="bg-pink-50 text-pink-500"
onClick={() => {
this.setState({ tab: 'overview' });
@ -331,52 +393,6 @@ class App extends React.Component {
</svg>
</Card>
</div>
<div aria-hidden="true" className="cursor-pointer" onClick={() => { this.setState({ tab: 'polling' }); }}>
<Card
name={intl.formatMessage({ id: 'app.learningDashboard.indicators.polls', defaultMessage: 'Polls' })}
number={Object.values(activitiesJson.polls || {}).length}
cardClass="border-blue-500"
iconClass="bg-blue-100 text-blue-500"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"
/>
</svg>
</Card>
</div>
<div aria-hidden="true" className="cursor-pointer" onClick={() => { this.setState({ tab: 'status_timeline' }); }}>
<Card
name={intl.formatMessage({ id: 'app.learningDashboard.indicators.raiseHand', defaultMessage: 'Raise Hand' })}
number={totalOfRaiseHand()}
cardClass="border-purple-500"
iconClass="bg-purple-200 text-purple-500"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M7 11.5V14m0-2.5v-6a1.5 1.5 0 113 0m-3 6a1.5 1.5 0 00-3 0v2a7.5 7.5 0 0015 0v-5a1.5 1.5 0 00-3 0m-6-3V11m0-5.5v-1a1.5 1.5 0 013 0v1m0 0V11m0-5.5a1.5 1.5 0 013 0v3m0 0V11"
/>
</svg>
</Card>
</div>
<div aria-hidden="true" className="cursor-pointer" onClick={() => { this.setState({ tab: 'overview_activityscore' }); }}>
<Card
name={intl.formatMessage({ id: 'app.learningDashboard.indicators.activityScore', defaultMessage: 'Activity Score' })}
@ -384,7 +400,7 @@ class App extends React.Component {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
})}
cardClass="border-green-500"
cardClass={tab === 'overview_activityscore' ? 'border-green-500' : 'hover:border-green-500'}
iconClass="bg-green-200 text-green-500"
>
<svg
@ -409,13 +425,46 @@ class App extends React.Component {
</svg>
</Card>
</div>
<div aria-hidden="true" className="cursor-pointer" onClick={() => { this.setState({ tab: 'status_timeline' }); }}>
<Card
name={intl.formatMessage({ id: 'app.learningDashboard.indicators.timeline', defaultMessage: 'Timeline' })}
number={totalOfEmojis()}
cardClass={tab === 'status_timeline' ? 'border-purple-500' : 'hover:border-purple-500'}
iconClass="bg-purple-200 text-purple-500"
>
{this.fetchMostUsedEmojis()}
</Card>
</div>
<div aria-hidden="true" className="cursor-pointer" onClick={() => { this.setState({ tab: 'polling' }); }}>
<Card
name={intl.formatMessage({ id: 'app.learningDashboard.indicators.polls', defaultMessage: 'Polls' })}
number={Object.values(activitiesJson.polls || {}).length}
cardClass={tab === 'polling' ? 'border-blue-500' : 'hover:border-blue-500'}
iconClass="bg-blue-100 text-blue-500"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"
/>
</svg>
</Card>
</div>
</div>
<h1 className="block my-1 pr-2 text-xl font-semibold">
<h1 className="block my-2 pr-2 text-xl font-semibold">
{ tab === 'overview' || tab === 'overview_activityscore'
? <FormattedMessage id="app.learningDashboard.usersTable.title" defaultMessage="Overview" />
: null }
{ tab === 'status_timeline'
? <FormattedMessage id="app.learningDashboard.statusTimelineTable.title" defaultMessage="Status Timeline" />
? <FormattedMessage id="app.learningDashboard.statusTimelineTable.title" defaultMessage="Timeline" />
: null }
{ tab === 'polling'
? <FormattedMessage id="app.learningDashboard.pollsTable.title" defaultMessage="Polling" />
@ -441,6 +490,44 @@ class App extends React.Component {
: null }
</div>
</div>
<hr className="my-8" />
<div className="flex justify-between mb-8 text-xs text-gray-700 dark:text-gray-400 whitespace-nowrap flex-col sm:flex-row">
<div className="flex flex-col justify-center mb-4 sm:mb-0">
<p>
{
lastUpdated && (
<>
<FormattedMessage
id="app.learningDashboard.lastUpdatedLabel"
defaultMessage="Last updated at"
/>
&nbsp;
<FormattedTime
value={lastUpdated}
/>
&nbsp;
<FormattedDate
value={lastUpdated}
year="numeric"
month="long"
day="numeric"
/>
</>
)
}
</p>
</div>
<button
type="button"
className="border-2 border-gray-200 rounded-md px-4 py-2 bg-white focus:outline-none focus:ring ring-offset-2 focus:ring-gray-500 focus:ring-opacity-50"
onClick={this.handleSaveSessionData.bind(this)}
>
<FormattedMessage
id="app.learningDashboard.downloadSessionDataLabel"
defaultMessage="Download Session Data"
/>
</button>
</div>
</div>
);
}

View File

@ -24,6 +24,15 @@
-moz-osx-font-smoothing: grayscale;
}
.bbb-icon-card {
font-size: 1.25rem;
font-weight: bold;
}
.bbb-icon-timeline {
font-weight: bold;
}
.icon-bbb-screenshare-fullscreen:before {
content: "\e92a";
}

View File

@ -5,8 +5,36 @@ function Card(props) {
number, name, children, iconClass, cardClass,
} = props;
let icons;
try {
React.Children.only(children);
icons = (
<div className={`p-2 text-orange-500 rounded-full ${iconClass}`}>
{ children }
</div>
);
} catch (e) {
icons = (
<div className="flex">
{
React.Children.map(children, (child, index) => {
let offset = 4 / (index + 1);
offset = index === (React.Children.count(children) - 1) ? 0 : offset;
return (
<div className={`flex justify-center transform translate-x-${offset} border-2 border-white p-2 text-orange-500 rounded-full z-${index * 10} ${iconClass}`}>
{ child }
</div>
);
})
}
</div>
);
}
return (
<div className={`flex items-center justify-between p-4 bg-white rounded-md shadow border-l-8 ${cardClass}`}>
<div className={`flex items-start justify-between p-3 bg-white rounded shadow border-l-4 border-white ${cardClass}`}>
<div className="w-70">
<p className="text-lg font-semibold text-gray-700">
{ number }
@ -15,9 +43,7 @@ function Card(props) {
{ name }
</p>
</div>
<div className={`p-3 mr-4 text-orange-500 rounded-full ${iconClass}`}>
{ children }
</div>
{icons}
</div>
);
}

View File

@ -14,6 +14,41 @@ class PollsTable extends React.Component {
return '';
}
if (typeof polls === 'object' && Object.values(polls).length === 0) {
return (
<div className="flex flex-col items-center py-24">
<div className="mb-1 p-3 text-orange-500 rounded-full bg-blue-100 text-blue-500">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"
/>
</svg>
</div>
<p className="text-lg font-semibold text-gray-700">
<FormattedMessage
id="app.learningDashboard.pollsTable.noPollsCreatedHeading"
defaultMessage="No polls have been created"
/>
</p>
<p className="mb-2 text-sm font-medium text-gray-600">
<FormattedMessage
id="app.learningDashboard.pollsTable.noPollsCreatedMessage"
defaultMessage="Once a poll has been sent to users, their results will appear in this list."
/>
</p>
</div>
);
}
return (
<table className="w-full whitespace-nowrap">
<thead>

View File

@ -1,9 +1,26 @@
import React from 'react';
import { FormattedMessage, injectIntl } from 'react-intl';
import { getUserEmojisSummary, emojiConfigs } from '../services/EmojiService';
import { emojiConfigs, filterUserEmojis } from '../services/EmojiService';
import UserAvatar from './UserAvatar';
class StatusTable extends React.Component {
componentDidMount() {
// This code is needed to prevent the emoji in the first cell
// after the username from overflowing
const emojis = document.getElementsByClassName('emojiOnFirstCell');
for (let i = 0; i < emojis.length; i += 1) {
const emojiStyle = window.getComputedStyle(emojis[i]);
let offsetLeft = emojiStyle
.left
.replace(/px/g, '')
.trim();
offsetLeft = Number(offsetLeft);
if (offsetLeft < 0) {
emojis[i].style.offsetLeft = '0px';
}
}
}
render() {
const spanMinutes = 10 * 60000; // 10 minutes default
const { allUsers, intl } = this.props;
@ -32,7 +49,7 @@ class StatusTable extends React.Component {
<table className="w-full whitespace-nowrap">
<thead>
<tr className="text-xs font-semibold tracking-wide text-gray-500 uppercase border-b bg-gray-100">
<th className="px-4 py-3 col-text-left">
<th className="px-4 py-3 col-text-left sticky left-0 z-30 bg-inherit">
<FormattedMessage id="app.learningDashboard.user" defaultMessage="User" />
<svg
xmlns="http://www.w3.org/2000/svg"
@ -58,8 +75,8 @@ class StatusTable extends React.Component {
return 0;
})
.map((user) => (
<tr className="text-gray-700">
<td className="px-4 py-3">
<tr className="text-gray-700 bg-inherit">
<td className="bg-inherit sticky left-0 z-30 px-4 py-3">
<div className="flex items-center text-sm">
<div className="relative hidden w-8 h-8 rounded-full md:block">
<UserAvatar user={user} />
@ -71,81 +88,97 @@ class StatusTable extends React.Component {
</div>
</td>
{ periods.map((period) => {
const userEmojisInPeriod = getUserEmojisSummary(user,
const userEmojisInPeriod = filterUserEmojis(user,
null,
period,
period + spanMinutes);
const { registeredOn, leftOn } = user;
const boundaryLeft = period;
const boundaryRight = period + spanMinutes - 1;
return (
<td className="px-4 py-3 text-sm col-text-left">
<td className="relative px-4 py-3 text-sm col-text-left">
{
user.registeredOn > period && user.registeredOn < period + spanMinutes
(registeredOn >= boundaryLeft && registeredOn <= boundaryRight)
|| (leftOn >= boundaryLeft && leftOn <= boundaryRight)
|| (boundaryLeft > registeredOn && boundaryRight < leftOn)
|| (boundaryLeft >= registeredOn && leftOn === 0)
? (
<span title={intl.formatDate(user.registeredOn, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 text-xs text-green-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
(function makeLineThrough() {
let roundedLeft = registeredOn >= boundaryLeft
&& registeredOn <= boundaryRight ? 'rounded-l' : '';
let roundedRight = leftOn > boundaryLeft
&& leftOn < boundaryRight ? 'rounded-r' : '';
let offsetLeft = 0;
let offsetRight = 0;
if (registeredOn >= boundaryLeft && registeredOn <= boundaryRight) {
offsetLeft = ((registeredOn - boundaryLeft) * 100) / spanMinutes;
}
if (leftOn >= boundaryLeft && leftOn <= boundaryRight) {
offsetRight = ((boundaryRight - leftOn) * 100) / spanMinutes;
}
let width = '';
if (offsetLeft === 0 && offsetRight >= 99) {
width = 'w-1.5';
}
if (offsetRight === 0 && offsetLeft >= 99) {
width = 'w-1.5';
}
if (offsetLeft && offsetRight) {
const variation = offsetLeft - offsetRight;
if (
variation > -1 && variation < 1
) {
width = 'w-1.5';
}
}
const isRTL = document.dir === 'rtl';
if (isRTL) {
const aux = roundedRight;
if (roundedLeft !== '') roundedRight = 'rounded-r';
else roundedRight = '';
if (aux !== '') roundedLeft = 'rounded-l';
else roundedLeft = '';
}
// height / 2
const redress = '(0.375rem / 2)';
return (
<div
className={`h-1.5 ${width} bg-gray-200 absolute inset-x-0 z-10 ${roundedLeft} ${roundedRight}`}
style={{
top: `calc(50% - ${redress})`,
left: `${isRTL ? offsetRight : offsetLeft}%`,
right: `${isRTL ? offsetLeft : offsetRight}%`,
}}
/>
</svg>
</span>
);
})()
) : null
}
{ Object.keys(userEmojisInPeriod)
.map((emoji) => (
<div className="text-sm text-gray-800">
<i className={`${emojiConfigs[emoji].icon} text-sm`} />
&nbsp;
{ userEmojisInPeriod[emoji] }
&nbsp;
<FormattedMessage
id={emojiConfigs[emoji].intlId}
defaultMessage={emojiConfigs[emoji].defaultMessage}
/>
{ userEmojisInPeriod.map((emoji) => {
const offset = ((emoji.sentOn - period) * 100) / spanMinutes;
const origin = document.dir === 'rtl' ? 'right' : 'left';
const onFirstCell = period === firstRegisteredOnTime;
// font-size / 2 + padding right/left + border-width
const redress = '(0.875rem / 2 + 0.25rem + 2px)';
return (
<div
className={`flex absolute p-1 border-white border-2 rounded-full text-sm z-20 bg-purple-500 text-purple-200 ${onFirstCell ? 'emojiOnFirstCell' : ''}`}
role="status"
style={{
top: `calc(50% - ${redress})`,
[origin]: `calc(${offset}% - ${redress})`,
}}
title={intl.formatMessage({
id: emojiConfigs[emoji.name].intlId,
defaultMessage: emojiConfigs[emoji.name].defaultMessage,
})}
>
<i className={`${emojiConfigs[emoji.name].icon} text-sm bbb-icon-timeline`} />
</div>
)) }
{
user.leftOn > period && user.leftOn < period + spanMinutes
? (
<span title={intl.formatDate(user.leftOn, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
</span>
) : null
}
);
})}
</td>
);
}) }

View File

@ -3,6 +3,7 @@ import {
FormattedMessage, FormattedDate, FormattedNumber, injectIntl,
} from 'react-intl';
import { getUserEmojisSummary, emojiConfigs } from '../services/EmojiService';
import { getActivityScore, getSumOfTime, tsToHHmmss } from '../services/UserService';
import UserAvatar from './UserAvatar';
class UsersTable extends React.Component {
@ -30,57 +31,6 @@ class UsersTable extends React.Component {
const { activityscoreOrder } = this.state;
function getSumOfTime(eventsArr) {
return eventsArr.reduce((prevVal, elem) => {
if (elem.stoppedOn > 0) return prevVal + (elem.stoppedOn - elem.startedOn);
return prevVal + (new Date().getTime() - elem.startedOn);
}, 0);
}
function getActivityScore(user) {
if (user.isModerator) return 0;
const allUsersArr = Object.values(allUsers || {}).filter((currUser) => !currUser.isModerator);
let userPoints = 0;
// Calculate points of Talking
const usersTalkTime = allUsersArr.map((currUser) => currUser.talk.totalTime);
const maxTalkTime = Math.max(...usersTalkTime);
if (maxTalkTime > 0) {
userPoints += (user.talk.totalTime / maxTalkTime) * 2;
}
// Calculate points of Chatting
const usersTotalOfMessages = allUsersArr.map((currUser) => currUser.totalOfMessages);
const maxMessages = Math.max(...usersTotalOfMessages);
if (maxMessages > 0) {
userPoints += (user.totalOfMessages / maxMessages) * 2;
}
// Calculate points of Raise hand
const usersRaiseHand = allUsersArr.map((currUser) => currUser.emojis.filter((emoji) => emoji.name === 'raiseHand').length);
const maxRaiseHand = Math.max(...usersRaiseHand);
const userRaiseHand = user.emojis.filter((emoji) => emoji.name === 'raiseHand').length;
if (maxRaiseHand > 0) {
userPoints += (userRaiseHand / maxRaiseHand) * 2;
}
// Calculate points of Emojis
const usersEmojis = allUsersArr.map((currUser) => currUser.emojis.filter((emoji) => emoji.name !== 'raiseHand').length);
const maxEmojis = Math.max(...usersEmojis);
const userEmojis = user.emojis.filter((emoji) => emoji.name !== 'raiseHand').length;
if (maxEmojis > 0) {
userPoints += (userEmojis / maxEmojis) * 2;
}
// Calculate points of Polls
if (totalOfPolls > 0) {
userPoints += (Object.values(user.answers || {}).length / totalOfPolls) * 2;
}
return userPoints;
}
const usersEmojisSummary = {};
Object.values(allUsers || {}).forEach((user) => {
usersEmojisSummary[user.intId] = getUserEmojisSummary(user, 'raiseHand');
@ -91,13 +41,9 @@ class UsersTable extends React.Component {
return Math.ceil((totalUserOnlineTime / totalOfActivityTime) * 100);
}
function tsToHHmmss(ts) {
return (new Date(ts).toISOString().substr(11, 8));
}
const usersActivityScore = {};
Object.values(allUsers || {}).forEach((user) => {
usersActivityScore[user.intId] = getActivityScore(user);
usersActivityScore[user.intId] = getActivityScore(user, allUsers, totalOfPolls);
});
return (
@ -187,49 +133,48 @@ class UsersTable extends React.Component {
if (a.name.toLowerCase() > b.name.toLowerCase()) return 1;
return 0;
})
.map((user) => (
<tr key={user} className="text-gray-700">
<td className="px-4 py-3 col-text-left text-sm">
<div className="inline-block relative w-8 h-8 rounded-full">
{/* <img className="object-cover w-full h-full rounded-full" */}
{/* src="" */}
{/* alt="" loading="lazy" /> */}
<UserAvatar user={user} />
<div
className="absolute inset-0 rounded-full shadow-inner"
aria-hidden="true"
/>
</div>
&nbsp;&nbsp;&nbsp;
<div className="inline-block">
<p className="font-semibold">
{user.name}
</p>
<p className="text-xs text-gray-600 dark:text-gray-400">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 inline"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
/>
</svg>
<FormattedDate
value={user.registeredOn}
month="short"
day="numeric"
hour="2-digit"
minute="2-digit"
second="2-digit"
.map((user) => {
const opacity = user.leftOn > 0 ? 'opacity-75' : '';
return (
<tr key={user} className="text-gray-700">
<td className={`px-4 py-3 col-text-left text-sm ${opacity}`}>
<div className="inline-block relative w-8 h-8 rounded-full">
<UserAvatar user={user} />
<div
className="absolute inset-0 rounded-full shadow-inner"
aria-hidden="true"
/>
</p>
{
</div>
&nbsp;&nbsp;&nbsp;
<div className="inline-block">
<p className="font-semibold">
{user.name}
</p>
<p className="text-xs text-gray-600 dark:text-gray-400">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 inline"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
/>
</svg>
<FormattedDate
value={user.registeredOn}
month="short"
day="numeric"
hour="2-digit"
minute="2-digit"
second="2-digit"
/>
</p>
{
user.leftOn > 0
? (
<p className="text-xs text-gray-600 dark:text-gray-400">
@ -259,154 +204,154 @@ class UsersTable extends React.Component {
</p>
)
: null
}
</div>
</td>
<td className="px-4 py-3 text-sm text-center items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 inline"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728m-9.9-2.829a5 5 0 010-7.07m7.072 0a5 5 0 010 7.07M13 12a1 1 0 11-2 0 1 1 0 012 0z"
/>
</svg>
&nbsp;
{ tsToHHmmss(
(user.leftOn > 0
? user.leftOn
: (new Date()).getTime()) - user.registeredOn,
) }
<br />
<div
className="bg-gray-200 transition-colors duration-500 rounded-full overflow-hidden"
title={`${getOnlinePercentage(user.registeredOn, user.leftOn).toString()}%`}
>
}
</div>
</td>
<td className={`px-4 py-3 text-sm text-center items-center ${opacity}`}>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 inline"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728m-9.9-2.829a5 5 0 010-7.07m7.072 0a5 5 0 010 7.07M13 12a1 1 0 11-2 0 1 1 0 012 0z"
/>
</svg>
&nbsp;
{ tsToHHmmss(
(user.leftOn > 0
? user.leftOn
: (new Date()).getTime()) - user.registeredOn,
) }
<br />
<div
aria-label=" "
className="bg-gradient-to-br from-green-100 to-green-600 transition-colors duration-900 h-1.5"
style={{ width: `${getOnlinePercentage(user.registeredOn, user.leftOn).toString()}%` }}
role="progressbar"
/>
</div>
</td>
<td className="px-4 py-3 text-sm text-center">
{ user.talk.totalTime > 0
? (
<span className="text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 inline"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
className="bg-gray-200 transition-colors duration-500 rounded-full overflow-hidden"
title={`${getOnlinePercentage(user.registeredOn, user.leftOn).toString()}%`}
>
<div
aria-label=" "
className="bg-gradient-to-br from-green-100 to-green-600 transition-colors duration-900 h-1.5"
style={{ width: `${getOnlinePercentage(user.registeredOn, user.leftOn).toString()}%` }}
role="progressbar"
/>
</div>
</td>
<td className={`px-4 py-3 text-sm text-center items-center ${opacity}`}>
{ user.talk.totalTime > 0
? (
<span className="text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 inline"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
/>
</svg>
&nbsp;
{ tsToHHmmss(user.talk.totalTime) }
</span>
) : null }
</td>
<td className={`px-4 py-3 text-sm text-center ${opacity}`}>
{ getSumOfTime(user.webcams) > 0
? (
<span className="text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 inline"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
&nbsp;
{ tsToHHmmss(getSumOfTime(user.webcams)) }
</span>
) : null }
</td>
<td className={`px-4 py-3 text-sm text-center ${opacity}`}>
{ user.totalOfMessages > 0
? (
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 inline"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
&nbsp;
{user.totalOfMessages}
</span>
) : null }
</td>
<td className={`px-4 py-3 text-sm col-text-left ${opacity}`}>
{
Object.keys(usersEmojisSummary[user.intId] || {}).map((emoji) => (
<div className="text-xs whitespace-nowrap">
<i className={`${emojiConfigs[emoji].icon} text-sm`} />
&nbsp;
{ usersEmojisSummary[user.intId][emoji] }
&nbsp;
<FormattedMessage
id={emojiConfigs[emoji].intlId}
defaultMessage={emojiConfigs[emoji].defaultMessage}
/>
</svg>
&nbsp;
{ tsToHHmmss(user.talk.totalTime) }
</span>
) : null }
</td>
<td className="px-4 py-3 text-sm text-center">
{ getSumOfTime(user.webcams) > 0
? (
<span className="text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 inline"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
&nbsp;
{ tsToHHmmss(getSumOfTime(user.webcams)) }
</span>
) : null }
</td>
<td className="px-4 py-3 text-sm text-center">
{ user.totalOfMessages > 0
? (
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 inline"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
&nbsp;
{user.totalOfMessages}
</span>
) : null }
</td>
<td className="px-4 py-3 text-sm col-text-left">
</div>
))
}
</td>
<td className={`px-4 py-3 text-sm text-center ${opacity}`}>
{ user.emojis.filter((emoji) => emoji.name === 'raiseHand').length > 0
? (
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 inline"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M7 11.5V14m0-2.5v-6a1.5 1.5 0 113 0m-3 6a1.5 1.5 0 00-3 0v2a7.5 7.5 0 0015 0v-5a1.5 1.5 0 00-3 0m-6-3V11m0-5.5v-1a1.5 1.5 0 013 0v1m0 0V11m0-5.5a1.5 1.5 0 013 0v3m0 0V11"
/>
</svg>
&nbsp;
{user.emojis.filter((emoji) => emoji.name === 'raiseHand').length}
</span>
) : null }
</td>
{
Object.keys(usersEmojisSummary[user.intId] || {}).map((emoji) => (
<div className="text-xs whitespace-nowrap">
<i className={`${emojiConfigs[emoji].icon} text-sm`} />
&nbsp;
{ usersEmojisSummary[user.intId][emoji] }
&nbsp;
<FormattedMessage
id={emojiConfigs[emoji].intlId}
defaultMessage={emojiConfigs[emoji].defaultMessage}
/>
</div>
))
}
</td>
<td className="px-4 py-3 text-sm text-center">
{ user.emojis.filter((emoji) => emoji.name === 'raiseHand').length > 0
? (
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 inline"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M7 11.5V14m0-2.5v-6a1.5 1.5 0 113 0m-3 6a1.5 1.5 0 00-3 0v2a7.5 7.5 0 0015 0v-5a1.5 1.5 0 00-3 0m-6-3V11m0-5.5v-1a1.5 1.5 0 013 0v1m0 0V11m0-5.5a1.5 1.5 0 013 0v3m0 0V11"
/>
</svg>
&nbsp;
{user.emojis.filter((emoji) => emoji.name === 'raiseHand').length}
</span>
) : null }
</td>
{
!user.isModerator ? (
<td className="px-4 py-3 text-sm text-center items">
<td className={`px-4 py-3 text-sm text-center items ${opacity}`}>
<svg viewBox="0 0 82 12" width="82" height="12" className="flex-none m-auto inline">
<rect width="12" height="12" fill={usersActivityScore[user.intId] > 0 ? '#A7F3D0' : '#e4e4e7'} />
<rect width="12" height="12" x="14" fill={usersActivityScore[user.intId] > 2 ? '#6EE7B7' : '#e4e4e7'} />
@ -422,23 +367,24 @@ class UsersTable extends React.Component {
</td>
) : <td />
}
<td className="px-4 py-3 text-xs text-center">
{
user.leftOn > 0
? (
<span className="px-2 py-1 font-semibold leading-tight text-red-700 bg-red-100 rounded-full">
<FormattedMessage id="app.learningDashboard.usersTable.userStatusOffline" defaultMessage="Offline" />
</span>
)
: (
<span className="px-2 py-1 font-semibold leading-tight text-green-700 bg-green-100 rounded-full">
<FormattedMessage id="app.learningDashboard.usersTable.userStatusOnline" defaultMessage="Online" />
</span>
)
}
</td>
</tr>
))
<td className="px-4 py-3 text-xs text-center">
{
user.leftOn > 0
? (
<span className="px-2 py-1 font-semibold leading-tight text-red-700 bg-red-100 rounded-full">
<FormattedMessage id="app.learningDashboard.usersTable.userStatusOffline" defaultMessage="Offline" />
</span>
)
: (
<span className="px-2 py-1 font-semibold leading-tight text-green-700 bg-green-100 rounded-full">
<FormattedMessage id="app.learningDashboard.usersTable.userStatusOnline" defaultMessage="Online" />
</span>
)
}
</td>
</tr>
);
})
) : (
<tr className="text-gray-700">
<td colSpan="8" className="px-4 py-3 text-sm text-center">

View File

@ -2,3 +2,33 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.bg-inherit {
background-color: inherit;
}
}
.translate-x-0 {
--tw-translate-x: 0px;
}
.translate-x-2 {
--tw-translate-x: 0.5rem;
}
.translate-x-4 {
--tw-translate-x: 1rem;
}
[dir="rtl"] .translate-x-0 {
--tw-translate-x: 0px;
}
[dir="rtl"] .translate-x-2 {
--tw-translate-x: -0.5rem;
}
[dir="rtl"] .translate-x-4 {
--tw-translate-x: -1rem;
}

View File

@ -60,3 +60,15 @@ export function getUserEmojisSummary(user, skipNames = null, start = null, end =
});
return userEmojis;
}
export function filterUserEmojis(user, skipNames = null, start = null, end = null) {
const userEmojis = [];
user.emojis.forEach((emoji) => {
if (typeof emojiConfigs[emoji.name] === 'undefined') return;
if (skipNames != null && skipNames.split(',').indexOf(emoji.name) > -1) return;
if (start != null && emoji.sentOn < start) return;
if (end != null && emoji.sentOn > end) return;
userEmojis.push(emoji);
});
return userEmojis;
}

View File

@ -0,0 +1,202 @@
import { emojiConfigs, filterUserEmojis } from './EmojiService';
export function getActivityScore(user, allUsers, totalOfPolls) {
if (user.isModerator) return 0;
const allUsersArr = Object.values(allUsers || {}).filter((currUser) => !currUser.isModerator);
let userPoints = 0;
// Calculate points of Talking
const usersTalkTime = allUsersArr.map((currUser) => currUser.talk.totalTime);
const maxTalkTime = Math.max(...usersTalkTime);
if (maxTalkTime > 0) {
userPoints += (user.talk.totalTime / maxTalkTime) * 2;
}
// Calculate points of Chatting
const usersTotalOfMessages = allUsersArr.map((currUser) => currUser.totalOfMessages);
const maxMessages = Math.max(...usersTotalOfMessages);
if (maxMessages > 0) {
userPoints += (user.totalOfMessages / maxMessages) * 2;
}
// Calculate points of Raise hand
const usersRaiseHand = allUsersArr.map((currUser) => currUser.emojis.filter((emoji) => emoji.name === 'raiseHand').length);
const maxRaiseHand = Math.max(...usersRaiseHand);
const userRaiseHand = user.emojis.filter((emoji) => emoji.name === 'raiseHand').length;
if (maxRaiseHand > 0) {
userPoints += (userRaiseHand / maxRaiseHand) * 2;
}
// Calculate points of Emojis
const usersEmojis = allUsersArr.map((currUser) => currUser.emojis.filter((emoji) => emoji.name !== 'raiseHand').length);
const maxEmojis = Math.max(...usersEmojis);
const userEmojis = user.emojis.filter((emoji) => emoji.name !== 'raiseHand').length;
if (maxEmojis > 0) {
userPoints += (userEmojis / maxEmojis) * 2;
}
// Calculate points of Polls
if (totalOfPolls > 0) {
userPoints += (Object.values(user.answers || {}).length / totalOfPolls) * 2;
}
return userPoints;
}
export function getSumOfTime(eventsArr) {
return eventsArr.reduce((prevVal, elem) => {
if (elem.stoppedOn > 0) return prevVal + (elem.stoppedOn - elem.startedOn);
return prevVal + (new Date().getTime() - elem.startedOn);
}, 0);
}
export function tsToHHmmss(ts) {
return (new Date(ts).toISOString().substr(11, 8));
}
const tableHeaderFields = [
{
id: 'name',
defaultMessage: 'Name',
},
{
id: 'moderator',
defaultMessage: 'Moderator',
},
{
id: 'activityScore',
defaultMessage: 'Activity Score',
},
{
id: 'colTalk',
defaultMessage: 'Talk Time',
},
{
id: 'colWebcam',
defaultMessage: 'Webcam Time',
},
{
id: 'colMessages',
defaultMessage: 'Messages',
},
{
id: 'colEmojis',
defaultMessage: 'Emojis',
},
{
id: 'pollVotes',
defaultMessage: 'Poll Votes',
},
{
id: 'colRaiseHands',
defaultMessage: 'Raise Hands',
},
{
id: 'join',
defaultMessage: 'Join',
},
{
id: 'left',
defaultMessage: 'Left',
},
{
id: 'duration',
defaultMessage: 'Duration',
},
];
export function makeUserCSVData(users, polls, intl) {
const userRecords = {};
const userValues = Object.values(users || {});
const pollValues = Object.values(polls || {});
const skipEmojis = Object
.keys(emojiConfigs)
.filter((emoji) => emoji !== 'raiseHand')
.join(',');
for (let i = 0; i < userValues.length; i += 1) {
const user = userValues[i];
const webcam = getSumOfTime(user.webcams);
const duration = user.leftOn > 0
? user.leftOn - user.registeredOn
: (new Date()).getTime() - user.registeredOn;
const userData = {
name: user.name,
moderator: user.isModerator.toString().toUpperCase(),
activityScore: intl.formatNumber(
getActivityScore(user, userValues, Object.values(polls || {}).length),
{
minimumFractionDigits: 0,
maximumFractionDigits: 1,
},
),
talk: user.talk.totalTime > 0 ? tsToHHmmss(user.talk.totalTime) : '-',
webcam: webcam > 0 ? tsToHHmmss(webcam) : '-',
messages: user.totalOfMessages,
raiseHand: filterUserEmojis(user, 'raiseHand').length,
answers: Object.keys(user.answers).length,
emojis: filterUserEmojis(user, skipEmojis).length,
registeredOn: intl.formatDate(user.registeredOn, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}),
leftOn: intl.formatDate(user.leftOn, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}),
duration: tsToHHmmss(duration),
};
for (let j = 0; j < pollValues.length; j += 1) {
userData[`Poll_${j}`] = user.answers[pollValues[j].pollId] || '-';
}
const userFields = Object
.values(userData)
.map((data) => `"${data}"`);
userRecords[user.intId] = userFields.join(',');
}
const tableHeaderFieldsTranslated = tableHeaderFields
.map(({ id, defaultMessage }) => intl.formatMessage({
id: `app.learningDashboard.usersTable.${id}`,
defaultMessage,
}));
let header = tableHeaderFieldsTranslated.join(',');
let anonymousRecord = `"${intl.formatMessage({
id: 'app.learningDashboard.pollsTable.anonymousRowName',
defaultMessage: 'Anonymous',
})}"`;
// Skip the fields for the anonymous record
for (let k = 0; k < header.split(',').length - 1; k += 1) {
// Empty fields
anonymousRecord += ',""';
}
for (let i = 0; i < pollValues.length; i += 1) {
// Add the poll question headers
header += `,${pollValues[i].question || `Poll ${i + 1}`}`;
// Add the anonymous answers
anonymousRecord += `,"${pollValues[i].anonymousAnswers.join('\r\n')}"`;
}
userRecords.Anonymous = anonymousRecord;
return [
header,
Object.values(userRecords).join('\r\n'),
].join('\r\n');
}

View File

@ -8,4 +8,4 @@ module.exports = {
extend: {},
},
plugins: [],
}
};

View File

@ -19,6 +19,7 @@ import Captions from '/imports/api/captions';
import AuthTokenValidation from '/imports/api/auth-token-validation';
import Annotations from '/imports/api/annotations';
import Breakouts from '/imports/api/breakouts';
import BreakoutsHistory from '/imports/api/breakouts-history';
import guestUsers from '/imports/api/guest-users';
import Meetings, { RecordMeetings, ExternalVideoMeetings, MeetingTimeRemaining } from '/imports/api/meetings';
import { UsersTyping } from '/imports/api/group-chat-msg';
@ -52,6 +53,7 @@ export const localExternalVideoMeetingsSync = new AbstractCollection(ExternalVid
export const localMeetingTimeRemainingSync = new AbstractCollection(MeetingTimeRemaining, MeetingTimeRemaining);
export const localUsersTypingSync = new AbstractCollection(UsersTyping, UsersTyping);
export const localBreakoutsSync = new AbstractCollection(Breakouts, Breakouts);
export const localBreakoutsHistorySync = new AbstractCollection(BreakoutsHistory, BreakoutsHistory);
export const localGuestUsersSync = new AbstractCollection(guestUsers, guestUsers);
export const localMeetingsSync = new AbstractCollection(Meetings, Meetings);
export const localUsersSync = new AbstractCollection(Users, Users);
@ -83,6 +85,7 @@ const collectionMirrorInitializer = () => {
localMeetingTimeRemainingSync.setupListeners();
localUsersTypingSync.setupListeners();
localBreakoutsSync.setupListeners();
localBreakoutsHistorySync.setupListeners();
localGuestUsersSync.setupListeners();
localMeetingsSync.setupListeners();
localUsersSync.setupListeners();

View File

@ -0,0 +1,13 @@
import { Meteor } from 'meteor/meteor';
const collectionOptions = Meteor.isClient ? {
connection: null,
} : {};
const BreakoutsHistory = new Mongo.Collection('breakouts-history', collectionOptions);
if (Meteor.isServer) {
BreakoutsHistory._ensureIndex({ meetingId: 1 });
}
export default BreakoutsHistory;

View File

@ -0,0 +1,4 @@
import RedisPubSub from '/imports/startup/server/redis';
import handleBreakoutRoomsList from './handlers/breakoutRoomsList';
RedisPubSub.on('BreakoutRoomsListEvtMsg', handleBreakoutRoomsList);

View File

@ -0,0 +1,35 @@
import { check } from 'meteor/check';
import BreakoutsHistory from '/imports/api/breakouts-history';
import Logger from '/imports/startup/server/logger';
export default function handleBreakoutRoomsList({ body }) {
const {
meetingId,
rooms,
} = body;
check(meetingId, String);
const selector = {
meetingId,
};
const modifier = {
$set: {
meetingId,
rooms,
},
};
try {
const { insertedId } = BreakoutsHistory.upsert(selector, modifier);
if (insertedId) {
Logger.info(`Added rooms to breakout-history Data: meeting=${meetingId}`);
} else {
Logger.info(`Upserted rooms to breakout-history Data: meeting=${meetingId}`);
}
} catch (err) {
Logger.error(`Adding note to the collection breakout-history: ${err}`);
}
}

View File

@ -0,0 +1,2 @@
import './eventHandlers';
import './publishers';

View File

@ -0,0 +1,56 @@
import BreakoutsHistory from '/imports/api/breakouts-history';
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
import Logger from '/imports/startup/server/logger';
import Meetings from '/imports/api/meetings';
import Users from '/imports/api/users';
import { publicationSafeGuard } from '/imports/api/common/server/helpers';
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
function breakoutsHistory() {
const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id });
if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
Logger.warn(`Publishing Meetings-history was requested by unauth connection ${this.connection.id}`);
return Meetings.find({ meetingId: '' });
}
const { meetingId, userId } = tokenValidation;
Logger.debug('Publishing Breakouts-History', { meetingId, userId });
const User = Users.findOne({ userId, meetingId }, { fields: { userId: 1, role: 1 } });
if (!User || User.role !== ROLE_MODERATOR) {
return BreakoutsHistory.find({ meetingId: '' });
}
check(meetingId, String);
const selector = {
meetingId,
};
// Monitor this publication and stop it when user is not a moderator anymore
const comparisonFunc = () => {
const user = Users.findOne({ userId, meetingId }, { fields: { role: 1, userId: 1 } });
const condition = user.role === ROLE_MODERATOR;
if (!condition) {
Logger.info(`conditions aren't filled anymore in publication ${this._name}:
user.role === ROLE_MODERATOR :${condition}, user.role: ${user.role} ROLE_MODERATOR: ${ROLE_MODERATOR}`);
}
return condition;
};
publicationSafeGuard(comparisonFunc, this);
return BreakoutsHistory.find(selector);
}
function publish(...args) {
const boundUsers = breakoutsHistory.bind(this);
return boundUsers(...args);
}
Meteor.publish('breakouts-history', publish);

View File

@ -1,13 +1,12 @@
import RedisPubSub from '/imports/startup/server/redis';
import handleBreakoutJoinURL from './handlers/breakoutJoinURL';
import handleBreakoutStarted from './handlers/breakoutStarted';
import handleBreakoutRoomsList from './handlers/breakoutList';
import handleUpdateTimeRemaining from './handlers/updateTimeRemaining';
import handleBreakoutClosed from './handlers/breakoutClosed';
import joinedUsersChanged from './handlers/joinedUsersChanged';
RedisPubSub.on('BreakoutRoomStartedEvtMsg', handleBreakoutStarted);
RedisPubSub.on('BreakoutRoomsListEvtMsg', handleBreakoutRoomsList);
RedisPubSub.on('BreakoutRoomJoinURLEvtMsg', handleBreakoutJoinURL);
RedisPubSub.on('RequestBreakoutJoinURLRespMsg', handleBreakoutJoinURL);
RedisPubSub.on('BreakoutRoomsTimeRemainingUpdateEvtMsg', handleUpdateTimeRemaining);
RedisPubSub.on('BreakoutRoomEndedEvtMsg', handleBreakoutClosed);
RedisPubSub.on('UpdateBreakoutUsersEvtMsg', joinedUsersChanged);

View File

@ -0,0 +1,60 @@
import Breakouts from '/imports/api/breakouts';
import Logger from '/imports/startup/server/logger';
import { check } from 'meteor/check';
import flat from 'flat';
import handleBreakoutRoomsListHist from '/imports/api/breakouts-history/server/handlers/breakoutRoomsList';
export default function handleBreakoutRoomsList({ body }, meetingId) {
// 0 seconds default breakout time, forces use of real expiration time
const DEFAULT_TIME_REMAINING = 0;
const {
meetingId: parentMeetingId,
rooms,
} = body;
// set firstly the last seq, then client will know when receive all
rooms.sort((a, b) => ((a.sequence < b.sequence) ? 1 : -1)).forEach((breakout) => {
const { breakoutId, html5JoinUrls, ...breakoutWithoutUrls } = breakout;
check(meetingId, String);
const selector = {
breakoutId,
};
const urls = {};
if (typeof html5JoinUrls === 'object' && Object.keys(html5JoinUrls).length > 0) {
Object.keys(html5JoinUrls).forEach((userId) => {
urls[`url_${userId}`] = {
redirectToHtml5JoinURL: html5JoinUrls[userId],
insertedTime: new Date().getTime(),
};
});
}
const modifier = {
$set: {
breakoutId,
joinedUsers: [],
timeRemaining: DEFAULT_TIME_REMAINING,
parentMeetingId,
...flat(breakoutWithoutUrls),
...urls,
},
};
try {
const { numberAffected } = Breakouts.upsert(selector, modifier);
if (numberAffected) {
Logger.info('Updated timeRemaining and externalMeetingId '
+ `for breakout id=${breakoutId}`);
}
} catch (err) {
Logger.error(`updating breakout: ${err}`);
}
});
handleBreakoutRoomsListHist({ body });
}

View File

@ -1,44 +0,0 @@
import Breakouts from '/imports/api/breakouts';
import Logger from '/imports/startup/server/logger';
import { check } from 'meteor/check';
import flat from 'flat';
export default function handleBreakoutRoomStarted({ body }, meetingId) {
// 0 seconds default breakout time, forces use of real expiration time
const DEFAULT_TIME_REMAINING = 0;
const {
parentMeetingId,
breakout,
} = body;
const { breakoutId } = breakout;
check(meetingId, String);
const selector = {
breakoutId,
};
const modifier = {
$set: Object.assign(
{
joinedUsers: [],
},
{ timeRemaining: DEFAULT_TIME_REMAINING },
{ parentMeetingId },
flat(breakout),
),
};
try {
const { numberAffected } = Breakouts.upsert(selector, modifier);
if (numberAffected) {
Logger.info('Updated timeRemaining and externalMeetingId '
+ `for breakout id=${breakoutId}`);
}
} catch (err) {
Logger.error(`updating breakout: ${err}`);
}
}

View File

@ -1,4 +1,5 @@
import Breakouts from '/imports/api/breakouts';
import updateUserBreakoutRoom from '/imports/api/users-persistent-data/server/modifiers/updateUserBreakoutRoom';
import Logger from '/imports/startup/server/logger';
import { check } from 'meteor/check';
@ -31,6 +32,8 @@ export default function joinedUsersChanged({ body }) {
const numberAffected = Breakouts.update(selector, modifier);
if (numberAffected) {
updateUserBreakoutRoom(parentId, breakoutId, users);
Logger.info(`Updated joined users in breakout id=${breakoutId}`);
}
} catch (err) {

View File

@ -117,6 +117,7 @@ export default function addMeeting(meeting) {
systemProps: {
html5InstanceId: Number,
},
groups: Array,
});
const {

View File

@ -0,0 +1,39 @@
import { check } from 'meteor/check';
import UsersPersistentData from '/imports/api/users-persistent-data';
import Logger from '/imports/startup/server/logger';
import Breakouts from '/imports/api/breakouts';
export default function updateUserBreakoutRoom(meetingId, breakoutId, users) {
check(meetingId, String);
check(breakoutId, String);
check(users, Array);
const lastBreakoutRoom = Breakouts.findOne({ breakoutId }, {
fields: {
isDefaultName: 1,
sequence: 1,
shortName: 1,
},
});
users.forEach((user) => {
const userId = user.id.substr(0, user.id.lastIndexOf('-'));
const selector = {
userId,
meetingId,
};
const modifier = {
$set: {
lastBreakoutRoom,
},
};
try {
UsersPersistentData.update(selector, modifier);
} catch (err) {
Logger.error(`Updating users persistent data's lastBreakoutRoom to the collection: ${err}`);
}
});
}

View File

@ -2,6 +2,9 @@ import UsersPersistentData from '/imports/api/users-persistent-data';
import { Meteor } from 'meteor/meteor';
import { extractCredentials } from '/imports/api/common/server/helpers';
import { check } from 'meteor/check';
import Users from '/imports/api/users';
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
function usersPersistentData() {
if (!this.userId) {
@ -16,7 +19,16 @@ function usersPersistentData() {
meetingId,
};
return UsersPersistentData.find(selector);
const options = {};
const User = Users.findOne({ userId: requesterUserId, meetingId }, { fields: { role: 1 } });
if (!User || User.role !== ROLE_MODERATOR) {
options.fields = {
lastBreakoutRoom: false,
};
}
return UsersPersistentData.find(selector, options);
}
function publishUsersPersistentData(...args) {

View File

@ -19,8 +19,6 @@ const oldParameters = {
listenOnlyMode: 'bbb_listen_only_mode',
multiUserPenOnly: 'bbb_multi_user_pen_only',
multiUserTools: 'bbb_multi_user_tools',
outsideToggleRecording: 'bbb_outside_toggle_recording',
outsideToggleSelfVoice: 'bbb_outside_toggle_self_voice',
presenterTools: 'bbb_presenter_tools',
shortcuts: 'bbb_shortcuts',
skipCheck: 'bbb_skip_check_audio',
@ -67,9 +65,6 @@ const currentParameters = [
'bbb_show_public_chat_on_login',
'bbb_hide_actions_bar',
'bbb_hide_nav_bar',
// OUTSIDE COMMANDS
'bbb_outside_toggle_self_voice',
'bbb_outside_toggle_recording',
];
function valueParser(val) {

View File

@ -37,6 +37,14 @@ const intlMessages = defineMessages({
id: 'app.createBreakoutRoom.durationInMinutes',
description: 'duration time label',
},
resetAssignments: {
id: 'app.createBreakoutRoom.resetAssignments',
description: 'reset assignments label',
},
resetAssignmentsDesc: {
id: 'app.createBreakoutRoom.resetAssignmentsDesc',
description: 'reset assignments label description',
},
randomlyAssign: {
id: 'app.createBreakoutRoom.randomlyAssign',
description: 'randomly assign label',
@ -165,6 +173,7 @@ class BreakoutRoom extends PureComponent {
this.setFreeJoin = this.setFreeJoin.bind(this);
this.getUserByRoom = this.getUserByRoom.bind(this);
this.onAssignRandomly = this.onAssignRandomly.bind(this);
this.onAssignReset = this.onAssignReset.bind(this);
this.onInviteBreakout = this.onInviteBreakout.bind(this);
this.renderUserItemByRoom = this.renderUserItemByRoom.bind(this);
this.renderRoomsGrid = this.renderRoomsGrid.bind(this);
@ -210,16 +219,24 @@ class BreakoutRoom extends PureComponent {
}
componentDidMount() {
const { isInvitation, breakoutJoinedUsers } = this.props;
const {
isInvitation, breakoutJoinedUsers, getLastBreakouts, groups,
} = this.props;
this.setRoomUsers();
if (isInvitation) {
this.setInvitationConfig();
}
if (isInvitation) {
this.setState({
breakoutJoinedUsers,
});
}
const lastBreakouts = getLastBreakouts();
if (lastBreakouts.length > 0) {
this.populateWithLastBreakouts(lastBreakouts);
} else if (groups && groups.length > 0) {
this.populateWithGroups(groups);
}
}
componentDidUpdate(prevProps, prevstate) {
@ -427,6 +444,16 @@ class BreakoutRoom extends PureComponent {
}
}
onAssignReset() {
const { users } = this.state;
users.forEach((u) => {
if (u.room !== null && u.room > 0) {
this.changeUserRoom(u.userId, 0);
}
});
}
setInvitationConfig() {
const { getBreakouts } = this.props;
this.setState({
@ -583,6 +610,68 @@ class BreakoutRoom extends PureComponent {
return false;
}
populateWithLastBreakouts(lastBreakouts) {
const { getBreakoutUserWasIn, users, intl } = this.props;
const changedNames = [];
lastBreakouts.forEach((breakout) => {
if (breakout.isDefaultName === false) {
changedNames[breakout.sequence] = breakout.shortName;
}
});
this.setState({
roomNamesChanged: changedNames,
numberOfRooms: lastBreakouts.length,
roomNameDuplicatedIsValid: true,
roomNameEmptyIsValid: true,
}, () => {
const rooms = _.range(1, lastBreakouts.length + 1).map((seq) => this.getRoomName(seq));
users.forEach((u) => {
const lastUserBreakout = getBreakoutUserWasIn(u.userId, u.extId);
if (lastUserBreakout !== null) {
const lastUserBreakoutName = lastUserBreakout.isDefaultName === false
? lastUserBreakout.shortName
: intl.formatMessage(intlMessages.breakoutRoom, { 0: lastUserBreakout.sequence });
if (rooms.indexOf(lastUserBreakoutName) !== false) {
this.changeUserRoom(u.userId, rooms.indexOf(lastUserBreakoutName) + 1);
}
}
});
});
}
populateWithGroups(groups) {
const { users } = this.props;
const changedNames = [];
groups.forEach((group, idx) => {
if (group.name.length > 0) {
changedNames[idx + 1] = group.name;
}
});
this.setState({
roomNamesChanged: changedNames,
numberOfRooms: groups.length > 1 ? groups.length : 2,
roomNameDuplicatedIsValid: true,
roomNameEmptyIsValid: true,
}, () => {
groups.forEach((group, groupIdx) => {
const usersInGroup = group.usersExtId;
if (usersInGroup.length > 0) {
usersInGroup.forEach((groupUserExtId) => {
users.filter((u) => u.extId === groupUserExtId).forEach((foundUser) => {
this.changeUserRoom(foundUser.userId, groupIdx + 1);
});
});
}
});
});
}
renderRoomsGrid() {
const { intl, isInvitation } = this.props;
const {
@ -764,15 +853,26 @@ class BreakoutRoom extends PureComponent {
}
</Styled.SpanWarn>
</Styled.DurationLabel>
<Styled.RandomlyAssignBtn
data-test="randomlyAssign"
label={intl.formatMessage(intlMessages.randomlyAssign)}
aria-describedby="randomlyAssignDesc"
onClick={this.onAssignRandomly}
size="sm"
color="default"
disabled={!numberOfRoomsIsValid}
/>
<Styled.AssignBtnsContainer>
<Styled.AssignBtns
data-test="randomlyAssign"
label={intl.formatMessage(intlMessages.randomlyAssign)}
aria-describedby="randomlyAssignDesc"
onClick={this.onAssignRandomly}
size="sm"
color="default"
disabled={!numberOfRoomsIsValid}
/>
<Styled.AssignBtns
data-test="resetAssignments"
label={intl.formatMessage(intlMessages.resetAssignments)}
aria-describedby="resetAssignmentsDesc"
onClick={this.onAssignReset}
size="sm"
color="default"
disabled={!numberOfRoomsIsValid}
/>
</Styled.AssignBtnsContainer>
</Styled.BreakoutSettings>
<Styled.SpanWarn valid={numberOfRoomsIsValid}>
{intl.formatMessage(intlMessages.numberOfRoomsIsValid)}

View File

@ -1,6 +1,7 @@
import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import ActionsBarService from '/imports/ui/components/actions-bar/service';
import BreakoutRoomService from '/imports/ui/components/breakout-room/service';
import CreateBreakoutRoomModal from './component';
@ -17,10 +18,13 @@ const CreateBreakoutRoomContainer = (props) => {
export default withTracker(() => ({
createBreakoutRoom: ActionsBarService.createBreakoutRoom,
getBreakouts: ActionsBarService.getBreakouts,
getLastBreakouts: ActionsBarService.getLastBreakouts,
getBreakoutUserWasIn: BreakoutRoomService.getBreakoutUserWasIn,
getUsersNotAssigned: ActionsBarService.getUsersNotAssigned,
sendInvitation: ActionsBarService.sendInvitation,
breakoutJoinedUsers: ActionsBarService.breakoutJoinedUsers(),
users: ActionsBarService.users(),
groups: ActionsBarService.groups(),
isMe: ActionsBarService.isMe,
meetingName: ActionsBarService.meetingName(),
amIModerator: ActionsBarService.amIModerator(),

View File

@ -190,7 +190,11 @@ const HoldButtonWrapper = styled(HoldButton)`
}
`;
const RandomlyAssignBtn = styled(Button)`
const AssignBtnsContainer = styled.div`
margin-top: auto;
`;
const AssignBtns = styled(Button)`
color: ${colorPrimary};
font-size: ${fontSizeSmall};
white-space: nowrap;
@ -302,7 +306,8 @@ export default {
DurationArea,
DurationInput,
HoldButtonWrapper,
RandomlyAssignBtn,
AssignBtnsContainer,
AssignBtns,
CheckBoxesContainer,
FreeJoinCheckbox,
RoomUserItem,

View File

@ -4,6 +4,7 @@ import { makeCall } from '/imports/ui/services/api';
import Meetings from '/imports/api/meetings';
import Breakouts from '/imports/api/breakouts';
import { getVideoUrl } from '/imports/ui/components/external-video-player/service';
import BreakoutsHistory from '/imports/api/breakouts-history';
const USER_CONFIG = Meteor.settings.public.user;
const ROLE_MODERATOR = USER_CONFIG.role_moderator;
@ -13,6 +14,16 @@ const getBreakouts = () => Breakouts.find({ parentMeetingId: Auth.meetingID })
.fetch()
.sort((a, b) => a.sequence - b.sequence);
const getLastBreakouts = () => {
const lastBreakouts = BreakoutsHistory.findOne({ meetingId: Auth.meetingID });
if (lastBreakouts) {
return lastBreakouts.rooms
.sort((a, b) => a.sequence - b.sequence);
}
return [];
};
const currentBreakoutUsers = (user) => !Breakouts.findOne({
'joinedUsers.userId': new RegExp(`^${user.userId}`),
});
@ -47,6 +58,8 @@ export default {
meetingId: Auth.meetingID,
clientType: { $ne: DIAL_IN_USER },
}).fetch(),
groups: () => Meetings.findOne({ meetingId: Auth.meetingID },
{ fields: { groups: 1 } }).groups,
isBreakoutEnabled: () => Meetings.findOne({ meetingId: Auth.meetingID },
{ fields: { 'breakoutProps.enabled': 1 } }).breakoutProps.enabled,
isBreakoutRecordable: () => Meetings.findOne({ meetingId: Auth.meetingID },
@ -58,6 +71,7 @@ export default {
joinedUsers: { $exists: true },
}, { fields: { joinedUsers: 1, breakoutId: 1, sequence: 1 }, sort: { sequence: 1 } }).fetch(),
getBreakouts,
getLastBreakouts,
getUsersNotAssigned,
takePresenterRole,
isSharingVideo: () => getVideoUrl(),

View File

@ -30,7 +30,6 @@ const intlMessages = defineMessages({
const propTypes = {
shortcuts: PropTypes.objectOf(PropTypes.string).isRequired,
processToggleMuteFromOutside: PropTypes.func.isRequired,
handleToggleMuteMicrophone: PropTypes.func.isRequired,
handleJoinAudio: PropTypes.func.isRequired,
handleLeaveAudio: PropTypes.func.isRequired,
@ -54,14 +53,6 @@ class AudioControls extends PureComponent {
this.renderJoinLeaveButton = this.renderJoinLeaveButton.bind(this);
}
componentDidMount() {
const { processToggleMuteFromOutside } = this.props;
if (Meteor.settings.public.allowOutsideCommands.toggleSelfVoice
|| getFromUserSettings('bbb_outside_toggle_self_voice', false)) {
window.addEventListener('message', processToggleMuteFromOutside);
}
}
renderJoinButton() {
const {
handleJoinAudio,

View File

@ -2,7 +2,6 @@ import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import { withModalMounter } from '/imports/ui/components/modal/service';
import AudioManager from '/imports/ui/services/audio-manager';
import { makeCall } from '/imports/ui/services/api';
import lockContextContainer from '/imports/ui/components/lock-viewers/context/container';
import { withUsersConsumer } from '/imports/ui/components/components-data/users-context/context';
import logger from '/imports/startup/client/logger';
@ -29,28 +28,6 @@ const AudioControlsContainer = (props) => {
return <AudioControls {...newProps} />;
};
const processToggleMuteFromOutside = (e) => {
switch (e.data) {
case 'c_mute': {
makeCall('toggleVoice');
break;
}
case 'get_audio_joined_status': {
const audioJoinedState = AudioManager.isConnected ? 'joinedAudio' : 'notInAudio';
this.window.parent.postMessage({ response: audioJoinedState }, '*');
break;
}
case 'c_mute_status': {
const muteState = AudioManager.isMuted ? 'selfMuted' : 'selfUnmuted';
this.window.parent.postMessage({ response: muteState }, '*');
break;
}
default: {
// console.log(e.data);
}
}
};
const handleLeaveAudio = () => {
const meetingIsBreakout = AppService.meetingIsBreakout();
@ -100,7 +77,6 @@ export default withUsersConsumer(
}
return ({
processToggleMuteFromOutside: (arg) => processToggleMuteFromOutside(arg),
showMute: isConnected() && !isListenOnly() && !isEchoTest() && !userLocks.userMic,
muted: isConnected() && !isListenOnly() && isMuted(),
inAudio: isConnected() && !isEchoTest(),

View File

@ -66,25 +66,27 @@ class BreakoutRoomInvitation extends Component {
const hasBreakouts = breakouts.length > 0;
if (hasBreakouts && !breakoutUserIsIn && BreakoutService.checkInviteModerators()) {
// Have to check for freeJoin breakouts first because currentBreakoutUrlData will
// populate after a room has been joined
const breakoutRoom = getBreakoutByUrlData(currentBreakoutUrlData);
const freeJoinBreakout = breakouts.find((breakout) => breakout.freeJoin);
if (freeJoinBreakout) {
if (!didSendBreakoutInvite) {
this.inviteUserToBreakout(breakoutRoom || freeJoinBreakout);
this.setState({ didSendBreakoutInvite: true });
}
} else if (currentBreakoutUrlData) {
const freeJoinRooms = breakouts.filter((breakout) => breakout.freeJoin);
if (currentBreakoutUrlData) {
const breakoutRoom = getBreakoutByUrlData(currentBreakoutUrlData);
const currentInsertedTime = currentBreakoutUrlData.insertedTime;
const oldCurrentUrlData = oldProps.currentBreakoutUrlData || {};
const oldInsertedTime = oldCurrentUrlData.insertedTime;
if (currentInsertedTime !== oldInsertedTime) {
const breakoutId = Session.get('lastBreakoutOpened');
if (breakoutRoom.breakoutId !== breakoutId) {
const lastBreakoutId = Session.get('lastBreakoutOpened');
if (breakoutRoom.breakoutId !== lastBreakoutId) {
this.inviteUserToBreakout(breakoutRoom);
}
}
} else if (freeJoinRooms.length > 0 && !didSendBreakoutInvite) {
const maxSeq = Math.max(...freeJoinRooms.map(((room) => room.sequence)));
// Check if received all rooms and Pick a room randomly
if (maxSeq === freeJoinRooms.length) {
const randomRoom = freeJoinRooms[Math.floor(Math.random() * freeJoinRooms.length)];
this.inviteUserToBreakout(randomRoom);
this.setState({ didSendBreakoutInvite: true });
}
}
}

View File

@ -1,11 +1,11 @@
import Breakouts from '/imports/api/breakouts';
import { MeetingTimeRemaining } from '/imports/api/meetings';
import Meetings from '/imports/api/meetings';
import { MeetingTimeRemaining, Meetings } from '/imports/api/meetings';
import { makeCall } from '/imports/ui/services/api';
import Auth from '/imports/ui/services/auth';
import Users from '/imports/api/users';
import UserListService from '/imports/ui/components/user-list/service';
import fp from 'lodash/fp';
import UsersPersistentData from '/imports/api/users-persistent-data';
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
@ -155,6 +155,33 @@ const getBreakoutUserIsIn = (userId) =>
{ fields: { sequence: 1 } }
);
const getBreakoutUserWasIn = (userId, extId) => {
const selector = {
meetingId: Auth.meetingID,
lastBreakoutRoom: { $exists: 1 },
};
if (extId !== null) {
selector.extId = extId;
} else {
selector.userId = userId;
}
const users = UsersPersistentData.find(
selector,
{ fields: { userId: 1, lastBreakoutRoom: 1 } },
).fetch();
if (users.length > 0) {
const hasCurrUserId = users.filter((user) => user.userId === userId);
if (hasCurrUserId.length > 0) return hasCurrUserId.pop().lastBreakoutRoom;
return users.pop().lastBreakoutRoom;
}
return null;
};
const isUserInBreakoutRoom = (joinedUsers) => {
const userId = Auth.userID;
@ -177,6 +204,7 @@ export default {
getBreakouts,
getBreakoutsNoTime,
getBreakoutUserIsIn,
getBreakoutUserWasIn,
sortUsersByName: UserListService.sortUsersByName,
isUserInBreakoutRoom,
checkInviteModerators,

View File

@ -2,7 +2,6 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withModalMounter } from '/imports/ui/components/modal/service';
import withShortcutHelper from '/imports/ui/components/shortcut-help/service';
import getFromUserSettings from '/imports/ui/services/users-settings';
import { defineMessages, injectIntl } from 'react-intl';
import Styled from './styles';
import RecordingIndicator from './recording-indicator/container';
@ -50,20 +49,12 @@ class NavBar extends Component {
componentDidMount() {
const {
processOutsideToggleRecording,
connectRecordingObserver,
shortcuts: TOGGLE_USERLIST_AK,
} = this.props;
const { isFirefox } = browserInfo;
const { isMacos } = deviceInfo;
if (Meteor.settings.public.allowOutsideCommands.toggleRecording
|| getFromUserSettings('bbb_outside_toggle_recording', false)) {
connectRecordingObserver();
window.addEventListener('message', processOutsideToggleRecording);
}
// accessKey U does not work on firefox for macOS for some unknown reason
if (isMacos && isFirefox && TOGGLE_USERLIST_AK === 'U') {
document.addEventListener('keyup', (event) => {

View File

@ -9,7 +9,6 @@ import { ChatContext } from '/imports/ui/components/components-data/chat-context
import { GroupChatContext } from '/imports/ui/components/components-data/group-chat-context/context';
import { UsersContext } from '/imports/ui/components/components-data/users-context/context';
import NoteService from '/imports/ui/components/note/service';
import Service from './service';
import NavBar from './component';
import { layoutSelectInput, layoutSelectOutput, layoutDispatch } from '../layout/context';
@ -100,12 +99,8 @@ export default withTracker(() => {
document.title = titleString;
}
const { connectRecordingObserver, processOutsideToggleRecording } = Service;
return {
currentUserId: Auth.userID,
processOutsideToggleRecording,
connectRecordingObserver,
meetingId,
presentationTitle: meetingTitle,
};

View File

@ -15,18 +15,6 @@ export default withTracker(() => {
const meetingId = Auth.meetingID;
const recordObeject = RecordMeetings.findOne({ meetingId });
RecordMeetings.find({ meetingId: Auth.meetingID }, { fields: { recording: 1 } }).observeChanges({
changed: (id, fields) => {
if (fields && fields.recording) {
this.window.parent.postMessage({ response: 'recordingStarted' }, '*');
}
if (fields && !fields.recording) {
this.window.parent.postMessage({ response: 'recordingStopped' }, '*');
}
},
});
const micUser = VoiceUsers.findOne({ meetingId, joined: true, listenOnly: false }, {
fields: {
joined: 1,

View File

@ -1,31 +0,0 @@
import Auth from '/imports/ui/services/auth';
import { makeCall } from '/imports/ui/services/api';
import Meetings from '/imports/api/meetings';
const processOutsideToggleRecording = (e) => {
switch (e.data) {
case 'c_record': {
makeCall('toggleRecording');
break;
}
case 'c_recording_status': {
const recordingState = (Meetings.findOne({ meetingId: Auth.meetingID })).recording;
const recordingMessage = recordingState ? 'recordingStarted' : 'recordingStopped';
this.window.parent.postMessage({ response: recordingMessage }, '*');
break;
}
default: {
// console.log(e.data);
}
}
};
const connectRecordingObserver = () => {
// notify on load complete
this.window.parent.postMessage({ response: 'readyToConnect' }, '*');
};
export default {
connectRecordingObserver: () => connectRecordingObserver(),
processOutsideToggleRecording: arg => processOutsideToggleRecording(arg),
};

View File

@ -9,6 +9,7 @@ import AnnotationsTextService from '/imports/ui/components/whiteboard/annotation
import { Annotations as AnnotationsLocal } from '/imports/ui/components/whiteboard/service';
import {
localBreakoutsSync,
localBreakoutsHistorySync,
localGuestUsersSync,
localMeetingsSync,
localUsersSync,
@ -25,7 +26,7 @@ const SUBSCRIPTIONS = [
'voiceUsers', 'whiteboard-multi-user', 'screenshare', 'group-chat',
'presentation-pods', 'users-settings', 'guestUser', 'users-infos', 'note', 'meeting-time-remaining',
'local-settings', 'users-typing', 'record-meetings', 'video-streams',
'connection-status', 'voice-call-states', 'external-video-meetings', 'breakouts',
'connection-status', 'voice-call-states', 'external-video-meetings', 'breakouts', 'breakouts-history',
];
const EVENT_NAME = 'bbb-group-chat-messages-subscription-has-stoppped';
@ -101,6 +102,7 @@ export default withTracker(() => {
// let this withTracker re-execute when a subscription is stopped
subscriptionReactivity.depend();
localBreakoutsSync.setIgnoreDeletes(true);
localBreakoutsHistorySync.setIgnoreDeletes(true);
localGuestUsersSync.setIgnoreDeletes(true);
localMeetingsSync.setIgnoreDeletes(true);
localUsersSync.setIgnoreDeletes(true);
@ -110,6 +112,7 @@ export default withTracker(() => {
SubscriptionRegistry.getSubscription('meetings'),
SubscriptionRegistry.getSubscription('users'),
SubscriptionRegistry.getSubscription('breakouts'),
SubscriptionRegistry.getSubscription('breakouts-history'),
SubscriptionRegistry.getSubscription('guestUser'),
].forEach((item) => {
if (item) item.stop();

View File

@ -40,6 +40,7 @@ class UserListItem extends PureComponent {
toggleUserLock,
requestUserInformation,
userInBreakout,
userLastBreakout,
breakoutSequence,
meetingIsBreakout,
isMeteorConnected,
@ -78,6 +79,7 @@ class UserListItem extends PureComponent {
toggleUserLock,
requestUserInformation,
userInBreakout,
userLastBreakout,
breakoutSequence,
meetingIsBreakout,
isMeteorConnected,

View File

@ -16,6 +16,7 @@ const isMe = (intId) => intId === Auth.userID;
export default withTracker(({ user }) => {
const findUserInBreakout = BreakoutService.getBreakoutUserIsIn(user.userId);
const findUserLastBreakout = BreakoutService.getBreakoutUserWasIn(user.userId, null);
const breakoutSequence = (findUserInBreakout || {}).sequence;
const Meeting = Meetings.findOne({ meetingId: Auth.meetingID },
{ fields: { lockSettingsProps: 1 } });
@ -24,6 +25,7 @@ export default withTracker(({ user }) => {
user,
isMe,
userInBreakout: !!findUserInBreakout,
userLastBreakout: findUserLastBreakout,
breakoutSequence,
lockSettingsProps: Meeting && Meeting.lockSettingsProps,
isMeteorConnected: Meteor.status().connected,

View File

@ -572,6 +572,7 @@ class UserDropdown extends PureComponent {
user,
intl,
isThisMeetingLocked,
userLastBreakout,
isMe,
isRTL,
} = this.props;
@ -612,6 +613,7 @@ class UserDropdown extends PureComponent {
isThisMeetingLocked,
userAriaLabel,
isActionsOpen,
userLastBreakout,
isMe,
}}
/>

View File

@ -42,6 +42,10 @@ const messages = defineMessages({
id: 'app.userList.userAriaLabel',
description: 'aria label for each user in the userlist',
},
breakoutRoom: {
id: 'app.createBreakoutRoom.room',
description: 'breakout room',
},
});
const propTypes = {
@ -68,6 +72,7 @@ const UserName = (props) => {
isThisMeetingLocked,
userAriaLabel,
isActionsOpen,
userLastBreakout,
isMe,
user,
} = props;
@ -110,6 +115,18 @@ const UserName = (props) => {
if (LABEL.guest) userNameSub.push(intl.formatMessage(messages.guest));
}
if (userLastBreakout) {
userNameSub.push(
<span key={_.uniqueId('breakout-')}>
<Icon iconName="rooms" />
&nbsp;
{userLastBreakout.isDefaultName
? intl.formatMessage(messages.breakoutRoom, { 0: userLastBreakout.sequence })
: userLastBreakout.shortName}
</span>,
);
}
return (
<Styled.UserName
role="button"

View File

@ -22,7 +22,7 @@ export default withTracker((params) => {
} = params;
const fetchFunc = published
? AnnotationGroupService.getCurrentAnnotationsInfo : AnnotationGroupService.getUnsetAnnotations;
? AnnotationGroupService.getCurrentAnnotationsInfo : AnnotationGroupService.getUnsentAnnotations;
const annotationsInfo = fetchFunc(whiteboardId);
return {

View File

@ -16,7 +16,7 @@ const getCurrentAnnotationsInfo = (whiteboardId) => {
).fetch();
};
const getUnsetAnnotations = (whiteboardId) => {
const getUnsentAnnotations = (whiteboardId) => {
if (!whiteboardId) {
return null;
}
@ -34,5 +34,5 @@ const getUnsetAnnotations = (whiteboardId) => {
export default {
getCurrentAnnotationsInfo,
getUnsetAnnotations,
getUnsentAnnotations,
};

View File

@ -393,8 +393,6 @@ class AudioManager {
muteState = 'selfUnmuted';
this.unmute();
}
window.parent.postMessage({ response: muteState }, '*');
}
if (fields.talking !== undefined && fields.talking !== this.isTalking) {
@ -431,7 +429,6 @@ class AudioManager {
}
if (!this.isEchoTest) {
window.parent.postMessage({ response: 'joinedAudio' }, '*');
this.notify(this.intl.formatMessage(this.messages.info.JOINED_AUDIO));
logger.info({ logCode: 'audio_joined' }, 'Audio Joined');
this.inputStream = (this.bridge ? this.bridge.inputStream : null);
@ -473,7 +470,6 @@ class AudioManager {
this.playHangUpSound();
}
window.parent.postMessage({ response: 'notInAudio' }, '*');
window.removeEventListener('audioPlayFailed', this.handlePlayElementFailed);
}

View File

@ -3574,6 +3574,64 @@
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
},
"meow": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz",
"integrity": "sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==",
"requires": {
"@types/minimist": "^1.2.0",
"camelcase-keys": "^6.2.2",
"decamelize": "^1.2.0",
"decamelize-keys": "^1.1.0",
"hard-rejection": "^2.1.0",
"minimist-options": "4.1.0",
"normalize-package-data": "^3.0.0",
"read-pkg-up": "^7.0.1",
"redent": "^3.0.0",
"trim-newlines": "^3.0.0",
"type-fest": "^0.18.0",
"yargs-parser": "^20.2.3"
},
"dependencies": {
"hosted-git-info": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.0.2.tgz",
"integrity": "sha512-c9OGXbZ3guC/xOlCg1Ci/VgWlwsqDv1yMQL1CWqXDL0hDjXuNcq0zuR4xqPSuasI3kqFDhqSyTjREz5gzq0fXg==",
"requires": {
"lru-cache": "^6.0.0"
}
},
"normalize-package-data": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.2.tgz",
"integrity": "sha512-6CdZocmfGaKnIHPVFhJJZ3GuR8SsLKvDANFp47Jmy51aKIr8akjAWTSxtpI+MBgBFdSMRyo4hMpDlT6dTffgZg==",
"requires": {
"hosted-git-info": "^4.0.1",
"resolve": "^1.20.0",
"semver": "^7.3.4",
"validate-npm-package-license": "^3.0.1"
}
},
"semver": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
"integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
"requires": {
"lru-cache": "^6.0.0"
}
},
"trim-newlines": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-4.0.2.tgz",
"integrity": "sha512-GJtWyq9InR/2HRiLZgpIKv+ufIKrVrvjQWEj7PxAXNc5dwbNJkqhAUoAGgzRmULAnoOM5EIpveYd3J2VeSAIew=="
},
"type-fest": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz",
"integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw=="
}
}
},
"merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",

View File

@ -411,9 +411,6 @@ public:
syncUsersWithConnectionManager:
enabled: false
syncInterval: 60000
allowOutsideCommands:
toggleRecording: false
toggleSelfVoice: false
poll:
enabled: true
maxCustom: 5

View File

@ -847,6 +847,8 @@
"app.createBreakoutRoom.durationInMinutes": "Duration (minutes)",
"app.createBreakoutRoom.randomlyAssign": "Randomly assign",
"app.createBreakoutRoom.randomlyAssignDesc": "Assigns users randomly to breakout rooms",
"app.createBreakoutRoom.resetAssignments": "Reset assignments",
"app.createBreakoutRoom.resetAssignmentsDesc": "Reset all user room assignments",
"app.createBreakoutRoom.endAllBreakouts": "End all breakout rooms",
"app.createBreakoutRoom.roomName": "{0} (Room - {1})",
"app.createBreakoutRoom.doneLabel": "Done",
@ -926,15 +928,18 @@
"playback.player.thumbnails.wrapper.aria": "Thumbnails area",
"playback.player.video.wrapper.aria": "Video area",
"app.learningDashboard.dashboardTitle": "Learning Dashboard",
"app.learningDashboard.user": "User",
"app.learningDashboard.downloadSessionDataLabel": "Download Session Data",
"app.learningDashboard.lastUpdatedLabel": "Last updated at",
"app.learningDashboard.sessionDataDownloadedLabel": "Downloaded!",
"app.learningDashboard.shareButton": "Share with others",
"app.learningDashboard.shareLinkCopied": "Link successfully copied!",
"app.learningDashboard.user": "Users",
"app.learningDashboard.indicators.meetingStatusEnded": "Ended",
"app.learningDashboard.indicators.meetingStatusActive": "Active",
"app.learningDashboard.indicators.usersOnline": "Active Users",
"app.learningDashboard.indicators.usersTotal": "Total Number Of Users",
"app.learningDashboard.indicators.polls": "Polls",
"app.learningDashboard.indicators.raiseHand": "Raise Hand",
"app.learningDashboard.indicators.timeline": "Timeline",
"app.learningDashboard.indicators.activityScore": "Activity Score",
"app.learningDashboard.indicators.duration": "Duration",
"app.learningDashboard.usersTable.title": "Overview",
@ -949,10 +954,17 @@
"app.learningDashboard.usersTable.userStatusOnline": "Online",
"app.learningDashboard.usersTable.userStatusOffline": "Offline",
"app.learningDashboard.usersTable.noUsers": "No users yet",
"app.learningDashboard.usersTable.name": "Name",
"app.learningDashboard.usersTable.moderator": "Moderator",
"app.learningDashboard.usersTable.pollVotes": "Poll Votes",
"app.learningDashboard.usersTable.join": "Join",
"app.learningDashboard.usersTable.left": "Left",
"app.learningDashboard.pollsTable.title": "Polling",
"app.learningDashboard.pollsTable.anonymousAnswer": "Anonymous Poll (answers in the last row)",
"app.learningDashboard.pollsTable.anonymousRowName": "Anonymous",
"app.learningDashboard.statusTimelineTable.title": "Status Timeline",
"app.learningDashboard.pollsTable.noPollsCreatedHeading": "No polls have been created",
"app.learningDashboard.pollsTable.noPollsCreatedMessage": "Once a poll has been sent to users, their results will appear in this list.",
"app.learningDashboard.statusTimelineTable.title": "Timeline",
"app.learningDashboard.errors.invalidToken": "Invalid session token",
"app.learningDashboard.errors.dataUnavailable": "Data is no longer available"
}

View File

@ -12,6 +12,7 @@ import '/imports/api/presentation-pods/server';
import '/imports/api/presentation-upload-token/server';
import '/imports/api/slides/server';
import '/imports/api/breakouts/server';
import '/imports/api/breakouts-history/server';
import '/imports/api/group-chat/server';
import '/imports/api/group-chat-msg/server';
import '/imports/api/screenshare/server';

View File

@ -49,7 +49,7 @@ module BigBlueButton
BigBlueButton.logger.info("Task: Getting meeting metadata")
doc = Nokogiri::XML(File.open(events_xml))
metadata = {}
doc.xpath("//metadata").each do |e|
doc.xpath("recording/metadata").each do |e|
e.keys.each do |k|
metadata[k] = e.attribute(k)
end
@ -613,7 +613,7 @@ module BigBlueButton
def self.get_record_status_events(events_xml)
BigBlueButton.logger.info "Getting record status events"
rec_events = []
events_xml.xpath("//event[@eventname='RecordStatusEvent']").each do |event|
events_xml.xpath("recording/event[@eventname='RecordStatusEvent']").each do |event|
s = { :timestamp => event['timestamp'].to_i }
rec_events << s
end
@ -623,14 +623,14 @@ module BigBlueButton
def self.get_external_video_events(events_xml)
BigBlueButton.logger.info "Getting external video events"
external_videos_events = []
events_xml.xpath("//event[@eventname='StartExternalVideoRecordEvent']").each do |event|
events_xml.xpath("recording/event[@eventname='StartExternalVideoRecordEvent']").each do |event|
s = {
:timestamp => event['timestamp'].to_i,
:external_video_url => event.at_xpath("externalVideoUrl").text
}
external_videos_events << s
end
events_xml.xpath("//event[@eventname='StopExternalVideoRecordEvent']").each do |event|
events_xml.xpath("recording/event[@eventname='StopExternalVideoRecordEvent']").each do |event|
s = { :timestamp => event['timestamp'].to_i }
external_videos_events << s
end

View File

@ -28,16 +28,17 @@ require File.expand_path('../../edl', __FILE__)
module BigBlueButton
def BigBlueButton.process_webcam_videos(target_dir, temp_dir, meeting_id, output_width, output_height, output_framerate, audio_offset, processed_audio_file, video_formats=['webm'])
def BigBlueButton.process_webcam_videos(target_dir, raw_archive_dir, output_width, output_height, output_framerate, audio_offset, processed_audio_file, video_formats=['webm'])
BigBlueButton.logger.info("Processing webcam videos")
events = Nokogiri::XML(File.open("#{temp_dir}/#{meeting_id}/events.xml"))
# raw_archive_dir already contains meeting_id
events = Nokogiri::XML(File.open("#{raw_archive_dir}/events.xml"))
# Process user video (camera)
start_time = BigBlueButton::Events.first_event_timestamp(events)
end_time = BigBlueButton::Events.last_event_timestamp(events)
webcam_edl = BigBlueButton::Events.create_webcam_edl(
events, "#{temp_dir}/#{meeting_id}")
events, raw_archive_dir)
user_video_edl = BigBlueButton::Events.edl_match_recording_marks_video(
webcam_edl, events, start_time, end_time)
BigBlueButton::EDL::Video.dump(user_video_edl)
@ -91,15 +92,16 @@ module BigBlueButton
end
end
def BigBlueButton.process_deskshare_videos(target_dir, temp_dir, meeting_id, output_width, output_height, output_framerate, video_formats=['webm'])
def BigBlueButton.process_deskshare_videos(target_dir, raw_archive_dir, output_width, output_height, output_framerate, video_formats=['webm'])
BigBlueButton.logger.info("Processing deskshare videos")
events = Nokogiri::XML(File.open("#{temp_dir}/#{meeting_id}/events.xml"))
# raw_archive_dir already contains meeting_id
events = Nokogiri::XML(File.open("#{raw_archive_dir}/events.xml"))
start_time = BigBlueButton::Events.first_event_timestamp(events)
end_time = BigBlueButton::Events.last_event_timestamp(events)
deskshare_edl = BigBlueButton::Events.create_deskshare_edl(
events, "#{temp_dir}/#{meeting_id}")
events, raw_archive_dir)
deskshare_video_edl = BigBlueButton::Events.edl_match_recording_marks_video(
deskshare_edl, events, start_time, end_time)
@ -168,5 +170,3 @@ module BigBlueButton
end
end

View File

@ -1,3 +1,4 @@
# frozen_string_literal: true
# Set encoding to utf-8
# encoding: UTF-8
@ -31,15 +32,15 @@ require 'trollop'
require 'yaml'
require 'json'
opts = Trollop::options do
opt :meeting_id, "Meeting id to archive", :default => '58f4a6b3-cd07-444d-8564-59116cb53974', :type => String
opts = Trollop.options do
opt :meeting_id, 'Meeting id to archive', default: '58f4a6b3-cd07-444d-8564-59116cb53974', type: String
end
meeting_id = opts[:meeting_id]
# This script lives in scripts/archive/steps while properties.yaml lives in scripts/
props = BigBlueButton.read_props
presentation_props = YAML::load(File.open('presentation.yml'))
presentation_props = YAML.safe_load(File.open('presentation.yml'))
presentation_props['audio_offset'] = 0 if presentation_props['audio_offset'].nil?
presentation_props['include_deskshare'] = false if presentation_props['include_deskshare'].nil?
@ -48,41 +49,38 @@ raw_archive_dir = "#{recording_dir}/raw/#{meeting_id}"
log_dir = props['log_dir']
target_dir = "#{recording_dir}/process/presentation/#{meeting_id}"
if not FileTest.directory?(target_dir)
unless FileTest.directory?(target_dir)
FileUtils.mkdir_p "#{log_dir}/presentation"
logger = Logger.new("#{log_dir}/presentation/process-#{meeting_id}.log", 'daily' )
logger = Logger.new("#{log_dir}/presentation/process-#{meeting_id}.log", 'daily')
BigBlueButton.logger = logger
BigBlueButton.logger.info("Processing script presentation.rb")
BigBlueButton.logger.info('Processing script presentation.rb')
FileUtils.mkdir_p target_dir
begin
# Create a copy of the raw archives
temp_dir = "#{target_dir}/temp"
FileUtils.mkdir_p temp_dir
FileUtils.cp_r(raw_archive_dir, temp_dir)
# Create initial metadata.xml
b = Builder::XmlMarkup.new(:indent => 2)
metaxml = b.recording {
b = Builder::XmlMarkup.new(indent: 2)
metaxml = b.recording do
b.id(meeting_id)
b.state("processing")
b.state('processing')
b.published(false)
b.start_time
b.end_time
b.participants
b.playback
b.meta
}
metadata_xml = File.new("#{target_dir}/metadata.xml","w")
end
metadata_xml = File.new("#{target_dir}/metadata.xml", 'w')
metadata_xml.write(metaxml)
metadata_xml.close
BigBlueButton.logger.info("Created inital metadata.xml")
BigBlueButton.logger.info('Created inital metadata.xml')
BigBlueButton::AudioProcessor.process("#{temp_dir}/#{meeting_id}", "#{target_dir}/audio")
events_xml = "#{temp_dir}/#{meeting_id}/events.xml"
BigBlueButton::AudioProcessor.process(raw_archive_dir, "#{target_dir}/audio")
events_xml = "#{raw_archive_dir}/events.xml"
# TODO: Don't copy events.xml to target directory
FileUtils.cp(events_xml, target_dir)
presentation_dir = "#{temp_dir}/#{meeting_id}/presentation"
presentation_dir = "#{raw_archive_dir}/presentation"
presentations = BigBlueButton::Presentation.get_presentations(events_xml)
processed_pres_dir = "#{target_dir}/presentation"
@ -91,13 +89,11 @@ if not FileTest.directory?(target_dir)
# Get the real-time start and end timestamp
@doc = Nokogiri::XML(File.read("#{target_dir}/events.xml"))
meeting_start = @doc.xpath("//event")[0][:timestamp]
meeting_end = @doc.xpath("//event").last()[:timestamp]
meeting_start = BigBlueButton::Events.first_event_timestamp(@doc)
meeting_end = BigBlueButton::Events.last_event_timestamp(@doc)
match = /.*-(\d+)$/.match(meeting_id)
real_start_time = match[1]
real_end_time = (real_start_time.to_i + (meeting_end.to_i - meeting_start.to_i)).to_s
real_start_time = match[1].to_i
real_end_time = real_start_time + (meeting_end - meeting_start)
# Add start_time, end_time and meta to metadata.xml
## Load metadata.xml
@ -105,48 +101,41 @@ if not FileTest.directory?(target_dir)
## Add start_time and end_time
recording = metadata.root
### Date Format for recordings: Thu Mar 04 14:05:56 UTC 2010
start_time = recording.at_xpath("start_time")
start_time = recording.at_xpath('start_time')
start_time.content = real_start_time
end_time = recording.at_xpath("end_time")
end_time = recording.at_xpath('end_time')
end_time.content = real_end_time
## Copy the breakout and breakout rooms node from
## events.xml if present.
breakout_xpath = @doc.xpath("//breakout")
breakout_rooms_xpath = @doc.xpath("//breakoutRooms")
meeting_xpath = @doc.xpath("//meeting")
breakout_xpath = @doc.xpath('recording/breakout')
breakout_rooms_xpath = @doc.xpath('recording/breakoutRooms')
meeting_xpath = @doc.xpath('recording/meeting')
if (meeting_xpath != nil)
recording << meeting_xpath
end
recording << meeting_xpath unless meeting_xpath.nil?
if (breakout_xpath != nil)
recording << breakout_xpath
end
recording << breakout_xpath unless breakout_xpath.nil?
if (breakout_rooms_xpath != nil)
recording << breakout_rooms_xpath
end
recording << breakout_rooms_xpath unless breakout_rooms_xpath.nil?
participants = recording.at_xpath("participants")
participants = recording.at_xpath('participants')
participants.content = BigBlueButton::Events.get_num_participants(@doc)
## Remove empty meta
metadata.search('//recording/meta').each do |meta|
meta.remove
end
## TODO: Clarify reasoning behind creating an empty node to then remove it
metadata.search('recording/meta').each(&:remove)
## Add the actual meta
metadata_with_playback = Nokogiri::XML::Builder.with(metadata.at('recording')) do |xml|
xml.meta {
BigBlueButton::Events.get_meeting_metadata("#{target_dir}/events.xml").each { |k,v| xml.method_missing(k,v) }
}
Nokogiri::XML::Builder.with(metadata.at('recording')) do |xml|
xml.meta do
BigBlueButton::Events.get_meeting_metadata("#{target_dir}/events.xml").each { |k, v| xml.method_missing(k, v) }
end
end
## Write the new metadata.xml
metadata_file = File.new("#{target_dir}/metadata.xml","w")
metadata = Nokogiri::XML(metadata.to_xml) { |x| x.noblanks }
metadata_file = File.new("#{target_dir}/metadata.xml", 'w')
metadata = Nokogiri::XML(metadata.to_xml, &:noblanks)
metadata_file.write(metadata.root)
metadata_file.close
BigBlueButton.logger.info("Created an updated metadata.xml with start_time and end_time")
BigBlueButton.logger.info('Created an updated metadata.xml with start_time and end_time')
# Start processing raw files
presentation_text = {}
@ -158,56 +147,54 @@ if not FileTest.directory?(target_dir)
FileUtils.mkdir_p target_pres_dir
FileUtils.mkdir_p "#{target_pres_dir}/textfiles"
images=Dir.glob("#{pres_dir}/#{pres}.{jpg,jpeg,png,gif,JPG,JPEG,PNG,GIF}")
images = Dir.glob("#{pres_dir}/#{pres}.{jpg,jpeg,png,gif,JPG,JPEG,PNG,GIF}")
if images.empty?
pres_name = "#{pres_dir}/#{pres}"
if File.exists?("#{pres_name}.pdf")
if File.exist?("#{pres_name}.pdf")
pres_pdf = "#{pres_name}.pdf"
BigBlueButton.logger.info("Found pdf file for presentation #{pres_pdf}")
elsif File.exists?("#{pres_name}.PDF")
elsif File.exist?("#{pres_name}.PDF")
pres_pdf = "#{pres_name}.PDF"
BigBlueButton.logger.info("Found PDF file for presentation #{pres_pdf}")
elsif File.exists?("#{pres_name}")
elsif File.exist?(pres_name.to_s)
pres_pdf = pres_name
BigBlueButton.logger.info("Falling back to old presentation filename #{pres_pdf}")
else
pres_pdf = ""
pres_pdf = ''
BigBlueButton.logger.warn("Could not find pdf file for presentation #{pres}")
end
if !pres_pdf.empty?
unless pres_pdf.empty?
text = {}
1.upto(num_pages) do |page|
BigBlueButton::Presentation.extract_png_page_from_pdf(
page, pres_pdf, "#{target_pres_dir}/slide-#{page}.png", '1600x1600')
if File.exist?("#{pres_dir}/textfiles/slide-#{page}.txt") then
t = File.read("#{pres_dir}/textfiles/slide-#{page}.txt", encoding: 'UTF-8')
text["slide-#{page}"] = t.encode('UTF-8', invalid: :replace)
FileUtils.cp("#{pres_dir}/textfiles/slide-#{page}.txt", "#{target_pres_dir}/textfiles")
end
page, pres_pdf, "#{target_pres_dir}/slide-#{page}.png", '1600x1600'
)
next unless File.exist?("#{pres_dir}/textfiles/slide-#{page}.txt")
t = File.read("#{pres_dir}/textfiles/slide-#{page}.txt", encoding: 'UTF-8')
text["slide-#{page}"] = t.encode('UTF-8', invalid: :replace)
FileUtils.cp("#{pres_dir}/textfiles/slide-#{page}.txt", "#{target_pres_dir}/textfiles")
end
presentation_text[pres] = text
end
else
ext = File.extname("#{images[0]}")
BigBlueButton::Presentation.convert_image_to_png(
images[0], "#{target_pres_dir}/slide-1.png", '1600x1600')
images[0], "#{target_pres_dir}/slide-1.png", '1600x1600'
)
end
# Copy thumbnails from raw files
FileUtils.cp_r("#{pres_dir}/thumbnails", "#{target_pres_dir}/thumbnails") if File.exist?("#{pres_dir}/thumbnails")
end
BigBlueButton.logger.info("Generating closed captions")
BigBlueButton.logger.info('Generating closed captions')
ret = BigBlueButton.exec_ret('utils/gen_webvtt', '-i', raw_archive_dir, '-o', target_dir)
if ret != 0
raise "Generating closed caption files failed"
end
captions = JSON.load(File.new("#{target_dir}/captions.json", 'r'))
raise 'Generating closed caption files failed' if ret != 0
captions = JSON.parse(File.read("#{target_dir}/captions.json"))
if not presentation_text.empty?
unless presentation_text.empty?
# Write presentation_text.json to file
File.open("#{target_dir}/presentation_text.json","w") { |f| f.puts presentation_text.to_json }
File.open("#{target_dir}/presentation_text.json", 'w') { |f| f.puts presentation_text.to_json }
end
# We have to decide whether to actually generate the webcams video file
@ -215,40 +202,38 @@ if not FileTest.directory?(target_dir)
# - There is webcam video present, or
# - There's broadcast video present, or
# - There are closed captions present (they need a video stream to be rendered on top of)
if !Dir["#{raw_archive_dir}/video/*"].empty? or
!Dir["#{raw_archive_dir}/video-broadcast/*"].empty? or
captions.length > 0
if !Dir["#{raw_archive_dir}/video/*"].empty? ||
!Dir["#{raw_archive_dir}/video-broadcast/*"].empty? ||
!captions.empty?
webcam_width = presentation_props['video_output_width']
webcam_height = presentation_props['video_output_height']
webcam_framerate = presentation_props['video_output_framerate']
# Use a higher resolution video canvas if there's broadcast video streams
if !Dir["#{raw_archive_dir}/video-broadcast/*"].empty?
unless Dir["#{raw_archive_dir}/video-broadcast/*"].empty?
webcam_width = presentation_props['deskshare_output_width']
webcam_height = presentation_props['deskshare_output_height']
webcam_framerate = presentation_props['deskshare_output_framerate']
end
webcam_framerate = 15 if webcam_framerate.nil?
processed_audio_file = BigBlueButton::AudioProcessor.get_processed_audio_file("#{temp_dir}/#{meeting_id}", "#{target_dir}/audio")
BigBlueButton.process_webcam_videos(target_dir, temp_dir, meeting_id, webcam_width, webcam_height, webcam_framerate, presentation_props['audio_offset'], processed_audio_file, presentation_props['video_formats'])
processed_audio_file = BigBlueButton::AudioProcessor.get_processed_audio_file(raw_archive_dir, "#{target_dir}/audio")
BigBlueButton.process_webcam_videos(target_dir, raw_archive_dir, webcam_width, webcam_height, webcam_framerate, presentation_props['audio_offset'], processed_audio_file, presentation_props['video_formats'])
end
if !Dir["#{raw_archive_dir}/deskshare/*"].empty? and presentation_props['include_deskshare']
if !Dir["#{raw_archive_dir}/deskshare/*"].empty? && presentation_props['include_deskshare']
deskshare_width = presentation_props['deskshare_output_width']
deskshare_height = presentation_props['deskshare_output_height']
deskshare_framerate = presentation_props['deskshare_output_framerate']
deskshare_framerate = 5 if deskshare_framerate.nil?
BigBlueButton.process_deskshare_videos(target_dir, temp_dir, meeting_id, deskshare_width, deskshare_height, deskshare_framerate, presentation_props['video_formats'])
BigBlueButton.process_deskshare_videos(target_dir, raw_archive_dir, deskshare_width, deskshare_height, deskshare_framerate, presentation_props['video_formats'])
end
# Copy shared notes from raw files
if !Dir["#{raw_archive_dir}/notes/*"].empty?
FileUtils.cp_r("#{raw_archive_dir}/notes", target_dir)
end
FileUtils.cp_r("#{raw_archive_dir}/notes", target_dir) unless Dir["#{raw_archive_dir}/notes/*"].empty?
process_done = File.new("#{recording_dir}/status/processed/#{meeting_id}-presentation.done", "w")
process_done = File.new("#{recording_dir}/status/processed/#{meeting_id}-presentation.done", 'w')
process_done.write("Processed #{meeting_id}")
process_done.close
@ -257,15 +242,14 @@ if not FileTest.directory?(target_dir)
metadata = Nokogiri::XML(File.read("#{target_dir}/metadata.xml"))
## Update status
recording = metadata.root
state = recording.at_xpath("state")
state.content = "processed"
state = recording.at_xpath('state')
state.content = 'processed'
## Write the new metadata.xml
metadata_file = File.new("#{target_dir}/metadata.xml","w")
metadata_file = File.new("#{target_dir}/metadata.xml", 'w')
metadata_file.write(metadata.root)
metadata_file.close
BigBlueButton.logger.info("Created an updated metadata.xml with state=processed")
rescue Exception => e
BigBlueButton.logger.info('Created an updated metadata.xml with state=processed')
rescue StandardError => e
BigBlueButton.logger.error(e.message)
e.backtrace.each do |traceline|
BigBlueButton.logger.error(traceline)