Merge branch 'develop' into origin/jdk-17-upgrade
This commit is contained in:
commit
ce7362380b
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
)
|
||||
|
||||
|
@ -162,6 +162,7 @@ class MeetingActor(
|
||||
new GroupChats(Map.empty),
|
||||
new PresentationPodManager(Map.empty),
|
||||
None,
|
||||
None,
|
||||
expiryTracker,
|
||||
recordingTracker
|
||||
)
|
||||
|
@ -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 =>
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,243 +0,0 @@
|
||||
<!--
|
||||
|
||||
BigBlueButton - http://www.bigbluebutton.org
|
||||
|
||||
Copyright (c) 2008-2018 by respective authors (see below). All rights reserved.
|
||||
|
||||
BigBlueButton is free software; you can redistribute it and/or modify it under the
|
||||
terms of the GNU Lesser General Public License as published by the Free Software
|
||||
Foundation; either version 3 of the License, or (at your option) any later
|
||||
version.
|
||||
|
||||
BigBlueButton is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public License along
|
||||
with BigBlueButton; if not, If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
Authors: James Jung
|
||||
Anton Georgiev
|
||||
|
||||
-->
|
||||
<%@ page language="java" contentType="text/html; charset=UTF-8"
|
||||
pageEncoding="UTF-8"%>
|
||||
<%
|
||||
request.setCharacterEncoding("UTF-8");
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
%>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<title>Join Meeting via HTML5 Client (API)</title>
|
||||
<style>
|
||||
#controls {
|
||||
width:50%;
|
||||
height:200px;
|
||||
float:left;
|
||||
}
|
||||
#client {
|
||||
width:100%;
|
||||
height:700px;
|
||||
float:left;
|
||||
}
|
||||
#client-content {
|
||||
width:100%;
|
||||
height:100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<p>You must have the BigBlueButton HTML5 client installed to use this API demo.</p>
|
||||
|
||||
<%@ include file="bbb_api.jsp"%>
|
||||
|
||||
<%
|
||||
if (request.getParameterMap().isEmpty()) {
|
||||
//
|
||||
// Assume we want to create a meeting
|
||||
//
|
||||
%>
|
||||
<%@ include file="demo_header.jsp"%>
|
||||
|
||||
<h2>Join Meeting via HTML5 Client (API)</h2>
|
||||
|
||||
<FORM NAME="form1" METHOD="GET">
|
||||
<table cellpadding="5" cellspacing="5" style="width: 400px; ">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td style="text-align: right; ">Full Name:</td>
|
||||
<td style="width: 5px; "> </td>
|
||||
<td style="text-align: left "><input type="text" autofocus required name="username" /></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td style="text-align: right; ">Meeting Name:</td>
|
||||
<td style="width: 5px; "> </td>
|
||||
<td style="text-align: left "><input type="text" required name="meetingname" value="Demo Meeting" /></td>
|
||||
<tr>
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td style="text-align: right; ">Moderator Role:</td>
|
||||
<td style="width: 5px; "> </td>
|
||||
<td style="text-align: left "><input type=checkbox name=isModerator value="true" checked></td>
|
||||
<tr>
|
||||
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
<td><input type="submit" value="Join" /></td>
|
||||
<tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<INPUT TYPE=hidden NAME=action VALUE="create">
|
||||
</FORM>
|
||||
|
||||
|
||||
<%
|
||||
} else if (request.getParameter("action").equals("create")) {
|
||||
|
||||
String username = request.getParameter("username");
|
||||
|
||||
// set defaults and overwrite them if custom values exist
|
||||
String meetingname = "Demo Meeting";
|
||||
if (request.getParameter("meetingname") != null) {
|
||||
meetingname = request.getParameter("meetingname");
|
||||
}
|
||||
|
||||
Boolean isModerator = new Boolean(false);
|
||||
Boolean isHTML5 = new Boolean(true);
|
||||
Boolean isRecorded = new Boolean(true);
|
||||
if (request.getParameter("isModerator") != null) {
|
||||
isModerator = Boolean.parseBoolean(request.getParameter("isModerator"));
|
||||
}
|
||||
|
||||
String joinURL = getJoinURLExtended(username, meetingname, isRecorded.toString(), null, null, null, isHTML5.toString(), isModerator.toString());
|
||||
|
||||
if (joinURL.startsWith("http://") || joinURL.startsWith("https://")) {
|
||||
%>
|
||||
|
||||
<script language="javascript" type="text/javascript">
|
||||
|
||||
const recButton = document.createElement('button');
|
||||
recButton.id = 'recButton';
|
||||
const muteButton = document.createElement('button');
|
||||
muteButton.id = 'muteButton';
|
||||
|
||||
function getInitialState() {
|
||||
document.getElementById('client-content').contentWindow.postMessage('c_recording_status', '*');
|
||||
document.getElementById('client-content').contentWindow.postMessage('get_audio_joined_status', '*');
|
||||
}
|
||||
|
||||
function handleMessage(e) {
|
||||
switch (e) {
|
||||
case 'readyToConnect': {
|
||||
// get initial state
|
||||
getInitialState(); break; }
|
||||
case 'recordingStarted': {
|
||||
recButton.innerHTML = 'Stop Recording';
|
||||
break;
|
||||
}
|
||||
case 'recordingStopped': {
|
||||
recButton.innerHTML = 'Start Recording';
|
||||
break;
|
||||
}
|
||||
case 'selfMuted': {
|
||||
muteButton.innerHTML = 'Unmute me';
|
||||
break;
|
||||
}
|
||||
case 'selfUnmuted': {
|
||||
muteButton.innerHTML = 'Mute me';
|
||||
break;
|
||||
}
|
||||
case 'notInAudio': {
|
||||
muteButton.innerHTML = 'Not in audio';
|
||||
document.getElementById('muteButton').disabled = true;
|
||||
break;
|
||||
}
|
||||
case 'joinedAudio': {
|
||||
muteButton.innerHTML = '';
|
||||
document.getElementById('muteButton').disabled = false;
|
||||
document.getElementById('client-content').contentWindow.postMessage('c_mute_status', '*');
|
||||
break;
|
||||
}
|
||||
default: console.log('neither', { e });
|
||||
}
|
||||
}
|
||||
|
||||
// EventListener(Getting message from iframe)
|
||||
window.addEventListener('message', function(e) {
|
||||
handleMessage(e.data.response);
|
||||
});
|
||||
|
||||
// Clean up the body node before loading controls and the client
|
||||
document.body.innerHTML = '';
|
||||
|
||||
// Node for the Client
|
||||
const client = document.createElement('div');
|
||||
client.setAttribute('id', 'client');
|
||||
|
||||
const clientContent = document.createElement('iframe');
|
||||
clientContent.setAttribute('id', 'client-content');
|
||||
clientContent.setAttribute('src','<%=joinURL%>');
|
||||
|
||||
// // in case your iframe is on a different domain MYDOMAIN.com
|
||||
// clientContent.setAttribute('src','https://MYDOMAIN.com/demo/demoHTML5.jsp');
|
||||
|
||||
// to enable microphone or camera use allow your iframe domain explicitly
|
||||
// clientContent.setAttribute('allow','microphone https://MYDOMAIN.com; camera https://MYDOMAIN.com');
|
||||
|
||||
client.appendChild(clientContent);
|
||||
|
||||
// Node for the Controls
|
||||
const controls = document.createElement('div');
|
||||
controls.setAttribute('id', 'controls');
|
||||
controls.setAttribute('align', 'middle');
|
||||
controls.setAttribute('float', 'left');
|
||||
|
||||
// ****************** Controls *****************************/
|
||||
function recToggle(){
|
||||
document.getElementById("client-content").contentWindow.postMessage('c_record', '*');
|
||||
}
|
||||
|
||||
function muteToggle(){
|
||||
document.getElementById("client-content").contentWindow.postMessage('c_mute', '*');
|
||||
}
|
||||
|
||||
// Node for the control which controls recording functionality of the html5Client
|
||||
recButton.setAttribute('onClick', 'recToggle();');
|
||||
controls.appendChild(recButton);
|
||||
|
||||
muteButton.setAttribute('onClick', 'muteToggle();');
|
||||
controls.appendChild(muteButton);
|
||||
|
||||
// Append the nodes of contents to the body node
|
||||
document.body.appendChild(controls);
|
||||
document.body.appendChild(client);
|
||||
|
||||
</script>
|
||||
<%
|
||||
} else {
|
||||
%>
|
||||
|
||||
Error: getJoinURL() failed
|
||||
<p/>
|
||||
<%=joinURL %>
|
||||
|
||||
<%
|
||||
}
|
||||
}
|
||||
%>
|
||||
|
||||
<%@ include file="demo_footer.jsp"%>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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";
|
||||
|
@ -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) {
|
||||
|
@ -29,6 +29,10 @@ import java.text.DecimalFormatSymbols;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
import org.apache.commons.codec.digest.DigestUtils;
|
||||
import org.apache.commons.lang3.RandomStringUtils;
|
||||
@ -42,6 +46,7 @@ import org.apache.http.util.EntityUtils;
|
||||
import org.bigbluebutton.api.domain.BreakoutRoomsParams;
|
||||
import org.bigbluebutton.api.domain.LockSettingsParams;
|
||||
import org.bigbluebutton.api.domain.Meeting;
|
||||
import org.bigbluebutton.api.domain.Group;
|
||||
import org.bigbluebutton.api.util.ParamsUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@ -338,6 +343,43 @@ public class ParamsProcessorUtil {
|
||||
lockSettingsLockOnJoinConfigurable);
|
||||
}
|
||||
|
||||
private ArrayList<Group> processGroupsParams(Map<String, String> params) {
|
||||
ArrayList<Group> groups = new ArrayList<Group>();
|
||||
|
||||
String groupsParam = params.get(ApiParams.GROUPS);
|
||||
if (!StringUtils.isEmpty(groupsParam)) {
|
||||
JsonElement groupParamsJson = new Gson().fromJson(groupsParam, JsonElement.class);
|
||||
|
||||
if(groupParamsJson != null && groupParamsJson.isJsonArray()) {
|
||||
JsonArray groupsJson = groupParamsJson.getAsJsonArray();
|
||||
for (JsonElement groupJson : groupsJson) {
|
||||
if(groupJson.isJsonObject()) {
|
||||
JsonObject groupJsonObj = groupJson.getAsJsonObject();
|
||||
if(groupJsonObj.has("id")) {
|
||||
String groupId = groupJsonObj.get("id").getAsString();
|
||||
String groupName = "";
|
||||
if(groupJsonObj.has("name")) {
|
||||
groupName = groupJsonObj.get("name").getAsString();
|
||||
}
|
||||
|
||||
Vector<String> groupUsers = new Vector<>();
|
||||
if(groupJsonObj.has("roster") && groupJsonObj.get("roster").isJsonArray()) {
|
||||
for (JsonElement jsonElementUser : groupJsonObj.get("roster").getAsJsonArray()) {
|
||||
if(jsonElementUser.isJsonObject() && jsonElementUser.getAsJsonObject().has("id")) {
|
||||
groupUsers.add(jsonElementUser.getAsJsonObject().get("id").getAsString());
|
||||
}
|
||||
}
|
||||
}
|
||||
groups.add(new Group(groupId,groupName,groupUsers));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
public Meeting processCreateParams(Map<String, String> params) {
|
||||
|
||||
String meetingName = params.get(ApiParams.NAME);
|
||||
@ -496,6 +538,8 @@ public class ParamsProcessorUtil {
|
||||
|
||||
String meetingLayout = defaultMeetingLayout;
|
||||
|
||||
ArrayList<Group> groups = processGroupsParams(params);
|
||||
|
||||
if (!StringUtils.isEmpty(params.get(ApiParams.MEETING_LAYOUT))) {
|
||||
meetingLayout = params.get(ApiParams.MEETING_LAYOUT);
|
||||
}
|
||||
@ -559,6 +603,7 @@ public class ParamsProcessorUtil {
|
||||
.withLearningDashboardEnabled(learningDashboardEn)
|
||||
.withLearningDashboardCleanupDelayInMinutes(learningDashboardCleanupMins)
|
||||
.withLearningDashboardAccessToken(learningDashboardAccessToken)
|
||||
.withGroups(groups)
|
||||
.build();
|
||||
|
||||
if (!StringUtils.isEmpty(params.get(ApiParams.MODERATOR_ONLY_MESSAGE))) {
|
||||
|
@ -0,0 +1,42 @@
|
||||
package org.bigbluebutton.api.domain;
|
||||
|
||||
import java.util.Vector;
|
||||
|
||||
public class Group {
|
||||
|
||||
private String groupId = "";
|
||||
private String name = "";
|
||||
private Vector<String> usersExtId;
|
||||
|
||||
public Group(String groupId,
|
||||
String name,
|
||||
Vector<String> usersExtId) {
|
||||
this.groupId = groupId;
|
||||
this.name = name;
|
||||
this.usersExtId = usersExtId;
|
||||
}
|
||||
|
||||
public String getGroupId() {
|
||||
return groupId;
|
||||
}
|
||||
|
||||
public void setGroupId(String groupId) {
|
||||
this.groupId = groupId;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Vector<String> getUsersExtId() {
|
||||
return usersExtId;
|
||||
}
|
||||
|
||||
public void setUsersExtId(Vector<String> usersExtId) {
|
||||
this.usersExtId = usersExtId;
|
||||
}
|
||||
}
|
@ -82,6 +82,7 @@ public class Meeting {
|
||||
private final ConcurrentMap<String, Long> enteredUsers;
|
||||
private final Boolean isBreakout;
|
||||
private final List<String> breakoutRooms = new ArrayList<>();
|
||||
private ArrayList<Group> groups = new ArrayList<Group>();
|
||||
private String customLogoURL = "";
|
||||
private String customCopyright = "";
|
||||
private Boolean muteOnStart = false;
|
||||
@ -135,13 +136,14 @@ public class Meeting {
|
||||
isBreakout = builder.isBreakout;
|
||||
guestPolicy = builder.guestPolicy;
|
||||
authenticatedGuest = builder.authenticatedGuest;
|
||||
meetingLayout = builder.meetingLayout;
|
||||
meetingLayout = builder.meetingLayout;
|
||||
breakoutRoomsParams = builder.breakoutRoomsParams;
|
||||
lockSettingsParams = builder.lockSettingsParams;
|
||||
allowDuplicateExtUserid = builder.allowDuplicateExtUserid;
|
||||
endWhenNoModerator = builder.endWhenNoModerator;
|
||||
endWhenNoModeratorDelayInMinutes = builder.endWhenNoModeratorDelayInMinutes;
|
||||
html5InstanceId = builder.html5InstanceId;
|
||||
groups = builder.groups;
|
||||
|
||||
/*
|
||||
* A pad is a pair of padId and readOnlyId that represents
|
||||
@ -224,6 +226,10 @@ public class Meeting {
|
||||
|
||||
public void setHtml5InstanceId(int instanceId) { html5InstanceId = instanceId; }
|
||||
|
||||
public ArrayList<Group> getGroups() { return groups; }
|
||||
|
||||
public void setGroups(ArrayList<Group> groups) { this.groups = groups; }
|
||||
|
||||
public long getStartTime() {
|
||||
return startTime;
|
||||
}
|
||||
@ -751,13 +757,14 @@ public class Meeting {
|
||||
private boolean isBreakout;
|
||||
private String guestPolicy;
|
||||
private Boolean authenticatedGuest;
|
||||
private String meetingLayout;
|
||||
private String meetingLayout;
|
||||
private BreakoutRoomsParams breakoutRoomsParams;
|
||||
private LockSettingsParams lockSettingsParams;
|
||||
private Boolean allowDuplicateExtUserid;
|
||||
private Boolean endWhenNoModerator;
|
||||
private Integer endWhenNoModeratorDelayInMinutes;
|
||||
private int html5InstanceId;
|
||||
private ArrayList<Group> groups;
|
||||
|
||||
public Builder(String externalId, String internalId, long createTime) {
|
||||
this.externalId = externalId;
|
||||
@ -930,7 +937,12 @@ public class Meeting {
|
||||
return this;
|
||||
}
|
||||
|
||||
public Meeting build() {
|
||||
public Builder withGroups(ArrayList<Group> groups) {
|
||||
this.groups = groups;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Meeting build() {
|
||||
return new Meeting(this);
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,11 @@
|
||||
package org.bigbluebutton.api2;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Map;
|
||||
|
||||
import org.bigbluebutton.api.domain.BreakoutRoomsParams;
|
||||
import org.bigbluebutton.api.domain.LockSettingsParams;
|
||||
import org.bigbluebutton.api.domain.Group;
|
||||
import org.bigbluebutton.api.messaging.converters.messages.DestroyMeetingMessage;
|
||||
import org.bigbluebutton.api.messaging.converters.messages.EndMeetingMessage;
|
||||
import org.bigbluebutton.api.messaging.converters.messages.PublishedRecordingMessage;
|
||||
@ -33,7 +35,8 @@ public interface IBbbWebApiGWApp {
|
||||
Boolean keepEvents,
|
||||
BreakoutRoomsParams breakoutParams,
|
||||
LockSettingsParams lockSettingsParams,
|
||||
Integer html5InstanceId);
|
||||
Integer html5InstanceId,
|
||||
ArrayList<Group> groups);
|
||||
|
||||
void registerUser(String meetingID, String internalUserId, String fullname, String role,
|
||||
String externUserID, String authToken, String avatarURL,
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -1,12 +1,16 @@
|
||||
import React from 'react';
|
||||
import './App.css';
|
||||
import './bbb-icons.css';
|
||||
import { FormattedMessage, FormattedDate, injectIntl } from 'react-intl';
|
||||
import {
|
||||
FormattedMessage, FormattedDate, injectIntl, FormattedTime,
|
||||
} from 'react-intl';
|
||||
import { emojiConfigs } from './services/EmojiService';
|
||||
import Card from './components/Card';
|
||||
import UsersTable from './components/UsersTable';
|
||||
import StatusTable from './components/StatusTable';
|
||||
import PollsTable from './components/PollsTable';
|
||||
import ErrorMessage from './components/ErrorMessage';
|
||||
import { makeUserCSVData, tsToHHmmss } from './services/UserService';
|
||||
|
||||
class App extends React.Component {
|
||||
constructor(props) {
|
||||
@ -19,6 +23,7 @@ class App extends React.Component {
|
||||
learningDashboardAccessToken: '',
|
||||
ldAccessTokenCopied: false,
|
||||
sessionToken: '',
|
||||
lastUpdated: null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -29,6 +34,34 @@ class App extends React.Component {
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
handleSaveSessionData(e) {
|
||||
const { target: downloadButton } = e;
|
||||
const { intl } = this.props;
|
||||
const { activitiesJson } = this.state;
|
||||
const {
|
||||
name: meetingName, createdOn, users, polls,
|
||||
} = activitiesJson;
|
||||
const link = document.createElement('a');
|
||||
const data = makeUserCSVData(users, polls, intl);
|
||||
const filename = `LearningDashboard_${meetingName}_${new Date(createdOn).toISOString().substr(0, 10)}.csv`.replace(/ /g, '-');
|
||||
|
||||
downloadButton.setAttribute('disabled', 'true');
|
||||
downloadButton.style.cursor = 'not-allowed';
|
||||
link.setAttribute('href', `data:application/octet-stream,${encodeURIComponent(data)}`);
|
||||
link.setAttribute('download', filename);
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
downloadButton.innerHTML = intl.formatMessage({ id: 'app.learningDashboard.sessionDataDownloadedLabel', defaultMessage: 'Downloaded!' });
|
||||
setTimeout(() => {
|
||||
downloadButton.innerHTML = intl.formatMessage({ id: 'app.learningDashboard.downloadSessionDataLabel', defaultMessage: 'Download Session Data' });
|
||||
downloadButton.removeAttribute('disabled');
|
||||
downloadButton.style.cursor = 'pointer';
|
||||
downloadButton.focus();
|
||||
}, 3000);
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
setDashboardParams() {
|
||||
let learningDashboardAccessToken = '';
|
||||
let meetingId = '';
|
||||
@ -67,6 +100,39 @@ class App extends React.Component {
|
||||
this.fetchActivitiesJson);
|
||||
}
|
||||
|
||||
fetchMostUsedEmojis() {
|
||||
const { activitiesJson } = this.state;
|
||||
if (!activitiesJson) { return []; }
|
||||
|
||||
// Icon elements
|
||||
const emojis = [...Object.keys(emojiConfigs)];
|
||||
const icons = {};
|
||||
emojis.forEach((emoji) => {
|
||||
icons[emoji] = (<i className={`${emojiConfigs[emoji].icon} bbb-icon-card`} />);
|
||||
});
|
||||
|
||||
// Count each emoji
|
||||
const emojiCount = {};
|
||||
emojis.forEach((emoji) => {
|
||||
emojiCount[emoji] = 0;
|
||||
});
|
||||
const allEmojisUsed = Object
|
||||
.values(activitiesJson.users || {})
|
||||
.map((user) => user.emojis || [])
|
||||
.flat(1);
|
||||
allEmojisUsed.forEach((emoji) => {
|
||||
emojiCount[emoji.name] += 1;
|
||||
});
|
||||
|
||||
// Get the three most used
|
||||
const mostUsedEmojis = Object
|
||||
.entries(emojiCount)
|
||||
.sort(([, countA], [, countB]) => countA - countB)
|
||||
.reverse()
|
||||
.slice(0, 3);
|
||||
return mostUsedEmojis.map(([emoji]) => icons[emoji]);
|
||||
}
|
||||
|
||||
fetchActivitiesJson() {
|
||||
const { learningDashboardAccessToken, meetingId, sessionToken } = this.state;
|
||||
|
||||
@ -74,7 +140,7 @@ class App extends React.Component {
|
||||
fetch(`${meetingId}/${learningDashboardAccessToken}/learning_dashboard_data.json`)
|
||||
.then((response) => response.json())
|
||||
.then((json) => {
|
||||
this.setState({ activitiesJson: json, loading: false });
|
||||
this.setState({ activitiesJson: json, loading: false, lastUpdated: Date.now() });
|
||||
document.title = `Learning Dashboard - ${json.name}`;
|
||||
}).catch(() => {
|
||||
this.setState({ loading: false });
|
||||
@ -86,7 +152,7 @@ class App extends React.Component {
|
||||
.then((json) => {
|
||||
if (json.response.returncode === 'SUCCESS') {
|
||||
const jsonData = JSON.parse(json.response.data);
|
||||
this.setState({ activitiesJson: jsonData, loading: false });
|
||||
this.setState({ activitiesJson: jsonData, loading: false, lastUpdated: Date.now() });
|
||||
document.title = `Learning Dashboard - ${jsonData.name}`;
|
||||
} else {
|
||||
// When meeting is ended the sessionToken stop working, check for new cookies
|
||||
@ -119,25 +185,21 @@ class App extends React.Component {
|
||||
|
||||
render() {
|
||||
const {
|
||||
activitiesJson, tab, sessionToken, loading,
|
||||
activitiesJson, tab, sessionToken, loading, lastUpdated,
|
||||
learningDashboardAccessToken, ldAccessTokenCopied,
|
||||
} = this.state;
|
||||
const { intl } = this.props;
|
||||
|
||||
document.title = `${intl.formatMessage({ id: 'app.learningDashboard.dashboardTitle', defaultMessage: 'Learning Dashboard' })} - ${activitiesJson.name}`;
|
||||
|
||||
function totalOfRaiseHand() {
|
||||
function totalOfEmojis() {
|
||||
if (activitiesJson && activitiesJson.users) {
|
||||
return Object.values(activitiesJson.users)
|
||||
.reduce((prevVal, elem) => prevVal + elem.emojis.filter((emoji) => emoji.name === 'raiseHand').length, 0);
|
||||
.reduce((prevVal, elem) => prevVal + elem.emojis.length, 0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function tsToHHmmss(ts) {
|
||||
return (new Date(ts).toISOString().substr(11, 8));
|
||||
}
|
||||
|
||||
function totalOfActivity() {
|
||||
const minTime = Object.values(activitiesJson.users || {}).reduce((prevVal, elem) => {
|
||||
if (prevVal === 0 || elem.registeredOn < prevVal) return elem.registeredOn;
|
||||
@ -257,7 +319,7 @@ class App extends React.Component {
|
||||
</span>
|
||||
)
|
||||
: null
|
||||
}
|
||||
}
|
||||
<br />
|
||||
<span className="text-sm font-medium">{activitiesJson.name || ''}</span>
|
||||
</h1>
|
||||
@ -309,7 +371,7 @@ class App extends React.Component {
|
||||
}
|
||||
number={Object.values(activitiesJson.users || {})
|
||||
.filter((u) => activitiesJson.endedOn > 0 || u.leftOn === 0).length}
|
||||
cardClass="border-pink-500"
|
||||
cardClass={tab === 'overview' ? 'border-pink-500' : 'hover:border-pink-500'}
|
||||
iconClass="bg-pink-50 text-pink-500"
|
||||
onClick={() => {
|
||||
this.setState({ tab: 'overview' });
|
||||
@ -331,52 +393,6 @@ class App extends React.Component {
|
||||
</svg>
|
||||
</Card>
|
||||
</div>
|
||||
<div aria-hidden="true" className="cursor-pointer" onClick={() => { this.setState({ tab: 'polling' }); }}>
|
||||
<Card
|
||||
name={intl.formatMessage({ id: 'app.learningDashboard.indicators.polls', defaultMessage: 'Polls' })}
|
||||
number={Object.values(activitiesJson.polls || {}).length}
|
||||
cardClass="border-blue-500"
|
||||
iconClass="bg-blue-100 text-blue-500"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"
|
||||
/>
|
||||
</svg>
|
||||
</Card>
|
||||
</div>
|
||||
<div aria-hidden="true" className="cursor-pointer" onClick={() => { this.setState({ tab: 'status_timeline' }); }}>
|
||||
<Card
|
||||
name={intl.formatMessage({ id: 'app.learningDashboard.indicators.raiseHand', defaultMessage: 'Raise Hand' })}
|
||||
number={totalOfRaiseHand()}
|
||||
cardClass="border-purple-500"
|
||||
iconClass="bg-purple-200 text-purple-500"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M7 11.5V14m0-2.5v-6a1.5 1.5 0 113 0m-3 6a1.5 1.5 0 00-3 0v2a7.5 7.5 0 0015 0v-5a1.5 1.5 0 00-3 0m-6-3V11m0-5.5v-1a1.5 1.5 0 013 0v1m0 0V11m0-5.5a1.5 1.5 0 013 0v3m0 0V11"
|
||||
/>
|
||||
</svg>
|
||||
</Card>
|
||||
</div>
|
||||
<div aria-hidden="true" className="cursor-pointer" onClick={() => { this.setState({ tab: 'overview_activityscore' }); }}>
|
||||
<Card
|
||||
name={intl.formatMessage({ id: 'app.learningDashboard.indicators.activityScore', defaultMessage: 'Activity Score' })}
|
||||
@ -384,7 +400,7 @@ class App extends React.Component {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
cardClass="border-green-500"
|
||||
cardClass={tab === 'overview_activityscore' ? 'border-green-500' : 'hover:border-green-500'}
|
||||
iconClass="bg-green-200 text-green-500"
|
||||
>
|
||||
<svg
|
||||
@ -409,13 +425,46 @@ class App extends React.Component {
|
||||
</svg>
|
||||
</Card>
|
||||
</div>
|
||||
<div aria-hidden="true" className="cursor-pointer" onClick={() => { this.setState({ tab: 'status_timeline' }); }}>
|
||||
<Card
|
||||
name={intl.formatMessage({ id: 'app.learningDashboard.indicators.timeline', defaultMessage: 'Timeline' })}
|
||||
number={totalOfEmojis()}
|
||||
cardClass={tab === 'status_timeline' ? 'border-purple-500' : 'hover:border-purple-500'}
|
||||
iconClass="bg-purple-200 text-purple-500"
|
||||
>
|
||||
{this.fetchMostUsedEmojis()}
|
||||
</Card>
|
||||
</div>
|
||||
<div aria-hidden="true" className="cursor-pointer" onClick={() => { this.setState({ tab: 'polling' }); }}>
|
||||
<Card
|
||||
name={intl.formatMessage({ id: 'app.learningDashboard.indicators.polls', defaultMessage: 'Polls' })}
|
||||
number={Object.values(activitiesJson.polls || {}).length}
|
||||
cardClass={tab === 'polling' ? 'border-blue-500' : 'hover:border-blue-500'}
|
||||
iconClass="bg-blue-100 text-blue-500"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"
|
||||
/>
|
||||
</svg>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="block my-1 pr-2 text-xl font-semibold">
|
||||
<h1 className="block my-2 pr-2 text-xl font-semibold">
|
||||
{ tab === 'overview' || tab === 'overview_activityscore'
|
||||
? <FormattedMessage id="app.learningDashboard.usersTable.title" defaultMessage="Overview" />
|
||||
: null }
|
||||
{ tab === 'status_timeline'
|
||||
? <FormattedMessage id="app.learningDashboard.statusTimelineTable.title" defaultMessage="Status Timeline" />
|
||||
? <FormattedMessage id="app.learningDashboard.statusTimelineTable.title" defaultMessage="Timeline" />
|
||||
: null }
|
||||
{ tab === 'polling'
|
||||
? <FormattedMessage id="app.learningDashboard.pollsTable.title" defaultMessage="Polling" />
|
||||
@ -441,6 +490,44 @@ class App extends React.Component {
|
||||
: null }
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-8" />
|
||||
<div className="flex justify-between mb-8 text-xs text-gray-700 dark:text-gray-400 whitespace-nowrap flex-col sm:flex-row">
|
||||
<div className="flex flex-col justify-center mb-4 sm:mb-0">
|
||||
<p>
|
||||
{
|
||||
lastUpdated && (
|
||||
<>
|
||||
<FormattedMessage
|
||||
id="app.learningDashboard.lastUpdatedLabel"
|
||||
defaultMessage="Last updated at"
|
||||
/>
|
||||
|
||||
<FormattedTime
|
||||
value={lastUpdated}
|
||||
/>
|
||||
|
||||
<FormattedDate
|
||||
value={lastUpdated}
|
||||
year="numeric"
|
||||
month="long"
|
||||
day="numeric"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="border-2 border-gray-200 rounded-md px-4 py-2 bg-white focus:outline-none focus:ring ring-offset-2 focus:ring-gray-500 focus:ring-opacity-50"
|
||||
onClick={this.handleSaveSessionData.bind(this)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="app.learningDashboard.downloadSessionDataLabel"
|
||||
defaultMessage="Download Session Data"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -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";
|
||||
}
|
||||
|
@ -5,8 +5,36 @@ function Card(props) {
|
||||
number, name, children, iconClass, cardClass,
|
||||
} = props;
|
||||
|
||||
let icons;
|
||||
|
||||
try {
|
||||
React.Children.only(children);
|
||||
icons = (
|
||||
<div className={`p-2 text-orange-500 rounded-full ${iconClass}`}>
|
||||
{ children }
|
||||
</div>
|
||||
);
|
||||
} catch (e) {
|
||||
icons = (
|
||||
<div className="flex">
|
||||
{
|
||||
React.Children.map(children, (child, index) => {
|
||||
let offset = 4 / (index + 1);
|
||||
offset = index === (React.Children.count(children) - 1) ? 0 : offset;
|
||||
|
||||
return (
|
||||
<div className={`flex justify-center transform translate-x-${offset} border-2 border-white p-2 text-orange-500 rounded-full z-${index * 10} ${iconClass}`}>
|
||||
{ child }
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-between p-4 bg-white rounded-md shadow border-l-8 ${cardClass}`}>
|
||||
<div className={`flex items-start justify-between p-3 bg-white rounded shadow border-l-4 border-white ${cardClass}`}>
|
||||
<div className="w-70">
|
||||
<p className="text-lg font-semibold text-gray-700">
|
||||
{ number }
|
||||
@ -15,9 +43,7 @@ function Card(props) {
|
||||
{ name }
|
||||
</p>
|
||||
</div>
|
||||
<div className={`p-3 mr-4 text-orange-500 rounded-full ${iconClass}`}>
|
||||
{ children }
|
||||
</div>
|
||||
{icons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -14,6 +14,41 @@ class PollsTable extends React.Component {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof polls === 'object' && Object.values(polls).length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center py-24">
|
||||
<div className="mb-1 p-3 text-orange-500 rounded-full bg-blue-100 text-blue-500">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-gray-700">
|
||||
<FormattedMessage
|
||||
id="app.learningDashboard.pollsTable.noPollsCreatedHeading"
|
||||
defaultMessage="No polls have been created"
|
||||
/>
|
||||
</p>
|
||||
<p className="mb-2 text-sm font-medium text-gray-600">
|
||||
<FormattedMessage
|
||||
id="app.learningDashboard.pollsTable.noPollsCreatedMessage"
|
||||
defaultMessage="Once a poll has been sent to users, their results will appear in this list."
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="w-full whitespace-nowrap">
|
||||
<thead>
|
||||
|
@ -1,9 +1,26 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage, injectIntl } from 'react-intl';
|
||||
import { getUserEmojisSummary, emojiConfigs } from '../services/EmojiService';
|
||||
import { emojiConfigs, filterUserEmojis } from '../services/EmojiService';
|
||||
import UserAvatar from './UserAvatar';
|
||||
|
||||
class StatusTable extends React.Component {
|
||||
componentDidMount() {
|
||||
// This code is needed to prevent the emoji in the first cell
|
||||
// after the username from overflowing
|
||||
const emojis = document.getElementsByClassName('emojiOnFirstCell');
|
||||
for (let i = 0; i < emojis.length; i += 1) {
|
||||
const emojiStyle = window.getComputedStyle(emojis[i]);
|
||||
let offsetLeft = emojiStyle
|
||||
.left
|
||||
.replace(/px/g, '')
|
||||
.trim();
|
||||
offsetLeft = Number(offsetLeft);
|
||||
if (offsetLeft < 0) {
|
||||
emojis[i].style.offsetLeft = '0px';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const spanMinutes = 10 * 60000; // 10 minutes default
|
||||
const { allUsers, intl } = this.props;
|
||||
@ -32,7 +49,7 @@ class StatusTable extends React.Component {
|
||||
<table className="w-full whitespace-nowrap">
|
||||
<thead>
|
||||
<tr className="text-xs font-semibold tracking-wide text-gray-500 uppercase border-b bg-gray-100">
|
||||
<th className="px-4 py-3 col-text-left">
|
||||
<th className="px-4 py-3 col-text-left sticky left-0 z-30 bg-inherit">
|
||||
<FormattedMessage id="app.learningDashboard.user" defaultMessage="User" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@ -58,8 +75,8 @@ class StatusTable extends React.Component {
|
||||
return 0;
|
||||
})
|
||||
.map((user) => (
|
||||
<tr className="text-gray-700">
|
||||
<td className="px-4 py-3">
|
||||
<tr className="text-gray-700 bg-inherit">
|
||||
<td className="bg-inherit sticky left-0 z-30 px-4 py-3">
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="relative hidden w-8 h-8 rounded-full md:block">
|
||||
<UserAvatar user={user} />
|
||||
@ -71,81 +88,97 @@ class StatusTable extends React.Component {
|
||||
</div>
|
||||
</td>
|
||||
{ periods.map((period) => {
|
||||
const userEmojisInPeriod = getUserEmojisSummary(user,
|
||||
const userEmojisInPeriod = filterUserEmojis(user,
|
||||
null,
|
||||
period,
|
||||
period + spanMinutes);
|
||||
const { registeredOn, leftOn } = user;
|
||||
const boundaryLeft = period;
|
||||
const boundaryRight = period + spanMinutes - 1;
|
||||
return (
|
||||
<td className="px-4 py-3 text-sm col-text-left">
|
||||
<td className="relative px-4 py-3 text-sm col-text-left">
|
||||
{
|
||||
user.registeredOn > period && user.registeredOn < period + spanMinutes
|
||||
(registeredOn >= boundaryLeft && registeredOn <= boundaryRight)
|
||||
|| (leftOn >= boundaryLeft && leftOn <= boundaryRight)
|
||||
|| (boundaryLeft > registeredOn && boundaryRight < leftOn)
|
||||
|| (boundaryLeft >= registeredOn && leftOn === 0)
|
||||
? (
|
||||
<span title={intl.formatDate(user.registeredOn, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 text-xs text-green-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
|
||||
(function makeLineThrough() {
|
||||
let roundedLeft = registeredOn >= boundaryLeft
|
||||
&& registeredOn <= boundaryRight ? 'rounded-l' : '';
|
||||
let roundedRight = leftOn > boundaryLeft
|
||||
&& leftOn < boundaryRight ? 'rounded-r' : '';
|
||||
let offsetLeft = 0;
|
||||
let offsetRight = 0;
|
||||
if (registeredOn >= boundaryLeft && registeredOn <= boundaryRight) {
|
||||
offsetLeft = ((registeredOn - boundaryLeft) * 100) / spanMinutes;
|
||||
}
|
||||
if (leftOn >= boundaryLeft && leftOn <= boundaryRight) {
|
||||
offsetRight = ((boundaryRight - leftOn) * 100) / spanMinutes;
|
||||
}
|
||||
let width = '';
|
||||
if (offsetLeft === 0 && offsetRight >= 99) {
|
||||
width = 'w-1.5';
|
||||
}
|
||||
if (offsetRight === 0 && offsetLeft >= 99) {
|
||||
width = 'w-1.5';
|
||||
}
|
||||
if (offsetLeft && offsetRight) {
|
||||
const variation = offsetLeft - offsetRight;
|
||||
if (
|
||||
variation > -1 && variation < 1
|
||||
) {
|
||||
width = 'w-1.5';
|
||||
}
|
||||
}
|
||||
const isRTL = document.dir === 'rtl';
|
||||
if (isRTL) {
|
||||
const aux = roundedRight;
|
||||
|
||||
if (roundedLeft !== '') roundedRight = 'rounded-r';
|
||||
else roundedRight = '';
|
||||
|
||||
if (aux !== '') roundedLeft = 'rounded-l';
|
||||
else roundedLeft = '';
|
||||
}
|
||||
// height / 2
|
||||
const redress = '(0.375rem / 2)';
|
||||
return (
|
||||
<div
|
||||
className={`h-1.5 ${width} bg-gray-200 absolute inset-x-0 z-10 ${roundedLeft} ${roundedRight}`}
|
||||
style={{
|
||||
top: `calc(50% - ${redress})`,
|
||||
left: `${isRTL ? offsetRight : offsetLeft}%`,
|
||||
right: `${isRTL ? offsetLeft : offsetRight}%`,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
})()
|
||||
) : null
|
||||
}
|
||||
{ Object.keys(userEmojisInPeriod)
|
||||
.map((emoji) => (
|
||||
<div className="text-sm text-gray-800">
|
||||
<i className={`${emojiConfigs[emoji].icon} text-sm`} />
|
||||
|
||||
{ userEmojisInPeriod[emoji] }
|
||||
|
||||
<FormattedMessage
|
||||
id={emojiConfigs[emoji].intlId}
|
||||
defaultMessage={emojiConfigs[emoji].defaultMessage}
|
||||
/>
|
||||
{ userEmojisInPeriod.map((emoji) => {
|
||||
const offset = ((emoji.sentOn - period) * 100) / spanMinutes;
|
||||
const origin = document.dir === 'rtl' ? 'right' : 'left';
|
||||
const onFirstCell = period === firstRegisteredOnTime;
|
||||
// font-size / 2 + padding right/left + border-width
|
||||
const redress = '(0.875rem / 2 + 0.25rem + 2px)';
|
||||
return (
|
||||
<div
|
||||
className={`flex absolute p-1 border-white border-2 rounded-full text-sm z-20 bg-purple-500 text-purple-200 ${onFirstCell ? 'emojiOnFirstCell' : ''}`}
|
||||
role="status"
|
||||
style={{
|
||||
top: `calc(50% - ${redress})`,
|
||||
[origin]: `calc(${offset}% - ${redress})`,
|
||||
}}
|
||||
title={intl.formatMessage({
|
||||
id: emojiConfigs[emoji.name].intlId,
|
||||
defaultMessage: emojiConfigs[emoji.name].defaultMessage,
|
||||
})}
|
||||
>
|
||||
<i className={`${emojiConfigs[emoji.name].icon} text-sm bbb-icon-timeline`} />
|
||||
</div>
|
||||
)) }
|
||||
{
|
||||
user.leftOn > period && user.leftOn < period + spanMinutes
|
||||
? (
|
||||
<span title={intl.formatDate(user.leftOn, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
);
|
||||
})}
|
||||
</td>
|
||||
);
|
||||
}) }
|
||||
|
@ -3,6 +3,7 @@ import {
|
||||
FormattedMessage, FormattedDate, FormattedNumber, injectIntl,
|
||||
} from 'react-intl';
|
||||
import { getUserEmojisSummary, emojiConfigs } from '../services/EmojiService';
|
||||
import { getActivityScore, getSumOfTime, tsToHHmmss } from '../services/UserService';
|
||||
import UserAvatar from './UserAvatar';
|
||||
|
||||
class UsersTable extends React.Component {
|
||||
@ -30,57 +31,6 @@ class UsersTable extends React.Component {
|
||||
|
||||
const { activityscoreOrder } = this.state;
|
||||
|
||||
function getSumOfTime(eventsArr) {
|
||||
return eventsArr.reduce((prevVal, elem) => {
|
||||
if (elem.stoppedOn > 0) return prevVal + (elem.stoppedOn - elem.startedOn);
|
||||
return prevVal + (new Date().getTime() - elem.startedOn);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function getActivityScore(user) {
|
||||
if (user.isModerator) return 0;
|
||||
|
||||
const allUsersArr = Object.values(allUsers || {}).filter((currUser) => !currUser.isModerator);
|
||||
let userPoints = 0;
|
||||
|
||||
// Calculate points of Talking
|
||||
const usersTalkTime = allUsersArr.map((currUser) => currUser.talk.totalTime);
|
||||
const maxTalkTime = Math.max(...usersTalkTime);
|
||||
if (maxTalkTime > 0) {
|
||||
userPoints += (user.talk.totalTime / maxTalkTime) * 2;
|
||||
}
|
||||
|
||||
// Calculate points of Chatting
|
||||
const usersTotalOfMessages = allUsersArr.map((currUser) => currUser.totalOfMessages);
|
||||
const maxMessages = Math.max(...usersTotalOfMessages);
|
||||
if (maxMessages > 0) {
|
||||
userPoints += (user.totalOfMessages / maxMessages) * 2;
|
||||
}
|
||||
|
||||
// Calculate points of Raise hand
|
||||
const usersRaiseHand = allUsersArr.map((currUser) => currUser.emojis.filter((emoji) => emoji.name === 'raiseHand').length);
|
||||
const maxRaiseHand = Math.max(...usersRaiseHand);
|
||||
const userRaiseHand = user.emojis.filter((emoji) => emoji.name === 'raiseHand').length;
|
||||
if (maxRaiseHand > 0) {
|
||||
userPoints += (userRaiseHand / maxRaiseHand) * 2;
|
||||
}
|
||||
|
||||
// Calculate points of Emojis
|
||||
const usersEmojis = allUsersArr.map((currUser) => currUser.emojis.filter((emoji) => emoji.name !== 'raiseHand').length);
|
||||
const maxEmojis = Math.max(...usersEmojis);
|
||||
const userEmojis = user.emojis.filter((emoji) => emoji.name !== 'raiseHand').length;
|
||||
if (maxEmojis > 0) {
|
||||
userPoints += (userEmojis / maxEmojis) * 2;
|
||||
}
|
||||
|
||||
// Calculate points of Polls
|
||||
if (totalOfPolls > 0) {
|
||||
userPoints += (Object.values(user.answers || {}).length / totalOfPolls) * 2;
|
||||
}
|
||||
|
||||
return userPoints;
|
||||
}
|
||||
|
||||
const usersEmojisSummary = {};
|
||||
Object.values(allUsers || {}).forEach((user) => {
|
||||
usersEmojisSummary[user.intId] = getUserEmojisSummary(user, 'raiseHand');
|
||||
@ -91,13 +41,9 @@ class UsersTable extends React.Component {
|
||||
return Math.ceil((totalUserOnlineTime / totalOfActivityTime) * 100);
|
||||
}
|
||||
|
||||
function tsToHHmmss(ts) {
|
||||
return (new Date(ts).toISOString().substr(11, 8));
|
||||
}
|
||||
|
||||
const usersActivityScore = {};
|
||||
Object.values(allUsers || {}).forEach((user) => {
|
||||
usersActivityScore[user.intId] = getActivityScore(user);
|
||||
usersActivityScore[user.intId] = getActivityScore(user, allUsers, totalOfPolls);
|
||||
});
|
||||
|
||||
return (
|
||||
@ -187,49 +133,48 @@ class UsersTable extends React.Component {
|
||||
if (a.name.toLowerCase() > b.name.toLowerCase()) return 1;
|
||||
return 0;
|
||||
})
|
||||
.map((user) => (
|
||||
<tr key={user} className="text-gray-700">
|
||||
<td className="px-4 py-3 col-text-left text-sm">
|
||||
<div className="inline-block relative w-8 h-8 rounded-full">
|
||||
{/* <img className="object-cover w-full h-full rounded-full" */}
|
||||
{/* src="" */}
|
||||
{/* alt="" loading="lazy" /> */}
|
||||
<UserAvatar user={user} />
|
||||
<div
|
||||
className="absolute inset-0 rounded-full shadow-inner"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="inline-block">
|
||||
<p className="font-semibold">
|
||||
{user.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedDate
|
||||
value={user.registeredOn}
|
||||
month="short"
|
||||
day="numeric"
|
||||
hour="2-digit"
|
||||
minute="2-digit"
|
||||
second="2-digit"
|
||||
.map((user) => {
|
||||
const opacity = user.leftOn > 0 ? 'opacity-75' : '';
|
||||
return (
|
||||
<tr key={user} className="text-gray-700">
|
||||
<td className={`px-4 py-3 col-text-left text-sm ${opacity}`}>
|
||||
<div className="inline-block relative w-8 h-8 rounded-full">
|
||||
<UserAvatar user={user} />
|
||||
<div
|
||||
className="absolute inset-0 rounded-full shadow-inner"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</p>
|
||||
{
|
||||
</div>
|
||||
|
||||
<div className="inline-block">
|
||||
<p className="font-semibold">
|
||||
{user.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedDate
|
||||
value={user.registeredOn}
|
||||
month="short"
|
||||
day="numeric"
|
||||
hour="2-digit"
|
||||
minute="2-digit"
|
||||
second="2-digit"
|
||||
/>
|
||||
</p>
|
||||
{
|
||||
user.leftOn > 0
|
||||
? (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
@ -259,154 +204,154 @@ class UsersTable extends React.Component {
|
||||
</p>
|
||||
)
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-center items-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728m-9.9-2.829a5 5 0 010-7.07m7.072 0a5 5 0 010 7.07M13 12a1 1 0 11-2 0 1 1 0 012 0z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{ tsToHHmmss(
|
||||
(user.leftOn > 0
|
||||
? user.leftOn
|
||||
: (new Date()).getTime()) - user.registeredOn,
|
||||
) }
|
||||
<br />
|
||||
<div
|
||||
className="bg-gray-200 transition-colors duration-500 rounded-full overflow-hidden"
|
||||
title={`${getOnlinePercentage(user.registeredOn, user.leftOn).toString()}%`}
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-sm text-center items-center ${opacity}`}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728m-9.9-2.829a5 5 0 010-7.07m7.072 0a5 5 0 010 7.07M13 12a1 1 0 11-2 0 1 1 0 012 0z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{ tsToHHmmss(
|
||||
(user.leftOn > 0
|
||||
? user.leftOn
|
||||
: (new Date()).getTime()) - user.registeredOn,
|
||||
) }
|
||||
<br />
|
||||
<div
|
||||
aria-label=" "
|
||||
className="bg-gradient-to-br from-green-100 to-green-600 transition-colors duration-900 h-1.5"
|
||||
style={{ width: `${getOnlinePercentage(user.registeredOn, user.leftOn).toString()}%` }}
|
||||
role="progressbar"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-center">
|
||||
{ user.talk.totalTime > 0
|
||||
? (
|
||||
<span className="text-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
|
||||
className="bg-gray-200 transition-colors duration-500 rounded-full overflow-hidden"
|
||||
title={`${getOnlinePercentage(user.registeredOn, user.leftOn).toString()}%`}
|
||||
>
|
||||
<div
|
||||
aria-label=" "
|
||||
className="bg-gradient-to-br from-green-100 to-green-600 transition-colors duration-900 h-1.5"
|
||||
style={{ width: `${getOnlinePercentage(user.registeredOn, user.leftOn).toString()}%` }}
|
||||
role="progressbar"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-sm text-center items-center ${opacity}`}>
|
||||
{ user.talk.totalTime > 0
|
||||
? (
|
||||
<span className="text-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{ tsToHHmmss(user.talk.totalTime) }
|
||||
</span>
|
||||
) : null }
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-sm text-center ${opacity}`}>
|
||||
{ getSumOfTime(user.webcams) > 0
|
||||
? (
|
||||
<span className="text-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{ tsToHHmmss(getSumOfTime(user.webcams)) }
|
||||
</span>
|
||||
) : null }
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-sm text-center ${opacity}`}>
|
||||
{ user.totalOfMessages > 0
|
||||
? (
|
||||
<span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{user.totalOfMessages}
|
||||
</span>
|
||||
) : null }
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-sm col-text-left ${opacity}`}>
|
||||
{
|
||||
Object.keys(usersEmojisSummary[user.intId] || {}).map((emoji) => (
|
||||
<div className="text-xs whitespace-nowrap">
|
||||
<i className={`${emojiConfigs[emoji].icon} text-sm`} />
|
||||
|
||||
{ usersEmojisSummary[user.intId][emoji] }
|
||||
|
||||
<FormattedMessage
|
||||
id={emojiConfigs[emoji].intlId}
|
||||
defaultMessage={emojiConfigs[emoji].defaultMessage}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{ tsToHHmmss(user.talk.totalTime) }
|
||||
</span>
|
||||
) : null }
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-center">
|
||||
{ getSumOfTime(user.webcams) > 0
|
||||
? (
|
||||
<span className="text-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{ tsToHHmmss(getSumOfTime(user.webcams)) }
|
||||
</span>
|
||||
) : null }
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-center">
|
||||
{ user.totalOfMessages > 0
|
||||
? (
|
||||
<span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{user.totalOfMessages}
|
||||
</span>
|
||||
) : null }
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm col-text-left">
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-sm text-center ${opacity}`}>
|
||||
{ user.emojis.filter((emoji) => emoji.name === 'raiseHand').length > 0
|
||||
? (
|
||||
<span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M7 11.5V14m0-2.5v-6a1.5 1.5 0 113 0m-3 6a1.5 1.5 0 00-3 0v2a7.5 7.5 0 0015 0v-5a1.5 1.5 0 00-3 0m-6-3V11m0-5.5v-1a1.5 1.5 0 013 0v1m0 0V11m0-5.5a1.5 1.5 0 013 0v3m0 0V11"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{user.emojis.filter((emoji) => emoji.name === 'raiseHand').length}
|
||||
</span>
|
||||
) : null }
|
||||
</td>
|
||||
{
|
||||
Object.keys(usersEmojisSummary[user.intId] || {}).map((emoji) => (
|
||||
<div className="text-xs whitespace-nowrap">
|
||||
<i className={`${emojiConfigs[emoji].icon} text-sm`} />
|
||||
|
||||
{ usersEmojisSummary[user.intId][emoji] }
|
||||
|
||||
<FormattedMessage
|
||||
id={emojiConfigs[emoji].intlId}
|
||||
defaultMessage={emojiConfigs[emoji].defaultMessage}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-center">
|
||||
{ user.emojis.filter((emoji) => emoji.name === 'raiseHand').length > 0
|
||||
? (
|
||||
<span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M7 11.5V14m0-2.5v-6a1.5 1.5 0 113 0m-3 6a1.5 1.5 0 00-3 0v2a7.5 7.5 0 0015 0v-5a1.5 1.5 0 00-3 0m-6-3V11m0-5.5v-1a1.5 1.5 0 013 0v1m0 0V11m0-5.5a1.5 1.5 0 013 0v3m0 0V11"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{user.emojis.filter((emoji) => emoji.name === 'raiseHand').length}
|
||||
</span>
|
||||
) : null }
|
||||
</td>
|
||||
{
|
||||
!user.isModerator ? (
|
||||
<td className="px-4 py-3 text-sm text-center items">
|
||||
<td className={`px-4 py-3 text-sm text-center items ${opacity}`}>
|
||||
<svg viewBox="0 0 82 12" width="82" height="12" className="flex-none m-auto inline">
|
||||
<rect width="12" height="12" fill={usersActivityScore[user.intId] > 0 ? '#A7F3D0' : '#e4e4e7'} />
|
||||
<rect width="12" height="12" x="14" fill={usersActivityScore[user.intId] > 2 ? '#6EE7B7' : '#e4e4e7'} />
|
||||
@ -422,23 +367,24 @@ class UsersTable extends React.Component {
|
||||
</td>
|
||||
) : <td />
|
||||
}
|
||||
<td className="px-4 py-3 text-xs text-center">
|
||||
{
|
||||
user.leftOn > 0
|
||||
? (
|
||||
<span className="px-2 py-1 font-semibold leading-tight text-red-700 bg-red-100 rounded-full">
|
||||
<FormattedMessage id="app.learningDashboard.usersTable.userStatusOffline" defaultMessage="Offline" />
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span className="px-2 py-1 font-semibold leading-tight text-green-700 bg-green-100 rounded-full">
|
||||
<FormattedMessage id="app.learningDashboard.usersTable.userStatusOnline" defaultMessage="Online" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
<td className="px-4 py-3 text-xs text-center">
|
||||
{
|
||||
user.leftOn > 0
|
||||
? (
|
||||
<span className="px-2 py-1 font-semibold leading-tight text-red-700 bg-red-100 rounded-full">
|
||||
<FormattedMessage id="app.learningDashboard.usersTable.userStatusOffline" defaultMessage="Offline" />
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span className="px-2 py-1 font-semibold leading-tight text-green-700 bg-green-100 rounded-full">
|
||||
<FormattedMessage id="app.learningDashboard.usersTable.userStatusOnline" defaultMessage="Online" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr className="text-gray-700">
|
||||
<td colSpan="8" className="px-4 py-3 text-sm text-center">
|
||||
|
@ -2,3 +2,33 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
.bg-inherit {
|
||||
background-color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.translate-x-0 {
|
||||
--tw-translate-x: 0px;
|
||||
}
|
||||
|
||||
.translate-x-2 {
|
||||
--tw-translate-x: 0.5rem;
|
||||
}
|
||||
|
||||
.translate-x-4 {
|
||||
--tw-translate-x: 1rem;
|
||||
}
|
||||
|
||||
[dir="rtl"] .translate-x-0 {
|
||||
--tw-translate-x: 0px;
|
||||
}
|
||||
|
||||
[dir="rtl"] .translate-x-2 {
|
||||
--tw-translate-x: -0.5rem;
|
||||
}
|
||||
|
||||
[dir="rtl"] .translate-x-4 {
|
||||
--tw-translate-x: -1rem;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
202
bbb-learning-dashboard/src/services/UserService.js
Normal file
202
bbb-learning-dashboard/src/services/UserService.js
Normal file
@ -0,0 +1,202 @@
|
||||
import { emojiConfigs, filterUserEmojis } from './EmojiService';
|
||||
|
||||
export function getActivityScore(user, allUsers, totalOfPolls) {
|
||||
if (user.isModerator) return 0;
|
||||
|
||||
const allUsersArr = Object.values(allUsers || {}).filter((currUser) => !currUser.isModerator);
|
||||
let userPoints = 0;
|
||||
|
||||
// Calculate points of Talking
|
||||
const usersTalkTime = allUsersArr.map((currUser) => currUser.talk.totalTime);
|
||||
const maxTalkTime = Math.max(...usersTalkTime);
|
||||
if (maxTalkTime > 0) {
|
||||
userPoints += (user.talk.totalTime / maxTalkTime) * 2;
|
||||
}
|
||||
|
||||
// Calculate points of Chatting
|
||||
const usersTotalOfMessages = allUsersArr.map((currUser) => currUser.totalOfMessages);
|
||||
const maxMessages = Math.max(...usersTotalOfMessages);
|
||||
if (maxMessages > 0) {
|
||||
userPoints += (user.totalOfMessages / maxMessages) * 2;
|
||||
}
|
||||
|
||||
// Calculate points of Raise hand
|
||||
const usersRaiseHand = allUsersArr.map((currUser) => currUser.emojis.filter((emoji) => emoji.name === 'raiseHand').length);
|
||||
const maxRaiseHand = Math.max(...usersRaiseHand);
|
||||
const userRaiseHand = user.emojis.filter((emoji) => emoji.name === 'raiseHand').length;
|
||||
if (maxRaiseHand > 0) {
|
||||
userPoints += (userRaiseHand / maxRaiseHand) * 2;
|
||||
}
|
||||
|
||||
// Calculate points of Emojis
|
||||
const usersEmojis = allUsersArr.map((currUser) => currUser.emojis.filter((emoji) => emoji.name !== 'raiseHand').length);
|
||||
const maxEmojis = Math.max(...usersEmojis);
|
||||
const userEmojis = user.emojis.filter((emoji) => emoji.name !== 'raiseHand').length;
|
||||
if (maxEmojis > 0) {
|
||||
userPoints += (userEmojis / maxEmojis) * 2;
|
||||
}
|
||||
|
||||
// Calculate points of Polls
|
||||
if (totalOfPolls > 0) {
|
||||
userPoints += (Object.values(user.answers || {}).length / totalOfPolls) * 2;
|
||||
}
|
||||
|
||||
return userPoints;
|
||||
}
|
||||
|
||||
export function getSumOfTime(eventsArr) {
|
||||
return eventsArr.reduce((prevVal, elem) => {
|
||||
if (elem.stoppedOn > 0) return prevVal + (elem.stoppedOn - elem.startedOn);
|
||||
return prevVal + (new Date().getTime() - elem.startedOn);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function tsToHHmmss(ts) {
|
||||
return (new Date(ts).toISOString().substr(11, 8));
|
||||
}
|
||||
|
||||
const tableHeaderFields = [
|
||||
{
|
||||
id: 'name',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
{
|
||||
id: 'moderator',
|
||||
defaultMessage: 'Moderator',
|
||||
},
|
||||
{
|
||||
id: 'activityScore',
|
||||
defaultMessage: 'Activity Score',
|
||||
},
|
||||
{
|
||||
id: 'colTalk',
|
||||
defaultMessage: 'Talk Time',
|
||||
},
|
||||
{
|
||||
id: 'colWebcam',
|
||||
defaultMessage: 'Webcam Time',
|
||||
},
|
||||
{
|
||||
id: 'colMessages',
|
||||
defaultMessage: 'Messages',
|
||||
},
|
||||
{
|
||||
id: 'colEmojis',
|
||||
defaultMessage: 'Emojis',
|
||||
},
|
||||
{
|
||||
id: 'pollVotes',
|
||||
defaultMessage: 'Poll Votes',
|
||||
},
|
||||
{
|
||||
id: 'colRaiseHands',
|
||||
defaultMessage: 'Raise Hands',
|
||||
},
|
||||
{
|
||||
id: 'join',
|
||||
defaultMessage: 'Join',
|
||||
},
|
||||
{
|
||||
id: 'left',
|
||||
defaultMessage: 'Left',
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
defaultMessage: 'Duration',
|
||||
},
|
||||
];
|
||||
|
||||
export function makeUserCSVData(users, polls, intl) {
|
||||
const userRecords = {};
|
||||
const userValues = Object.values(users || {});
|
||||
const pollValues = Object.values(polls || {});
|
||||
const skipEmojis = Object
|
||||
.keys(emojiConfigs)
|
||||
.filter((emoji) => emoji !== 'raiseHand')
|
||||
.join(',');
|
||||
|
||||
for (let i = 0; i < userValues.length; i += 1) {
|
||||
const user = userValues[i];
|
||||
const webcam = getSumOfTime(user.webcams);
|
||||
const duration = user.leftOn > 0
|
||||
? user.leftOn - user.registeredOn
|
||||
: (new Date()).getTime() - user.registeredOn;
|
||||
|
||||
const userData = {
|
||||
name: user.name,
|
||||
moderator: user.isModerator.toString().toUpperCase(),
|
||||
activityScore: intl.formatNumber(
|
||||
getActivityScore(user, userValues, Object.values(polls || {}).length),
|
||||
{
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1,
|
||||
},
|
||||
),
|
||||
talk: user.talk.totalTime > 0 ? tsToHHmmss(user.talk.totalTime) : '-',
|
||||
webcam: webcam > 0 ? tsToHHmmss(webcam) : '-',
|
||||
messages: user.totalOfMessages,
|
||||
raiseHand: filterUserEmojis(user, 'raiseHand').length,
|
||||
answers: Object.keys(user.answers).length,
|
||||
emojis: filterUserEmojis(user, skipEmojis).length,
|
||||
registeredOn: intl.formatDate(user.registeredOn, {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}),
|
||||
leftOn: intl.formatDate(user.leftOn, {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}),
|
||||
duration: tsToHHmmss(duration),
|
||||
};
|
||||
|
||||
for (let j = 0; j < pollValues.length; j += 1) {
|
||||
userData[`Poll_${j}`] = user.answers[pollValues[j].pollId] || '-';
|
||||
}
|
||||
|
||||
const userFields = Object
|
||||
.values(userData)
|
||||
.map((data) => `"${data}"`);
|
||||
|
||||
userRecords[user.intId] = userFields.join(',');
|
||||
}
|
||||
|
||||
const tableHeaderFieldsTranslated = tableHeaderFields
|
||||
.map(({ id, defaultMessage }) => intl.formatMessage({
|
||||
id: `app.learningDashboard.usersTable.${id}`,
|
||||
defaultMessage,
|
||||
}));
|
||||
|
||||
let header = tableHeaderFieldsTranslated.join(',');
|
||||
let anonymousRecord = `"${intl.formatMessage({
|
||||
id: 'app.learningDashboard.pollsTable.anonymousRowName',
|
||||
defaultMessage: 'Anonymous',
|
||||
})}"`;
|
||||
|
||||
// Skip the fields for the anonymous record
|
||||
for (let k = 0; k < header.split(',').length - 1; k += 1) {
|
||||
// Empty fields
|
||||
anonymousRecord += ',""';
|
||||
}
|
||||
|
||||
for (let i = 0; i < pollValues.length; i += 1) {
|
||||
// Add the poll question headers
|
||||
header += `,${pollValues[i].question || `Poll ${i + 1}`}`;
|
||||
|
||||
// Add the anonymous answers
|
||||
anonymousRecord += `,"${pollValues[i].anonymousAnswers.join('\r\n')}"`;
|
||||
}
|
||||
userRecords.Anonymous = anonymousRecord;
|
||||
|
||||
return [
|
||||
header,
|
||||
Object.values(userRecords).join('\r\n'),
|
||||
].join('\r\n');
|
||||
}
|
@ -8,4 +8,4 @@ module.exports = {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
};
|
||||
|
@ -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();
|
||||
|
13
bigbluebutton-html5/imports/api/breakouts-history/index.js
Normal file
13
bigbluebutton-html5/imports/api/breakouts-history/index.js
Normal file
@ -0,0 +1,13 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
const collectionOptions = Meteor.isClient ? {
|
||||
connection: null,
|
||||
} : {};
|
||||
|
||||
const BreakoutsHistory = new Mongo.Collection('breakouts-history', collectionOptions);
|
||||
|
||||
if (Meteor.isServer) {
|
||||
BreakoutsHistory._ensureIndex({ meetingId: 1 });
|
||||
}
|
||||
|
||||
export default BreakoutsHistory;
|
@ -0,0 +1,4 @@
|
||||
import RedisPubSub from '/imports/startup/server/redis';
|
||||
import handleBreakoutRoomsList from './handlers/breakoutRoomsList';
|
||||
|
||||
RedisPubSub.on('BreakoutRoomsListEvtMsg', handleBreakoutRoomsList);
|
@ -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}`);
|
||||
}
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
import './eventHandlers';
|
||||
import './publishers';
|
@ -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);
|
@ -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);
|
||||
|
@ -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 });
|
||||
}
|
@ -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}`);
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -117,6 +117,7 @@ export default function addMeeting(meeting) {
|
||||
systemProps: {
|
||||
html5InstanceId: Number,
|
||||
},
|
||||
groups: Array,
|
||||
});
|
||||
|
||||
const {
|
||||
|
@ -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}`);
|
||||
}
|
||||
});
|
||||
}
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -37,6 +37,14 @@ const intlMessages = defineMessages({
|
||||
id: 'app.createBreakoutRoom.durationInMinutes',
|
||||
description: 'duration time label',
|
||||
},
|
||||
resetAssignments: {
|
||||
id: 'app.createBreakoutRoom.resetAssignments',
|
||||
description: 'reset assignments label',
|
||||
},
|
||||
resetAssignmentsDesc: {
|
||||
id: 'app.createBreakoutRoom.resetAssignmentsDesc',
|
||||
description: 'reset assignments label description',
|
||||
},
|
||||
randomlyAssign: {
|
||||
id: 'app.createBreakoutRoom.randomlyAssign',
|
||||
description: 'randomly assign label',
|
||||
@ -165,6 +173,7 @@ class BreakoutRoom extends PureComponent {
|
||||
this.setFreeJoin = this.setFreeJoin.bind(this);
|
||||
this.getUserByRoom = this.getUserByRoom.bind(this);
|
||||
this.onAssignRandomly = this.onAssignRandomly.bind(this);
|
||||
this.onAssignReset = this.onAssignReset.bind(this);
|
||||
this.onInviteBreakout = this.onInviteBreakout.bind(this);
|
||||
this.renderUserItemByRoom = this.renderUserItemByRoom.bind(this);
|
||||
this.renderRoomsGrid = this.renderRoomsGrid.bind(this);
|
||||
@ -210,16 +219,24 @@ class BreakoutRoom extends PureComponent {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { isInvitation, breakoutJoinedUsers } = this.props;
|
||||
const {
|
||||
isInvitation, breakoutJoinedUsers, getLastBreakouts, groups,
|
||||
} = this.props;
|
||||
this.setRoomUsers();
|
||||
if (isInvitation) {
|
||||
this.setInvitationConfig();
|
||||
}
|
||||
if (isInvitation) {
|
||||
|
||||
this.setState({
|
||||
breakoutJoinedUsers,
|
||||
});
|
||||
}
|
||||
|
||||
const lastBreakouts = getLastBreakouts();
|
||||
if (lastBreakouts.length > 0) {
|
||||
this.populateWithLastBreakouts(lastBreakouts);
|
||||
} else if (groups && groups.length > 0) {
|
||||
this.populateWithGroups(groups);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevstate) {
|
||||
@ -427,6 +444,16 @@ class BreakoutRoom extends PureComponent {
|
||||
}
|
||||
}
|
||||
|
||||
onAssignReset() {
|
||||
const { users } = this.state;
|
||||
|
||||
users.forEach((u) => {
|
||||
if (u.room !== null && u.room > 0) {
|
||||
this.changeUserRoom(u.userId, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setInvitationConfig() {
|
||||
const { getBreakouts } = this.props;
|
||||
this.setState({
|
||||
@ -583,6 +610,68 @@ class BreakoutRoom extends PureComponent {
|
||||
return false;
|
||||
}
|
||||
|
||||
populateWithLastBreakouts(lastBreakouts) {
|
||||
const { getBreakoutUserWasIn, users, intl } = this.props;
|
||||
|
||||
const changedNames = [];
|
||||
lastBreakouts.forEach((breakout) => {
|
||||
if (breakout.isDefaultName === false) {
|
||||
changedNames[breakout.sequence] = breakout.shortName;
|
||||
}
|
||||
});
|
||||
|
||||
this.setState({
|
||||
roomNamesChanged: changedNames,
|
||||
numberOfRooms: lastBreakouts.length,
|
||||
roomNameDuplicatedIsValid: true,
|
||||
roomNameEmptyIsValid: true,
|
||||
}, () => {
|
||||
const rooms = _.range(1, lastBreakouts.length + 1).map((seq) => this.getRoomName(seq));
|
||||
|
||||
users.forEach((u) => {
|
||||
const lastUserBreakout = getBreakoutUserWasIn(u.userId, u.extId);
|
||||
if (lastUserBreakout !== null) {
|
||||
const lastUserBreakoutName = lastUserBreakout.isDefaultName === false
|
||||
? lastUserBreakout.shortName
|
||||
: intl.formatMessage(intlMessages.breakoutRoom, { 0: lastUserBreakout.sequence });
|
||||
|
||||
if (rooms.indexOf(lastUserBreakoutName) !== false) {
|
||||
this.changeUserRoom(u.userId, rooms.indexOf(lastUserBreakoutName) + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
populateWithGroups(groups) {
|
||||
const { users } = this.props;
|
||||
|
||||
const changedNames = [];
|
||||
groups.forEach((group, idx) => {
|
||||
if (group.name.length > 0) {
|
||||
changedNames[idx + 1] = group.name;
|
||||
}
|
||||
});
|
||||
|
||||
this.setState({
|
||||
roomNamesChanged: changedNames,
|
||||
numberOfRooms: groups.length > 1 ? groups.length : 2,
|
||||
roomNameDuplicatedIsValid: true,
|
||||
roomNameEmptyIsValid: true,
|
||||
}, () => {
|
||||
groups.forEach((group, groupIdx) => {
|
||||
const usersInGroup = group.usersExtId;
|
||||
if (usersInGroup.length > 0) {
|
||||
usersInGroup.forEach((groupUserExtId) => {
|
||||
users.filter((u) => u.extId === groupUserExtId).forEach((foundUser) => {
|
||||
this.changeUserRoom(foundUser.userId, groupIdx + 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderRoomsGrid() {
|
||||
const { intl, isInvitation } = this.props;
|
||||
const {
|
||||
@ -764,15 +853,26 @@ class BreakoutRoom extends PureComponent {
|
||||
}
|
||||
</Styled.SpanWarn>
|
||||
</Styled.DurationLabel>
|
||||
<Styled.RandomlyAssignBtn
|
||||
data-test="randomlyAssign"
|
||||
label={intl.formatMessage(intlMessages.randomlyAssign)}
|
||||
aria-describedby="randomlyAssignDesc"
|
||||
onClick={this.onAssignRandomly}
|
||||
size="sm"
|
||||
color="default"
|
||||
disabled={!numberOfRoomsIsValid}
|
||||
/>
|
||||
<Styled.AssignBtnsContainer>
|
||||
<Styled.AssignBtns
|
||||
data-test="randomlyAssign"
|
||||
label={intl.formatMessage(intlMessages.randomlyAssign)}
|
||||
aria-describedby="randomlyAssignDesc"
|
||||
onClick={this.onAssignRandomly}
|
||||
size="sm"
|
||||
color="default"
|
||||
disabled={!numberOfRoomsIsValid}
|
||||
/>
|
||||
<Styled.AssignBtns
|
||||
data-test="resetAssignments"
|
||||
label={intl.formatMessage(intlMessages.resetAssignments)}
|
||||
aria-describedby="resetAssignmentsDesc"
|
||||
onClick={this.onAssignReset}
|
||||
size="sm"
|
||||
color="default"
|
||||
disabled={!numberOfRoomsIsValid}
|
||||
/>
|
||||
</Styled.AssignBtnsContainer>
|
||||
</Styled.BreakoutSettings>
|
||||
<Styled.SpanWarn valid={numberOfRoomsIsValid}>
|
||||
{intl.formatMessage(intlMessages.numberOfRoomsIsValid)}
|
||||
|
@ -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(),
|
||||
|
@ -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,
|
||||
|
@ -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(),
|
||||
|
@ -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,
|
||||
|
@ -2,7 +2,6 @@ import React from 'react';
|
||||
import { withTracker } from 'meteor/react-meteor-data';
|
||||
import { withModalMounter } from '/imports/ui/components/modal/service';
|
||||
import AudioManager from '/imports/ui/services/audio-manager';
|
||||
import { makeCall } from '/imports/ui/services/api';
|
||||
import lockContextContainer from '/imports/ui/components/lock-viewers/context/container';
|
||||
import { withUsersConsumer } from '/imports/ui/components/components-data/users-context/context';
|
||||
import logger from '/imports/startup/client/logger';
|
||||
@ -29,28 +28,6 @@ const AudioControlsContainer = (props) => {
|
||||
return <AudioControls {...newProps} />;
|
||||
};
|
||||
|
||||
const processToggleMuteFromOutside = (e) => {
|
||||
switch (e.data) {
|
||||
case 'c_mute': {
|
||||
makeCall('toggleVoice');
|
||||
break;
|
||||
}
|
||||
case 'get_audio_joined_status': {
|
||||
const audioJoinedState = AudioManager.isConnected ? 'joinedAudio' : 'notInAudio';
|
||||
this.window.parent.postMessage({ response: audioJoinedState }, '*');
|
||||
break;
|
||||
}
|
||||
case 'c_mute_status': {
|
||||
const muteState = AudioManager.isMuted ? 'selfMuted' : 'selfUnmuted';
|
||||
this.window.parent.postMessage({ response: muteState }, '*');
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// console.log(e.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeaveAudio = () => {
|
||||
const meetingIsBreakout = AppService.meetingIsBreakout();
|
||||
|
||||
@ -100,7 +77,6 @@ export default withUsersConsumer(
|
||||
}
|
||||
|
||||
return ({
|
||||
processToggleMuteFromOutside: (arg) => processToggleMuteFromOutside(arg),
|
||||
showMute: isConnected() && !isListenOnly() && !isEchoTest() && !userLocks.userMic,
|
||||
muted: isConnected() && !isListenOnly() && isMuted(),
|
||||
inAudio: isConnected() && !isEchoTest(),
|
||||
|
@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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) => {
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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),
|
||||
};
|
@ -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();
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
|
@ -42,6 +42,10 @@ const messages = defineMessages({
|
||||
id: 'app.userList.userAriaLabel',
|
||||
description: 'aria label for each user in the userlist',
|
||||
},
|
||||
breakoutRoom: {
|
||||
id: 'app.createBreakoutRoom.room',
|
||||
description: 'breakout room',
|
||||
},
|
||||
});
|
||||
|
||||
const propTypes = {
|
||||
@ -68,6 +72,7 @@ const UserName = (props) => {
|
||||
isThisMeetingLocked,
|
||||
userAriaLabel,
|
||||
isActionsOpen,
|
||||
userLastBreakout,
|
||||
isMe,
|
||||
user,
|
||||
} = props;
|
||||
@ -110,6 +115,18 @@ const UserName = (props) => {
|
||||
if (LABEL.guest) userNameSub.push(intl.formatMessage(messages.guest));
|
||||
}
|
||||
|
||||
if (userLastBreakout) {
|
||||
userNameSub.push(
|
||||
<span key={_.uniqueId('breakout-')}>
|
||||
<Icon iconName="rooms" />
|
||||
|
||||
{userLastBreakout.isDefaultName
|
||||
? intl.formatMessage(messages.breakoutRoom, { 0: userLastBreakout.sequence })
|
||||
: userLastBreakout.shortName}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Styled.UserName
|
||||
role="button"
|
||||
|
@ -22,7 +22,7 @@ export default withTracker((params) => {
|
||||
} = params;
|
||||
|
||||
const fetchFunc = published
|
||||
? AnnotationGroupService.getCurrentAnnotationsInfo : AnnotationGroupService.getUnsetAnnotations;
|
||||
? AnnotationGroupService.getCurrentAnnotationsInfo : AnnotationGroupService.getUnsentAnnotations;
|
||||
|
||||
const annotationsInfo = fetchFunc(whiteboardId);
|
||||
return {
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
58
bigbluebutton-html5/package-lock.json
generated
58
bigbluebutton-html5/package-lock.json
generated
@ -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",
|
||||
|
@ -411,9 +411,6 @@ public:
|
||||
syncUsersWithConnectionManager:
|
||||
enabled: false
|
||||
syncInterval: 60000
|
||||
allowOutsideCommands:
|
||||
toggleRecording: false
|
||||
toggleSelfVoice: false
|
||||
poll:
|
||||
enabled: true
|
||||
maxCustom: 5
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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
|
||||
|
@ -28,16 +28,17 @@ require File.expand_path('../../edl', __FILE__)
|
||||
module BigBlueButton
|
||||
|
||||
|
||||
def BigBlueButton.process_webcam_videos(target_dir, temp_dir, meeting_id, output_width, output_height, output_framerate, audio_offset, processed_audio_file, video_formats=['webm'])
|
||||
def BigBlueButton.process_webcam_videos(target_dir, raw_archive_dir, output_width, output_height, output_framerate, audio_offset, processed_audio_file, video_formats=['webm'])
|
||||
BigBlueButton.logger.info("Processing webcam videos")
|
||||
|
||||
events = Nokogiri::XML(File.open("#{temp_dir}/#{meeting_id}/events.xml"))
|
||||
# raw_archive_dir already contains meeting_id
|
||||
events = Nokogiri::XML(File.open("#{raw_archive_dir}/events.xml"))
|
||||
|
||||
# Process user video (camera)
|
||||
start_time = BigBlueButton::Events.first_event_timestamp(events)
|
||||
end_time = BigBlueButton::Events.last_event_timestamp(events)
|
||||
webcam_edl = BigBlueButton::Events.create_webcam_edl(
|
||||
events, "#{temp_dir}/#{meeting_id}")
|
||||
events, raw_archive_dir)
|
||||
user_video_edl = BigBlueButton::Events.edl_match_recording_marks_video(
|
||||
webcam_edl, events, start_time, end_time)
|
||||
BigBlueButton::EDL::Video.dump(user_video_edl)
|
||||
@ -91,15 +92,16 @@ module BigBlueButton
|
||||
end
|
||||
end
|
||||
|
||||
def BigBlueButton.process_deskshare_videos(target_dir, temp_dir, meeting_id, output_width, output_height, output_framerate, video_formats=['webm'])
|
||||
def BigBlueButton.process_deskshare_videos(target_dir, raw_archive_dir, output_width, output_height, output_framerate, video_formats=['webm'])
|
||||
BigBlueButton.logger.info("Processing deskshare videos")
|
||||
|
||||
events = Nokogiri::XML(File.open("#{temp_dir}/#{meeting_id}/events.xml"))
|
||||
# raw_archive_dir already contains meeting_id
|
||||
events = Nokogiri::XML(File.open("#{raw_archive_dir}/events.xml"))
|
||||
|
||||
start_time = BigBlueButton::Events.first_event_timestamp(events)
|
||||
end_time = BigBlueButton::Events.last_event_timestamp(events)
|
||||
deskshare_edl = BigBlueButton::Events.create_deskshare_edl(
|
||||
events, "#{temp_dir}/#{meeting_id}")
|
||||
events, raw_archive_dir)
|
||||
deskshare_video_edl = BigBlueButton::Events.edl_match_recording_marks_video(
|
||||
deskshare_edl, events, start_time, end_time)
|
||||
|
||||
@ -168,5 +170,3 @@ module BigBlueButton
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user