diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/breakout/BreakoutHdlrHelpers.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/breakout/BreakoutHdlrHelpers.scala index 0d8361a8ab..30655499c8 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/breakout/BreakoutHdlrHelpers.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/breakout/BreakoutHdlrHelpers.scala @@ -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) } } diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/breakout/BreakoutRoomCreatedMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/breakout/BreakoutRoomCreatedMsgHdlr.scala index 49df85b23a..d77309a201 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/breakout/BreakoutRoomCreatedMsgHdlr.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/breakout/BreakoutRoomCreatedMsgHdlr.scala @@ -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) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/breakout/BreakoutRoomUsersUpdateMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/breakout/BreakoutRoomUsersUpdateMsgHdlr.scala index 1dbfa28c0e..5456e1695b 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/breakout/BreakoutRoomUsersUpdateMsgHdlr.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/breakout/BreakoutRoomUsersUpdateMsgHdlr.scala @@ -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) } diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/breakout/BreakoutRoomsListMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/breakout/BreakoutRoomsListMsgHdlr.scala index dfcd84f65a..2eb2d87bbb 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/breakout/BreakoutRoomsListMsgHdlr.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/breakout/BreakoutRoomsListMsgHdlr.scala @@ -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) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/domain/MeetingState2x.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/domain/MeetingState2x.scala index c21a58d0e3..2213e9e449 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/domain/MeetingState2x.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/domain/MeetingState2x.scala @@ -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) } diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/models/RegisteredUsers.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/models/RegisteredUsers.scala index b2db60e631..434d4a42f1 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/models/RegisteredUsers.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/models/RegisteredUsers.scala @@ -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 ) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/running/MeetingActor.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/running/MeetingActor.scala index 94e28a1f80..ef80d84e31 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/running/MeetingActor.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/running/MeetingActor.scala @@ -162,6 +162,7 @@ class MeetingActor( new GroupChats(Map.empty), new PresentationPodManager(Map.empty), None, + None, expiryTracker, recordingTracker ) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core2/FromAkkaAppsMsgSenderActor.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core2/FromAkkaAppsMsgSenderActor.scala index 165d9262fb..1f442489db 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core2/FromAkkaAppsMsgSenderActor.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core2/FromAkkaAppsMsgSenderActor.scala @@ -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 => diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/LearningDashboardActor.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/LearningDashboardActor.scala index f5f1d5ce93..e82ba50da4 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/LearningDashboardActor.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/endpoint/redis/LearningDashboardActor.scala @@ -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) } } diff --git a/bbb-api-demo/src/main/webapp/demo_iframe.jsp b/bbb-api-demo/src/main/webapp/demo_iframe.jsp deleted file mode 100644 index 55ac7eed74..0000000000 --- a/bbb-api-demo/src/main/webapp/demo_iframe.jsp +++ /dev/null @@ -1,243 +0,0 @@ - -<%@ page language="java" contentType="text/html; charset=UTF-8" - pageEncoding="UTF-8"%> -<% - request.setCharacterEncoding("UTF-8"); - response.setCharacterEncoding("UTF-8"); -%> - - - - - Join Meeting via HTML5 Client (API) - - - - - -

You must have the BigBlueButton HTML5 client installed to use this API demo.

- -<%@ include file="bbb_api.jsp"%> - -<% -if (request.getParameterMap().isEmpty()) { - // - // Assume we want to create a meeting - // - %> -<%@ include file="demo_header.jsp"%> - -

Join Meeting via HTML5 Client (API)

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
 Full Name: 
 Meeting Name: 
 Moderator Role: 
   
- -
- - -<% -} 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://")) { -%> - - -<% - } else { -%> - -Error: getJoinURL() failed -

-<%=joinURL %> - -<% - } -} -%> - -<%@ include file="demo_footer.jsp"%> - - - - diff --git a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/domain/Meeting2x.scala b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/domain/Meeting2x.scala index a81362697e..d0c262ee86 100755 --- a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/domain/Meeting2x.scala +++ b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/domain/Meeting2x.scala @@ -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) diff --git a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/BreakoutMsgs.scala b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/BreakoutMsgs.scala index 9452d51822..ec04f6d0d9 100755 --- a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/BreakoutMsgs.scala +++ b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/BreakoutMsgs.scala @@ -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) diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/ApiParams.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/ApiParams.java index 1dabda6d15..ce100d4764 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/ApiParams.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/ApiParams.java @@ -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"; diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java index fa8b4f3fea..185660e155 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java @@ -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) { diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/ParamsProcessorUtil.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/ParamsProcessorUtil.java index 5f93d090cb..f950307611 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/ParamsProcessorUtil.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/ParamsProcessorUtil.java @@ -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 processGroupsParams(Map params) { + ArrayList groups = new ArrayList(); + + 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 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 params) { String meetingName = params.get(ApiParams.NAME); @@ -496,6 +538,8 @@ public class ParamsProcessorUtil { String meetingLayout = defaultMeetingLayout; + ArrayList 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))) { diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Group.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Group.java new file mode 100644 index 0000000000..11aa9aeeb9 --- /dev/null +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Group.java @@ -0,0 +1,42 @@ +package org.bigbluebutton.api.domain; + +import java.util.Vector; + +public class Group { + + private String groupId = ""; + private String name = ""; + private Vector usersExtId; + + public Group(String groupId, + String name, + Vector 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 getUsersExtId() { + return usersExtId; + } + + public void setUsersExtId(Vector usersExtId) { + this.usersExtId = usersExtId; + } +} \ No newline at end of file diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Meeting.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Meeting.java index 3b7875d821..5c09c98b4a 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Meeting.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Meeting.java @@ -82,6 +82,7 @@ public class Meeting { private final ConcurrentMap enteredUsers; private final Boolean isBreakout; private final List breakoutRooms = new ArrayList<>(); + private ArrayList groups = new ArrayList(); 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 getGroups() { return groups; } + + public void setGroups(ArrayList 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 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 groups) { + this.groups = groups; + return this; + } + + public Meeting build() { return new Meeting(this); } } diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api2/IBbbWebApiGWApp.java b/bbb-common-web/src/main/java/org/bigbluebutton/api2/IBbbWebApiGWApp.java index fd32c7759a..d38585d212 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api2/IBbbWebApiGWApp.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api2/IBbbWebApiGWApp.java @@ -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 groups); void registerUser(String meetingID, String internalUserId, String fullname, String role, String externUserID, String authToken, String avatarURL, diff --git a/bbb-common-web/src/main/scala/org/bigbluebutton/api2/BbbWebApiGWApp.scala b/bbb-common-web/src/main/scala/org/bigbluebutton/api2/BbbWebApiGWApp.scala index fbd8b01fae..06520012d2 100755 --- a/bbb-common-web/src/main/scala/org/bigbluebutton/api2/BbbWebApiGWApp.scala +++ b/bbb-common-web/src/main/scala/org/bigbluebutton/api2/BbbWebApiGWApp.scala @@ -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) diff --git a/bbb-learning-dashboard/src/App.css b/bbb-learning-dashboard/src/App.css index 4328cf33cd..c240bf3d88 100644 --- a/bbb-learning-dashboard/src/App.css +++ b/bbb-learning-dashboard/src/App.css @@ -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; } diff --git a/bbb-learning-dashboard/src/App.js b/bbb-learning-dashboard/src/App.js index 9c4262c575..7a680b25c1 100644 --- a/bbb-learning-dashboard/src/App.js +++ b/bbb-learning-dashboard/src/App.js @@ -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] = (); + }); + + // 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 { ) : null -} + }
{activitiesJson.name || ''} @@ -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 { -

- + + -

+

{ tab === 'overview' || tab === 'overview_activityscore' ? : null } { tab === 'status_timeline' - ? + ? : null } { tab === 'polling' ? @@ -441,6 +490,44 @@ class App extends React.Component { : null } +
+
+
+

+ { + lastUpdated && ( + <> + +   + +   + + + ) + } +

+
+ +
); } diff --git a/bbb-learning-dashboard/src/bbb-icons.css b/bbb-learning-dashboard/src/bbb-icons.css index 207ca1178b..fd2e1a725f 100755 --- a/bbb-learning-dashboard/src/bbb-icons.css +++ b/bbb-learning-dashboard/src/bbb-icons.css @@ -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"; } diff --git a/bbb-learning-dashboard/src/components/Card.jsx b/bbb-learning-dashboard/src/components/Card.jsx index da3aa404e5..a81950b609 100644 --- a/bbb-learning-dashboard/src/components/Card.jsx +++ b/bbb-learning-dashboard/src/components/Card.jsx @@ -5,8 +5,36 @@ function Card(props) { number, name, children, iconClass, cardClass, } = props; + let icons; + + try { + React.Children.only(children); + icons = ( +
+ { children } +
+ ); + } catch (e) { + icons = ( +
+ { + React.Children.map(children, (child, index) => { + let offset = 4 / (index + 1); + offset = index === (React.Children.count(children) - 1) ? 0 : offset; + + return ( +
+ { child } +
+ ); + }) + } +
+ ); + } + return ( -
+

{ number } @@ -15,9 +43,7 @@ function Card(props) { { name }

-
- { children } -
+ {icons}
); } diff --git a/bbb-learning-dashboard/src/components/PollsTable.jsx b/bbb-learning-dashboard/src/components/PollsTable.jsx index fb4d34cb14..fa1efc9500 100644 --- a/bbb-learning-dashboard/src/components/PollsTable.jsx +++ b/bbb-learning-dashboard/src/components/PollsTable.jsx @@ -14,6 +14,41 @@ class PollsTable extends React.Component { return ''; } + if (typeof polls === 'object' && Object.values(polls).length === 0) { + return ( +
+
+ + + +
+

+ +

+

+ +

+
+ ); + } + return ( diff --git a/bbb-learning-dashboard/src/components/StatusTable.jsx b/bbb-learning-dashboard/src/components/StatusTable.jsx index c6af05b0e0..bc35f2e78f 100644 --- a/bbb-learning-dashboard/src/components/StatusTable.jsx +++ b/bbb-learning-dashboard/src/components/StatusTable.jsx @@ -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 {
- ); }) } diff --git a/bbb-learning-dashboard/src/components/UsersTable.jsx b/bbb-learning-dashboard/src/components/UsersTable.jsx index c900b91c89..58b9101eee 100644 --- a/bbb-learning-dashboard/src/components/UsersTable.jsx +++ b/bbb-learning-dashboard/src/components/UsersTable.jsx @@ -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) => ( - - + - + - + + + + - - - + { - Object.keys(usersEmojisSummary[user.intId] || {}).map((emoji) => ( -
- -   - { usersEmojisSummary[user.intId][emoji] } -   - -
- )) - } - - - { !user.isModerator ? ( - ) : - - )) + + + ); + }) ) : (
+ ( -
- +
+
@@ -71,81 +88,97 @@ class StatusTable extends React.Component {
{ 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 ( -
+ { - user.registeredOn > period && user.registeredOn < period + spanMinutes + (registeredOn >= boundaryLeft && registeredOn <= boundaryRight) + || (leftOn >= boundaryLeft && leftOn <= boundaryRight) + || (boundaryLeft > registeredOn && boundaryRight < leftOn) + || (boundaryLeft >= registeredOn && leftOn === 0) ? ( - - - = 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 ( +
- - + ); + })() ) : null } - { Object.keys(userEmojisInPeriod) - .map((emoji) => ( -
- -   - { userEmojisInPeriod[emoji] } -   - + { 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 ( +
+
- )) } - { - user.leftOn > period && user.leftOn < period + spanMinutes - ? ( - - - - - - ) : null - } + ); + })}
-
- {/* */} - - -     -
-

- {user.name} -

-

- - - - { + const opacity = user.leftOn > 0 ? 'opacity-75' : ''; + return ( +

+
+ + +     +
+

+ {user.name} +

+

+ + + + +

+ { user.leftOn > 0 ? (

@@ -259,154 +204,154 @@ class UsersTable extends React.Component {

) : null - } -
-
- - - -   - { tsToHHmmss( - (user.leftOn > 0 - ? user.leftOn - : (new Date()).getTime()) - user.registeredOn, - ) } -
-
+ } +
+
+ + + +   + { tsToHHmmss( + (user.leftOn > 0 + ? user.leftOn + : (new Date()).getTime()) - user.registeredOn, + ) } +
-
-
- { user.talk.totalTime > 0 - ? ( - - - +
+
+
+ { user.talk.totalTime > 0 + ? ( + + + + +   + { tsToHHmmss(user.talk.totalTime) } + + ) : null } + + { getSumOfTime(user.webcams) > 0 + ? ( + + + + +   + { tsToHHmmss(getSumOfTime(user.webcams)) } + + ) : null } + + { user.totalOfMessages > 0 + ? ( + + + + +   + {user.totalOfMessages} + + ) : null } + + { + Object.keys(usersEmojisSummary[user.intId] || {}).map((emoji) => ( +
+ +   + { usersEmojisSummary[user.intId][emoji] } +   + - -   - { tsToHHmmss(user.talk.totalTime) } - - ) : null } -
- { getSumOfTime(user.webcams) > 0 - ? ( - - - - -   - { tsToHHmmss(getSumOfTime(user.webcams)) } - - ) : null } - - { user.totalOfMessages > 0 - ? ( - - - - -   - {user.totalOfMessages} - - ) : null } - + + )) + } + + { user.emojis.filter((emoji) => emoji.name === 'raiseHand').length > 0 + ? ( + + + + +   + {user.emojis.filter((emoji) => emoji.name === 'raiseHand').length} + + ) : null } + - { user.emojis.filter((emoji) => emoji.name === 'raiseHand').length > 0 - ? ( - - - - -   - {user.emojis.filter((emoji) => emoji.name === 'raiseHand').length} - - ) : null } - + 0 ? '#A7F3D0' : '#e4e4e7'} /> 2 ? '#6EE7B7' : '#e4e4e7'} /> @@ -422,23 +367,24 @@ class UsersTable extends React.Component { } - - { - user.leftOn > 0 - ? ( - - - - ) - : ( - - - - ) - } -
+ { + user.leftOn > 0 + ? ( + + + + ) + : ( + + + + ) + } +
diff --git a/bbb-learning-dashboard/src/index.css b/bbb-learning-dashboard/src/index.css index a3319c71b3..4949bc1fe1 100644 --- a/bbb-learning-dashboard/src/index.css +++ b/bbb-learning-dashboard/src/index.css @@ -1,4 +1,34 @@ /* ./src/index.css */ @tailwind base; @tailwind components; -@tailwind utilities; \ No newline at end of file +@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; +} diff --git a/bbb-learning-dashboard/src/services/EmojiService.js b/bbb-learning-dashboard/src/services/EmojiService.js index b64fdc98ff..20fdd737ce 100644 --- a/bbb-learning-dashboard/src/services/EmojiService.js +++ b/bbb-learning-dashboard/src/services/EmojiService.js @@ -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; +} diff --git a/bbb-learning-dashboard/src/services/UserService.js b/bbb-learning-dashboard/src/services/UserService.js new file mode 100644 index 0000000000..84dc8a4c94 --- /dev/null +++ b/bbb-learning-dashboard/src/services/UserService.js @@ -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'); +} diff --git a/bbb-learning-dashboard/tailwind.config.js b/bbb-learning-dashboard/tailwind.config.js index 7b39079d2d..664d9719fa 100644 --- a/bbb-learning-dashboard/tailwind.config.js +++ b/bbb-learning-dashboard/tailwind.config.js @@ -8,4 +8,4 @@ module.exports = { extend: {}, }, plugins: [], -} +}; diff --git a/bigbluebutton-html5/client/collection-mirror-initializer.js b/bigbluebutton-html5/client/collection-mirror-initializer.js index 232381e665..f86765b5d0 100644 --- a/bigbluebutton-html5/client/collection-mirror-initializer.js +++ b/bigbluebutton-html5/client/collection-mirror-initializer.js @@ -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(); diff --git a/bigbluebutton-html5/imports/api/breakouts-history/index.js b/bigbluebutton-html5/imports/api/breakouts-history/index.js new file mode 100644 index 0000000000..d9a7c17eea --- /dev/null +++ b/bigbluebutton-html5/imports/api/breakouts-history/index.js @@ -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; diff --git a/bigbluebutton-html5/imports/api/breakouts-history/server/eventHandlers.js b/bigbluebutton-html5/imports/api/breakouts-history/server/eventHandlers.js new file mode 100644 index 0000000000..9418f5f1fe --- /dev/null +++ b/bigbluebutton-html5/imports/api/breakouts-history/server/eventHandlers.js @@ -0,0 +1,4 @@ +import RedisPubSub from '/imports/startup/server/redis'; +import handleBreakoutRoomsList from './handlers/breakoutRoomsList'; + +RedisPubSub.on('BreakoutRoomsListEvtMsg', handleBreakoutRoomsList); diff --git a/bigbluebutton-html5/imports/api/breakouts-history/server/handlers/breakoutRoomsList.js b/bigbluebutton-html5/imports/api/breakouts-history/server/handlers/breakoutRoomsList.js new file mode 100644 index 0000000000..0b26a79310 --- /dev/null +++ b/bigbluebutton-html5/imports/api/breakouts-history/server/handlers/breakoutRoomsList.js @@ -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}`); + } +} diff --git a/bigbluebutton-html5/imports/api/breakouts-history/server/index.js b/bigbluebutton-html5/imports/api/breakouts-history/server/index.js new file mode 100644 index 0000000000..f993f38e5b --- /dev/null +++ b/bigbluebutton-html5/imports/api/breakouts-history/server/index.js @@ -0,0 +1,2 @@ +import './eventHandlers'; +import './publishers'; diff --git a/bigbluebutton-html5/imports/api/breakouts-history/server/publishers.js b/bigbluebutton-html5/imports/api/breakouts-history/server/publishers.js new file mode 100644 index 0000000000..124d575d7c --- /dev/null +++ b/bigbluebutton-html5/imports/api/breakouts-history/server/publishers.js @@ -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); diff --git a/bigbluebutton-html5/imports/api/breakouts/server/eventHandlers.js b/bigbluebutton-html5/imports/api/breakouts/server/eventHandlers.js index 8628c3e2dd..d179e4164b 100755 --- a/bigbluebutton-html5/imports/api/breakouts/server/eventHandlers.js +++ b/bigbluebutton-html5/imports/api/breakouts/server/eventHandlers.js @@ -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); diff --git a/bigbluebutton-html5/imports/api/breakouts/server/handlers/breakoutList.js b/bigbluebutton-html5/imports/api/breakouts/server/handlers/breakoutList.js new file mode 100644 index 0000000000..7de9c5d5d6 --- /dev/null +++ b/bigbluebutton-html5/imports/api/breakouts/server/handlers/breakoutList.js @@ -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 }); +} diff --git a/bigbluebutton-html5/imports/api/breakouts/server/handlers/breakoutStarted.js b/bigbluebutton-html5/imports/api/breakouts/server/handlers/breakoutStarted.js deleted file mode 100644 index 47a0afe1fc..0000000000 --- a/bigbluebutton-html5/imports/api/breakouts/server/handlers/breakoutStarted.js +++ /dev/null @@ -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}`); - } -} diff --git a/bigbluebutton-html5/imports/api/breakouts/server/handlers/joinedUsersChanged.js b/bigbluebutton-html5/imports/api/breakouts/server/handlers/joinedUsersChanged.js index 4918a3b8ac..44cdefc0a6 100644 --- a/bigbluebutton-html5/imports/api/breakouts/server/handlers/joinedUsersChanged.js +++ b/bigbluebutton-html5/imports/api/breakouts/server/handlers/joinedUsersChanged.js @@ -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) { diff --git a/bigbluebutton-html5/imports/api/meetings/server/modifiers/addMeeting.js b/bigbluebutton-html5/imports/api/meetings/server/modifiers/addMeeting.js index a7bf664b7a..ffc681cee9 100755 --- a/bigbluebutton-html5/imports/api/meetings/server/modifiers/addMeeting.js +++ b/bigbluebutton-html5/imports/api/meetings/server/modifiers/addMeeting.js @@ -117,6 +117,7 @@ export default function addMeeting(meeting) { systemProps: { html5InstanceId: Number, }, + groups: Array, }); const { diff --git a/bigbluebutton-html5/imports/api/users-persistent-data/server/modifiers/updateUserBreakoutRoom.js b/bigbluebutton-html5/imports/api/users-persistent-data/server/modifiers/updateUserBreakoutRoom.js new file mode 100644 index 0000000000..9fe78b8710 --- /dev/null +++ b/bigbluebutton-html5/imports/api/users-persistent-data/server/modifiers/updateUserBreakoutRoom.js @@ -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}`); + } + }); +} diff --git a/bigbluebutton-html5/imports/api/users-persistent-data/server/publishers.js b/bigbluebutton-html5/imports/api/users-persistent-data/server/publishers.js index b634afd05c..5160bef510 100644 --- a/bigbluebutton-html5/imports/api/users-persistent-data/server/publishers.js +++ b/bigbluebutton-html5/imports/api/users-persistent-data/server/publishers.js @@ -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) { diff --git a/bigbluebutton-html5/imports/api/users-settings/server/methods/addUserSettings.js b/bigbluebutton-html5/imports/api/users-settings/server/methods/addUserSettings.js index 2f6a93d464..b40818349c 100644 --- a/bigbluebutton-html5/imports/api/users-settings/server/methods/addUserSettings.js +++ b/bigbluebutton-html5/imports/api/users-settings/server/methods/addUserSettings.js @@ -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) { diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/component.jsx b/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/component.jsx index 42ab536403..f3582328e0 100755 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/component.jsx @@ -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 { } - + + + + {intl.formatMessage(intlMessages.numberOfRoomsIsValid)} diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/container.jsx b/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/container.jsx index d6e5b4445b..b5a1530f7e 100644 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/container.jsx @@ -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(), diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/styles.js b/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/styles.js index b7317f63a9..82efb39464 100644 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/styles.js +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/styles.js @@ -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, diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/service.js b/bigbluebutton-html5/imports/ui/components/actions-bar/service.js index 2f295970af..146ab005c1 100755 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/service.js +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/service.js @@ -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(), diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-controls/component.jsx b/bigbluebutton-html5/imports/ui/components/audio/audio-controls/component.jsx index d3fd47cead..f1d10516b6 100755 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-controls/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-controls/component.jsx @@ -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, diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-controls/container.jsx b/bigbluebutton-html5/imports/ui/components/audio/audio-controls/container.jsx index 2e6bda4c36..bedaabee5f 100755 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-controls/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-controls/container.jsx @@ -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 ; }; -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(), diff --git a/bigbluebutton-html5/imports/ui/components/breakout-room/invitation/component.jsx b/bigbluebutton-html5/imports/ui/components/breakout-room/invitation/component.jsx index 0bea15f742..1bd4763849 100644 --- a/bigbluebutton-html5/imports/ui/components/breakout-room/invitation/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/breakout-room/invitation/component.jsx @@ -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 }); + } } } diff --git a/bigbluebutton-html5/imports/ui/components/breakout-room/service.js b/bigbluebutton-html5/imports/ui/components/breakout-room/service.js index 1313b4293e..56add6a0a4 100644 --- a/bigbluebutton-html5/imports/ui/components/breakout-room/service.js +++ b/bigbluebutton-html5/imports/ui/components/breakout-room/service.js @@ -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, diff --git a/bigbluebutton-html5/imports/ui/components/nav-bar/component.jsx b/bigbluebutton-html5/imports/ui/components/nav-bar/component.jsx index 8bdf6313ad..99ef2eb7ef 100755 --- a/bigbluebutton-html5/imports/ui/components/nav-bar/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/nav-bar/component.jsx @@ -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) => { diff --git a/bigbluebutton-html5/imports/ui/components/nav-bar/container.jsx b/bigbluebutton-html5/imports/ui/components/nav-bar/container.jsx index ef299cdb53..b3c12c9319 100755 --- a/bigbluebutton-html5/imports/ui/components/nav-bar/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/nav-bar/container.jsx @@ -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, }; diff --git a/bigbluebutton-html5/imports/ui/components/nav-bar/recording-indicator/container.jsx b/bigbluebutton-html5/imports/ui/components/nav-bar/recording-indicator/container.jsx index b29c5364e4..c2a31bd3b3 100644 --- a/bigbluebutton-html5/imports/ui/components/nav-bar/recording-indicator/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/nav-bar/recording-indicator/container.jsx @@ -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, diff --git a/bigbluebutton-html5/imports/ui/components/nav-bar/service.js b/bigbluebutton-html5/imports/ui/components/nav-bar/service.js deleted file mode 100755 index 6857f00219..0000000000 --- a/bigbluebutton-html5/imports/ui/components/nav-bar/service.js +++ /dev/null @@ -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), -}; diff --git a/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx b/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx index 1f72ec79a9..c8065ab46e 100755 --- a/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/subscriptions/component.jsx @@ -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(); diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/component.jsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/component.jsx index f9b84e19f6..b38c0e8369 100644 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/component.jsx @@ -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, diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/container.jsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/container.jsx index 77bbc191f2..2d7b9035d1 100755 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/container.jsx @@ -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, diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-dropdown/component.jsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-dropdown/component.jsx index 153e23f155..f424aa2c73 100755 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-dropdown/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-dropdown/component.jsx @@ -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, }} /> diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-name/component.jsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-name/component.jsx index 0115e41557..75715a3122 100644 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-name/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/user-name/component.jsx @@ -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( + + +   + {userLastBreakout.isDefaultName + ? intl.formatMessage(messages.breakoutRoom, { 0: userLastBreakout.sequence }) + : userLastBreakout.shortName} + , + ); + } + return ( { } = params; const fetchFunc = published - ? AnnotationGroupService.getCurrentAnnotationsInfo : AnnotationGroupService.getUnsetAnnotations; + ? AnnotationGroupService.getCurrentAnnotationsInfo : AnnotationGroupService.getUnsentAnnotations; const annotationsInfo = fetchFunc(whiteboardId); return { diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/annotation-group/service.js b/bigbluebutton-html5/imports/ui/components/whiteboard/annotation-group/service.js index 683b8449a7..84097afb50 100755 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/annotation-group/service.js +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/annotation-group/service.js @@ -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, }; diff --git a/bigbluebutton-html5/imports/ui/services/audio-manager/index.js b/bigbluebutton-html5/imports/ui/services/audio-manager/index.js index f0a3253873..a7b7b1b066 100755 --- a/bigbluebutton-html5/imports/ui/services/audio-manager/index.js +++ b/bigbluebutton-html5/imports/ui/services/audio-manager/index.js @@ -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); } diff --git a/bigbluebutton-html5/package-lock.json b/bigbluebutton-html5/package-lock.json index cbe1916288..7dd9d75e56 100644 --- a/bigbluebutton-html5/package-lock.json +++ b/bigbluebutton-html5/package-lock.json @@ -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", diff --git a/bigbluebutton-html5/private/config/settings.yml b/bigbluebutton-html5/private/config/settings.yml index 6e6d3b3061..5541ee8b69 100755 --- a/bigbluebutton-html5/private/config/settings.yml +++ b/bigbluebutton-html5/private/config/settings.yml @@ -411,9 +411,6 @@ public: syncUsersWithConnectionManager: enabled: false syncInterval: 60000 - allowOutsideCommands: - toggleRecording: false - toggleSelfVoice: false poll: enabled: true maxCustom: 5 diff --git a/bigbluebutton-html5/public/locales/en.json b/bigbluebutton-html5/public/locales/en.json index fc8c73c147..98001c031e 100755 --- a/bigbluebutton-html5/public/locales/en.json +++ b/bigbluebutton-html5/public/locales/en.json @@ -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" } diff --git a/bigbluebutton-html5/server/main.js b/bigbluebutton-html5/server/main.js index b2ca08d332..460e64c5fb 100755 --- a/bigbluebutton-html5/server/main.js +++ b/bigbluebutton-html5/server/main.js @@ -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'; diff --git a/record-and-playback/core/lib/recordandplayback/generators/events.rb b/record-and-playback/core/lib/recordandplayback/generators/events.rb index 855038e612..9ec6bde3f5 100755 --- a/record-and-playback/core/lib/recordandplayback/generators/events.rb +++ b/record-and-playback/core/lib/recordandplayback/generators/events.rb @@ -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 @@ -892,4 +892,4 @@ module BigBlueButton end end -end +end \ No newline at end of file diff --git a/record-and-playback/core/lib/recordandplayback/generators/video.rb b/record-and-playback/core/lib/recordandplayback/generators/video.rb index fdcf0ed049..76e8ecee2d 100755 --- a/record-and-playback/core/lib/recordandplayback/generators/video.rb +++ b/record-and-playback/core/lib/recordandplayback/generators/video.rb @@ -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) @@ -167,6 +169,4 @@ module BigBlueButton return false end -end - - +end \ No newline at end of file diff --git a/record-and-playback/presentation/scripts/process/presentation.rb b/record-and-playback/presentation/scripts/process/presentation.rb index 5c3a7ab0ad..9b2afbbcd5 100755 --- a/record-and-playback/presentation/scripts/process/presentation.rb +++ b/record-and-playback/presentation/scripts/process/presentation.rb @@ -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)