Merge branch 'v2.6.x-release' into issue-15891

This commit is contained in:
Manuel Schwarz 2022-11-10 15:00:28 +01:00 committed by GitHub
commit 26c8d31197
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
196 changed files with 4361 additions and 2268 deletions

View File

@ -74,7 +74,6 @@ class BigBlueButtonActor(
case m: CreateMeetingReqMsg => handleCreateMeetingReqMsg(m) case m: CreateMeetingReqMsg => handleCreateMeetingReqMsg(m)
case m: RegisterUserReqMsg => handleRegisterUserReqMsg(m) case m: RegisterUserReqMsg => handleRegisterUserReqMsg(m)
case m: EjectDuplicateUserReqMsg => handleEjectDuplicateUserReqMsg(m)
case m: GetAllMeetingsReqMsg => handleGetAllMeetingsReqMsg(m) case m: GetAllMeetingsReqMsg => handleGetAllMeetingsReqMsg(m)
case m: GetRunningMeetingsReqMsg => handleGetRunningMeetingsReqMsg(m) case m: GetRunningMeetingsReqMsg => handleGetRunningMeetingsReqMsg(m)
case m: CheckAlivePingSysMsg => handleCheckAlivePingSysMsg(m) case m: CheckAlivePingSysMsg => handleCheckAlivePingSysMsg(m)
@ -105,16 +104,6 @@ class BigBlueButtonActor(
} }
} }
def handleEjectDuplicateUserReqMsg(msg: EjectDuplicateUserReqMsg): Unit = {
log.debug("RECEIVED EjectDuplicateUserReqMsg msg {}", msg)
for {
m <- RunningMeetings.findWithId(meetings, msg.header.meetingId)
} yield {
log.debug("FORWARDING EjectDuplicateUserReqMsg")
m.actorRef forward (msg)
}
}
def handleCreateMeetingReqMsg(msg: CreateMeetingReqMsg): Unit = { def handleCreateMeetingReqMsg(msg: CreateMeetingReqMsg): Unit = {
log.debug("RECEIVED CreateMeetingReqMsg msg {}", msg) log.debug("RECEIVED CreateMeetingReqMsg msg {}", msg)

View File

@ -104,6 +104,22 @@ case class SendMessageToBreakoutRoomInternalMsg(parentId: String, breakoutId: St
*/ */
case class EjectUserFromBreakoutInternalMsg(parentId: String, breakoutId: String, extUserId: String, ejectedBy: String, reason: String, reasonCode: String, ban: Boolean) extends InMessage case class EjectUserFromBreakoutInternalMsg(parentId: String, breakoutId: String, extUserId: String, ejectedBy: String, reason: String, reasonCode: String, ban: Boolean) extends InMessage
/**
* Sent by parent meeting to breakout room to import annotated slides.
* @param userId
* @param parentMeetingId
* @param allPages
*/
case class CapturePresentationReqInternalMsg(userId: String, parentMeetingId: String, allPages: Boolean = true) extends InMessage
/**
* Sent by parent meeting to breakout room to import shared notes.
* @param parentMeetingId
* @param meetingName
* @param sequence
*/
case class CaptureSharedNotesReqInternalMsg(parentMeetingId: String, meetingName: String, sequence: Int) extends InMessage
// DeskShare // DeskShare
case class DeskShareStartedRequest(conferenceName: String, callerId: String, callerIdName: String) extends InMessage case class DeskShareStartedRequest(conferenceName: String, callerId: String, callerIdName: String) extends InMessage
case class DeskShareStoppedRequest(conferenceName: String, callerId: String, callerIdName: String) extends InMessage case class DeskShareStoppedRequest(conferenceName: String, callerId: String, callerIdName: String) extends InMessage

View File

@ -13,9 +13,11 @@ object BreakoutModel {
isDefaultName: Boolean, isDefaultName: Boolean,
freeJoin: Boolean, freeJoin: Boolean,
voiceConf: String, voiceConf: String,
assignedUsers: Vector[String] assignedUsers: Vector[String],
captureNotes: Boolean,
captureSlides: Boolean,
): BreakoutRoom2x = { ): BreakoutRoom2x = {
new BreakoutRoom2x(id, externalId, name, parentId, sequence, shortName, isDefaultName, freeJoin, voiceConf, assignedUsers, Vector(), Vector(), None, false) new BreakoutRoom2x(id, externalId, name, parentId, sequence, shortName, isDefaultName, freeJoin, voiceConf, assignedUsers, Vector(), Vector(), None, false, captureNotes, captureSlides)
} }
} }

View File

@ -1,9 +1,6 @@
package org.bigbluebutton.core.apps package org.bigbluebutton.core.apps
import org.bigbluebutton.core.util.jhotdraw.BezierWrapper
import scala.collection.immutable.List
import scala.collection.immutable.HashMap import scala.collection.immutable.HashMap
import scala.collection.JavaConverters._
import org.bigbluebutton.common2.msgs.AnnotationVO import org.bigbluebutton.common2.msgs.AnnotationVO
import org.bigbluebutton.core.apps.whiteboard.Whiteboard import org.bigbluebutton.core.apps.whiteboard.Whiteboard
import org.bigbluebutton.SystemConfiguration import org.bigbluebutton.SystemConfiguration
@ -24,86 +21,83 @@ class WhiteboardModel extends SystemConfiguration {
} }
private def createWhiteboard(wbId: String): Whiteboard = { private def createWhiteboard(wbId: String): Whiteboard = {
new Whiteboard( Whiteboard(
wbId, wbId,
Array.empty[String], Array.empty[String],
Array.empty[String], Array.empty[String],
System.currentTimeMillis(), System.currentTimeMillis(),
new HashMap[String, Map[String, AnnotationVO]]() new HashMap[String, AnnotationVO]
) )
} }
private def getAnnotationsByUserId(wb: Whiteboard, id: String): Map[String, AnnotationVO] = { private def deepMerge(test: Map[String, _], that: Map[String, _]): Map[String, _] =
wb.annotationsMap.get(id).getOrElse(Map[String, AnnotationVO]()) (for (k <- test.keys ++ that.keys) yield {
} val newValue =
(test.get(k), that.get(k)) match {
case (Some(v), None) => v
case (None, Some(v)) => v
case (Some(v1), Some(v2)) =>
if (v1.isInstanceOf[Map[String, _]] && v2.isInstanceOf[Map[String, _]])
deepMerge(v1.asInstanceOf[Map[String, _]], v2.asInstanceOf[Map[String, _]])
else v2
case (_, _) => ???
}
k -> newValue
}).toMap
def addAnnotations(wbId: String, userId: String, annotations: Array[AnnotationVO]): Array[AnnotationVO] = { def addAnnotations(wbId: String, userId: String, annotations: Array[AnnotationVO], isPresenter: Boolean, isModerator: Boolean): Array[AnnotationVO] = {
var annotationsAdded = Array[AnnotationVO]()
val wb = getWhiteboard(wbId) val wb = getWhiteboard(wbId)
val usersAnnotations = getAnnotationsByUserId(wb, userId) var newAnnotationsMap = wb.annotationsMap
var newUserAnnotations = usersAnnotations
for (annotation <- annotations) { for (annotation <- annotations) {
newUserAnnotations = newUserAnnotations + (annotation.id -> annotation) val oldAnnotation = wb.annotationsMap.get(annotation.id)
println("Adding annotation to page [" + wb.id + "]. After numAnnotations=[" + newUserAnnotations.size + "].") if (!oldAnnotation.isEmpty) {
val hasPermission = isPresenter || isModerator || oldAnnotation.get.userId == userId
if (hasPermission) {
val newAnnotation = oldAnnotation.get.copy(annotationInfo = deepMerge(oldAnnotation.get.annotationInfo, annotation.annotationInfo))
newAnnotationsMap += (annotation.id -> newAnnotation)
annotationsAdded :+= annotation
println(s"Updated annotation onpage [${wb.id}]. After numAnnotations=[${newAnnotationsMap.size}].")
} else {
println(s"User $userId doesn't have permission to edit annotation ${annotation.id}, ignoring...")
}
} else if (annotation.annotationInfo.contains("type")) {
newAnnotationsMap += (annotation.id -> annotation)
annotationsAdded :+= annotation
println(s"Adding annotation to page [${wb.id}]. After numAnnotations=[${newAnnotationsMap.size}].")
} else {
println(s"New annotation [${annotation.id}] with no type, ignoring (probably received a remove message before and now the shape is incomplete, ignoring...")
}
} }
val newAnnotationsMap = wb.annotationsMap + (userId -> newUserAnnotations)
val newWb = wb.copy(annotationsMap = newAnnotationsMap) val newWb = wb.copy(annotationsMap = newAnnotationsMap)
saveWhiteboard(newWb) saveWhiteboard(newWb)
annotations annotationsAdded
} }
def getHistory(wbId: String): Array[AnnotationVO] = { def getHistory(wbId: String): Array[AnnotationVO] = {
//wb.annotationsMap.values.flatten.toArray.sortBy(_.position);
val wb = getWhiteboard(wbId) val wb = getWhiteboard(wbId)
var annotations = Array[AnnotationVO]() wb.annotationsMap.values.toArray
// TODO: revisit this, probably there is a one-liner simple solution
wb.annotationsMap.values.foreach(
user => user.values.foreach(
annotation => annotations = annotations :+ annotation
)
)
annotations
} }
def clearWhiteboard(wbId: String, userId: String): Option[Boolean] = { def deleteAnnotations(wbId: String, userId: String, annotationsIds: Array[String], isPresenter: Boolean, isModerator: Boolean): Array[String] = {
var cleared: Option[Boolean] = None
if (hasWhiteboard(wbId)) {
val wb = getWhiteboard(wbId)
if (wb.multiUser.contains(userId)) {
if (wb.annotationsMap.contains(userId)) {
val newWb = wb.copy(annotationsMap = wb.annotationsMap - userId)
saveWhiteboard(newWb)
cleared = Some(false)
}
} else {
if (wb.annotationsMap.nonEmpty) {
val newWb = wb.copy(annotationsMap = new HashMap[String, Map[String, AnnotationVO]]())
saveWhiteboard(newWb)
cleared = Some(true)
}
}
}
cleared
}
def deleteAnnotations(wbId: String, userId: String, annotationsIds: Array[String]): Array[String] = {
var annotationsIdsRemoved = Array[String]() var annotationsIdsRemoved = Array[String]()
val wb = getWhiteboard(wbId) val wb = getWhiteboard(wbId)
var newAnnotationsMap = wb.annotationsMap
val usersAnnotations = getAnnotationsByUserId(wb, userId)
var newUserAnnotations = usersAnnotations
for (annotationId <- annotationsIds) { for (annotationId <- annotationsIds) {
val annotation = usersAnnotations.get(annotationId) val annotation = wb.annotationsMap.get(annotationId)
//not empty and annotation exists if (!annotation.isEmpty) {
if (!usersAnnotations.isEmpty && !annotation.isEmpty) { val hasPermission = isPresenter || isModerator || annotation.get.userId == userId
newUserAnnotations = newUserAnnotations - annotationId if (hasPermission) {
println("Removing annotation on page [" + wb.id + "]. After numAnnotations=[" + newUserAnnotations.size + "].") newAnnotationsMap -= annotationId
annotationsIdsRemoved = annotationsIdsRemoved :+ annotationId println("Removing annotation on page [" + wb.id + "]. After numAnnotations=[" + newAnnotationsMap.size + "].")
annotationsIdsRemoved :+= annotationId
} else {
println("User doesn't have permission to remove this annotation, ignoring...")
}
} }
} }
val newAnnotationsMap = wb.annotationsMap + (userId -> newUserAnnotations)
val newWb = wb.copy(annotationsMap = newAnnotationsMap) val newWb = wb.copy(annotationsMap = newAnnotationsMap)
saveWhiteboard(newWb) saveWhiteboard(newWb)
annotationsIdsRemoved annotationsIdsRemoved

View File

@ -52,7 +52,7 @@ trait BreakoutRoomCreatedMsgHdlr {
(redirectToHtml5JoinURL, redirectJoinURL) <- BreakoutHdlrHelpers.getRedirectUrls(liveMeeting, user, r.externalId, r.sequence.toString()) (redirectToHtml5JoinURL, redirectJoinURL) <- BreakoutHdlrHelpers.getRedirectUrls(liveMeeting, user, r.externalId, r.sequence.toString())
} yield (user -> redirectToHtml5JoinURL) } yield (user -> redirectToHtml5JoinURL)
new BreakoutRoomInfo(r.name, r.externalId, r.id, r.sequence, r.shortName, r.isDefaultName, r.freeJoin, html5JoinUrls.toMap) new BreakoutRoomInfo(r.name, r.externalId, r.id, r.sequence, r.shortName, r.isDefaultName, r.freeJoin, html5JoinUrls.toMap, r.captureNotes, r.captureSlides)
} }
log.info("Sending breakout rooms list to {} with containing {} room(s)", liveMeeting.props.meetingProp.intId, breakoutRooms.length) log.info("Sending breakout rooms list to {} with containing {} room(s)", liveMeeting.props.meetingProp.intId, breakoutRooms.length)
@ -79,7 +79,7 @@ trait BreakoutRoomCreatedMsgHdlr {
BbbCommonEnvCoreMsg(envelope, event) BbbCommonEnvCoreMsg(envelope, event)
} }
val breakoutInfo = BreakoutRoomInfo(room.name, room.externalId, room.id, room.sequence, room.shortName, room.isDefaultName, room.freeJoin, Map()) val breakoutInfo = BreakoutRoomInfo(room.name, room.externalId, room.id, room.sequence, room.shortName, room.isDefaultName, room.freeJoin, Map(), room.captureNotes, room.captureSlides)
val event = build(liveMeeting.props.meetingProp.intId, breakoutInfo) val event = build(liveMeeting.props.meetingProp.intId, breakoutInfo)
outGW.send(event) outGW.send(event)

View File

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

View File

@ -52,7 +52,7 @@ trait CreateBreakoutRoomsCmdMsgHdlr extends RightsManagementTrait {
val (internalId, externalId) = BreakoutRoomsUtil.createMeetingIds(liveMeeting.props.meetingProp.intId, i) val (internalId, externalId) = BreakoutRoomsUtil.createMeetingIds(liveMeeting.props.meetingProp.intId, i)
val voiceConf = BreakoutRoomsUtil.createVoiceConfId(liveMeeting.props.voiceProp.voiceConf, i) val voiceConf = BreakoutRoomsUtil.createVoiceConfId(liveMeeting.props.voiceProp.voiceConf, i)
val breakout = BreakoutModel.create(parentId, internalId, externalId, room.name, room.sequence, room.shortName, room.isDefaultName, room.freeJoin, voiceConf, room.users) val breakout = BreakoutModel.create(parentId, internalId, externalId, room.name, room.sequence, room.shortName, room.isDefaultName, room.freeJoin, voiceConf, room.users, msg.body.captureNotes, msg.body.captureSlides)
rooms = rooms + (breakout.id -> breakout) rooms = rooms + (breakout.id -> breakout)
} }
@ -70,7 +70,9 @@ trait CreateBreakoutRoomsCmdMsgHdlr extends RightsManagementTrait {
liveMeeting.props.password.moderatorPass, liveMeeting.props.password.moderatorPass,
liveMeeting.props.password.viewerPass, liveMeeting.props.password.viewerPass,
presId, presSlide, msg.body.record, presId, presSlide, msg.body.record,
liveMeeting.props.breakoutProps.privateChatEnabled liveMeeting.props.breakoutProps.privateChatEnabled,
breakout.captureNotes,
breakout.captureSlides,
) )
val event = buildCreateBreakoutRoomSysCmdMsg(liveMeeting.props.meetingProp.intId, roomDetail) val event = buildCreateBreakoutRoomSysCmdMsg(liveMeeting.props.meetingProp.intId, roomDetail)

View File

@ -14,8 +14,8 @@ trait EndAllBreakoutRoomsMsgHdlr extends RightsManagementTrait {
val outGW: OutMsgRouter val outGW: OutMsgRouter
def handleEndAllBreakoutRoomsMsg(msg: EndAllBreakoutRoomsMsg, state: MeetingState2x): MeetingState2x = { def handleEndAllBreakoutRoomsMsg(msg: EndAllBreakoutRoomsMsg, state: MeetingState2x): MeetingState2x = {
val meetingId = liveMeeting.props.meetingProp.intId
if (permissionFailed(PermissionCheck.MOD_LEVEL, PermissionCheck.VIEWER_LEVEL, liveMeeting.users2x, msg.header.userId)) { if (permissionFailed(PermissionCheck.MOD_LEVEL, PermissionCheck.VIEWER_LEVEL, liveMeeting.users2x, msg.header.userId)) {
val meetingId = liveMeeting.props.meetingProp.intId
val reason = "No permission to end breakout rooms for meeting." val reason = "No permission to end breakout rooms for meeting."
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, outGW, liveMeeting) PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, outGW, liveMeeting)
state state
@ -24,11 +24,11 @@ trait EndAllBreakoutRoomsMsgHdlr extends RightsManagementTrait {
model <- state.breakout model <- state.breakout
} yield { } yield {
model.rooms.values.foreach { room => model.rooms.values.foreach { room =>
eventBus.publish(BigBlueButtonEvent(room.id, EndBreakoutRoomInternalMsg(props.breakoutProps.parentId, room.id, MeetingEndReason.BREAKOUT_ENDED_BY_MOD))) eventBus.publish(BigBlueButtonEvent(room.id, EndBreakoutRoomInternalMsg(meetingId, room.id, MeetingEndReason.BREAKOUT_ENDED_BY_MOD)))
} }
val notifyEvent = MsgBuilder.buildNotifyAllInMeetingEvtMsg( val notifyEvent = MsgBuilder.buildNotifyAllInMeetingEvtMsg(
liveMeeting.props.meetingProp.intId, meetingId,
"info", "info",
"rooms", "rooms",
"app.toast.breakoutRoomEnded", "app.toast.breakoutRoomEnded",

View File

@ -1,7 +1,7 @@
package org.bigbluebutton.core.apps.breakout package org.bigbluebutton.core.apps.breakout
import org.bigbluebutton.core.api.EndBreakoutRoomInternalMsg import org.bigbluebutton.core.api.{ CaptureSharedNotesReqInternalMsg, CapturePresentationReqInternalMsg, EndBreakoutRoomInternalMsg }
import org.bigbluebutton.core.bus.{ InternalEventBus } import org.bigbluebutton.core.bus.{ BigBlueButtonEvent, InternalEventBus }
import org.bigbluebutton.core.running.{ BaseMeetingActor, HandlerHelpers, LiveMeeting, OutMsgRouter } import org.bigbluebutton.core.running.{ BaseMeetingActor, HandlerHelpers, LiveMeeting, OutMsgRouter }
trait EndBreakoutRoomInternalMsgHdlr extends HandlerHelpers { trait EndBreakoutRoomInternalMsgHdlr extends HandlerHelpers {
@ -12,6 +12,18 @@ trait EndBreakoutRoomInternalMsgHdlr extends HandlerHelpers {
val eventBus: InternalEventBus val eventBus: InternalEventBus
def handleEndBreakoutRoomInternalMsg(msg: EndBreakoutRoomInternalMsg): Unit = { def handleEndBreakoutRoomInternalMsg(msg: EndBreakoutRoomInternalMsg): Unit = {
if (liveMeeting.props.breakoutProps.captureSlides) {
val captureSlidesEvent = BigBlueButtonEvent(msg.breakoutId, CapturePresentationReqInternalMsg("system", msg.parentId))
eventBus.publish(captureSlidesEvent)
}
if (liveMeeting.props.breakoutProps.captureNotes) {
val meetingName: String = liveMeeting.props.meetingProp.name
val captureNotesEvent = BigBlueButtonEvent(msg.breakoutId, CaptureSharedNotesReqInternalMsg(msg.parentId, meetingName, liveMeeting.props.breakoutProps.sequence))
eventBus.publish(captureNotesEvent)
}
log.info("Breakout room {} ended by parent meeting {}.", msg.breakoutId, msg.parentId) log.info("Breakout room {} ended by parent meeting {}.", msg.breakoutId, msg.parentId)
sendEndMeetingDueToExpiry(msg.reason, eventBus, outGW, liveMeeting, "system") sendEndMeetingDueToExpiry(msg.reason, eventBus, outGW, liveMeeting, "system")
} }

View File

@ -84,10 +84,12 @@ trait CreateGroupChatReqMsgHdlr extends SystemConfiguration {
BbbCoreEnvelope(name, routing) BbbCoreEnvelope(name, routing)
} }
def makeBody(chatId: String, def makeBody(
access: String, correlationId: String, chatId: String,
createdBy: GroupChatUser, users: Vector[GroupChatUser], access: String, correlationId: String,
msgs: Vector[GroupChatMsgToUser]): GroupChatCreatedEvtMsgBody = { createdBy: GroupChatUser, users: Vector[GroupChatUser],
msgs: Vector[GroupChatMsgToUser]
): GroupChatCreatedEvtMsgBody = {
GroupChatCreatedEvtMsgBody(correlationId, chatId, createdBy, GroupChatCreatedEvtMsgBody(correlationId, chatId, createdBy,
access, users, msgs) access, users, msgs)
} }

View File

@ -45,27 +45,31 @@ trait RespondToPollReqMsgHdlr {
bus.outGW.send(msgEvent) bus.outGW.send(msgEvent)
} }
for { if (Polls.checkUserResponded(msg.body.pollId, msg.header.userId, liveMeeting.polls) == false) {
(pollId: String, updatedPoll: SimplePollResultOutVO) <- Polls.handleRespondToPollReqMsg(msg.header.userId, msg.body.pollId,
msg.body.questionId, msg.body.answerIds, liveMeeting)
} yield {
broadcastPollUpdatedEvent(msg, pollId, updatedPoll)
for { for {
poll <- Polls.getPoll(pollId, liveMeeting.polls) (pollId: String, updatedPoll: SimplePollResultOutVO) <- Polls.handleRespondToPollReqMsg(msg.header.userId, msg.body.pollId,
msg.body.questionId, msg.body.answerIds, liveMeeting)
} yield { } yield {
broadcastPollUpdatedEvent(msg, pollId, updatedPoll)
for { for {
answerId <- msg.body.answerIds poll <- Polls.getPoll(pollId, liveMeeting.polls)
} yield { } yield {
val answerText = poll.questions(0).answers.get(answerId).key for {
broadcastUserRespondedToPollRecordMsg(msg, pollId, answerId, answerText, poll.isSecret) answerId <- msg.body.answerIds
} yield {
val answerText = poll.questions(0).answers.get(answerId).key
broadcastUserRespondedToPollRecordMsg(msg, pollId, answerId, answerText, poll.isSecret)
}
}
for {
presenter <- Users2x.findPresenter(liveMeeting.users2x)
} yield {
broadcastUserRespondedToPollRespMsg(msg, pollId, msg.body.answerIds, presenter.intId)
} }
} }
} else {
for { log.info("Ignoring typed answer from user {} once user already added an answer to this poll {} in meeting {}", msg.header.userId, msg.body.pollId, msg.header.meetingId)
presenter <- Users2x.findPresenter(liveMeeting.users2x)
} yield {
broadcastUserRespondedToPollRespMsg(msg, pollId, msg.body.answerIds, presenter.intId)
}
} }
} }
} }

View File

@ -34,17 +34,23 @@ trait RespondToTypedPollReqMsgHdlr {
bus.outGW.send(msgEvent) bus.outGW.send(msgEvent)
} }
for { if (Polls.isResponsePollType(msg.body.pollId, liveMeeting.polls) &&
(pollId: String, updatedPoll: SimplePollResultOutVO) <- Polls.handleRespondToTypedPollReqMsg(msg.header.userId, msg.body.pollId, Polls.checkUserResponded(msg.body.pollId, msg.header.userId, liveMeeting.polls) == false &&
msg.body.questionId, msg.body.answer, liveMeeting) Polls.checkUserAddedQuestion(msg.body.pollId, msg.header.userId, liveMeeting.polls) == false) {
} yield {
broadcastPollUpdatedEvent(msg, pollId, updatedPoll)
for { for {
presenter <- Users2x.findPresenter(liveMeeting.users2x) (pollId: String, updatedPoll: SimplePollResultOutVO) <- Polls.handleRespondToTypedPollReqMsg(msg.header.userId, msg.body.pollId,
msg.body.questionId, msg.body.answer, liveMeeting)
} yield { } yield {
broadcastUserRespondedToTypedPollRespMsg(msg, pollId, msg.body.answer, presenter.intId) broadcastPollUpdatedEvent(msg, pollId, updatedPoll)
for {
presenter <- Users2x.findPresenter(liveMeeting.users2x)
} yield {
broadcastUserRespondedToTypedPollRespMsg(msg, pollId, msg.body.answer, presenter.intId)
}
} }
} else {
log.info("Ignoring typed answer from user {} once user already added an answer to this poll {} in meeting {}", msg.header.userId, msg.body.pollId, msg.header.meetingId)
} }
} }
} }

View File

@ -1,6 +1,7 @@
package org.bigbluebutton.core.apps.presentationpod package org.bigbluebutton.core.apps.presentationpod
import org.bigbluebutton.common2.msgs._ import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.api.{ CapturePresentationReqInternalMsg, CaptureSharedNotesReqInternalMsg }
import org.bigbluebutton.core.apps.{ PermissionCheck, RightsManagementTrait } import org.bigbluebutton.core.apps.{ PermissionCheck, RightsManagementTrait }
import org.bigbluebutton.core.bus.MessageBus import org.bigbluebutton.core.bus.MessageBus
import org.bigbluebutton.core.domain.MeetingState2x import org.bigbluebutton.core.domain.MeetingState2x
@ -122,32 +123,27 @@ trait PresentationWithAnnotationsMsgHdlr extends RightsManagementTrait {
} }
} }
def handle(m: ExportPresentationWithAnnotationReqMsg, state: MeetingState2x, liveMeeting: LiveMeeting, bus: MessageBus): Unit = { def handle(m: CapturePresentationReqInternalMsg, state: MeetingState2x, liveMeeting: LiveMeeting, bus: MessageBus): Unit = {
val meetingId = liveMeeting.props.meetingProp.intId val meetingId = liveMeeting.props.meetingProp.intId
val userId = m.header.userId val userId = m.userId
val presentationPods: Vector[PresentationPod] = state.presentationPodManager.getAllPresentationPodsInMeeting() val presentationPods: Vector[PresentationPod] = state.presentationPodManager.getAllPresentationPodsInMeeting()
val currentPres: Option[PresentationInPod] = presentationPods.flatMap(_.getCurrentPresentation()).headOption val currentPres: Option[PresentationInPod] = presentationPods.flatMap(_.getCurrentPresentation()).headOption
if (liveMeeting.props.meetingProp.disabledFeatures.contains("importPresentationWithAnnotationsFromBreakoutRooms")) { if (liveMeeting.props.meetingProp.disabledFeatures.contains("importPresentationWithAnnotationsFromBreakoutRooms")) {
val reason = "Importing slides from breakout rooms disabled for this meeting." log.error(s"Capturing breakout rooms slides disabled in meeting ${meetingId}.")
PermissionCheck.ejectUserForFailedPermission(meetingId, userId, reason, bus.outGW, liveMeeting)
} else if (permissionFailed(PermissionCheck.MOD_LEVEL, PermissionCheck.VIEWER_LEVEL, liveMeeting.users2x, userId)) {
val reason = "No permission to export presentation."
PermissionCheck.ejectUserForFailedPermission(meetingId, userId, reason, bus.outGW, liveMeeting)
} else if (currentPres.isEmpty) { } else if (currentPres.isEmpty) {
log.error(s"No presentation set in meeting ${meetingId}") log.error(s"No presentation set in meeting ${meetingId}")
} else { } else {
val jobId: String = RandomStringGenerator.randomAlphanumericString(16); val jobId: String = RandomStringGenerator.randomAlphanumericString(16);
val jobType = "PresentationWithAnnotationExportJob" val jobType = "PresentationWithAnnotationExportJob"
val allPages: Boolean = m.body.allPages val allPages: Boolean = m.allPages
val pageCount = currentPres.get.pages.size val pageCount = currentPres.get.pages.size
val presId: String = PresentationPodsApp.getAllPresentationPodsInMeeting(state).flatMap(_.getCurrentPresentation.map(_.id)).mkString val presId: String = PresentationPodsApp.getAllPresentationPodsInMeeting(state).flatMap(_.getCurrentPresentation.map(_.id)).mkString
val presLocation = List("var", "bigbluebutton", meetingId, meetingId, presId).mkString(File.separator, File.separator, ""); val presLocation = List("var", "bigbluebutton", meetingId, meetingId, presId).mkString(File.separator, File.separator, "");
val parentMeetingId: String = m.body.parentMeetingId val parentMeetingId: String = m.parentMeetingId
val currentPage: PresentationPage = PresentationInPod.getCurrentPage(currentPres.get).get val currentPage: PresentationPage = PresentationInPod.getCurrentPage(currentPres.get).get
val pagesRange: List[Int] = if (allPages) (1 to pageCount).toList else List(currentPage.num) val pagesRange: List[Int] = if (allPages) (1 to pageCount).toList else List(currentPage.num)
@ -183,7 +179,32 @@ trait PresentationWithAnnotationsMsgHdlr extends RightsManagementTrait {
log.info("Received NewPresAnnFileAvailableMsg meetingId={} presId={} fileUrl={}", liveMeeting.props.meetingProp.intId, m.body.presId, m.body.fileURI) log.info("Received NewPresAnnFileAvailableMsg meetingId={} presId={} fileUrl={}", liveMeeting.props.meetingProp.intId, m.body.presId, m.body.fileURI)
bus.outGW.send(buildBroadcastNewPresAnnFileAvailable(m, liveMeeting)) bus.outGW.send(buildBroadcastNewPresAnnFileAvailable(m, liveMeeting))
} }
def handle(m: CaptureSharedNotesReqInternalMsg, liveMeeting: LiveMeeting, bus: MessageBus): Unit = {
val meetingId = liveMeeting.props.meetingProp.intId
val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, meetingId, "not-used")
val envelope = BbbCoreEnvelope(PresentationPageConversionStartedEventMsg.NAME, routing)
val header = BbbClientMsgHeader(CaptureSharedNotesReqEvtMsg.NAME, meetingId, "not-used")
val body = CaptureSharedNotesReqEvtMsgBody(m.parentMeetingId, m.meetingName, m.sequence)
val event = CaptureSharedNotesReqEvtMsg(header, body)
bus.outGW.send(BbbCommonEnvCoreMsg(envelope, event))
}
def handle(m: PadCapturePubMsg, liveMeeting: LiveMeeting, bus: MessageBus): Unit = {
val userId: String = "system"
val jobId: String = RandomStringGenerator.randomAlphanumericString(16);
val jobType = "PadCaptureJob"
val filename = s"${m.body.meetingName}-notes"
val presentationUploadToken: String = PresentationPodsApp.generateToken("DEFAULT_PRESENTATION_POD", userId)
bus.outGW.send(buildPresentationUploadTokenSysPubMsg(m.body.parentMeetingId, userId, presentationUploadToken, filename))
val exportJob = new ExportJob(jobId, jobType, filename, m.body.padId, "", true, List(m.body.sequence), m.body.parentMeetingId, presentationUploadToken)
val job = buildStoreExportJobInRedisSysMsg(exportJob, liveMeeting)
bus.outGW.send(job)
}
} }

View File

@ -55,7 +55,7 @@ trait ChangeLockSettingsInMeetingCmdMsgHdlr extends RightsManagementTrait {
outGW.send(notifyEvent) outGW.send(notifyEvent)
LockSettingsUtil.enforceCamLockSettingsForAllUsers(liveMeeting, outGW) LockSettingsUtil.enforceCamLockSettingsForAllUsers(liveMeeting, outGW)
// Dial-in // Dial-in
def buildLockMessage(meetingId: String, userId: String, lockedBy: String, locked: Boolean): BbbCommonEnvCoreMsg = { def buildLockMessage(meetingId: String, userId: String, lockedBy: String, locked: Boolean): BbbCommonEnvCoreMsg = {
val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, meetingId, userId) val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, meetingId, userId)

View File

@ -1,26 +0,0 @@
package org.bigbluebutton.core.apps.users
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.models.{ EjectReasonCode, SystemUser }
import org.bigbluebutton.core.running.{ LiveMeeting, OutMsgRouter }
import org.bigbluebutton.core2.message.senders.Sender
trait EjectDuplicateUserReqMsgHdlr {
this: UsersApp =>
val liveMeeting: LiveMeeting
val outGW: OutMsgRouter
def handleEjectDuplicateUserReqMsg(msg: EjectDuplicateUserReqMsg) {
val meetingId = liveMeeting.props.meetingProp.intId
val userId = msg.body.intUserId
val ejectedBy = SystemUser.ID
val reason = "user ejected because of duplicate external userid"
UsersApp.ejectUserFromMeeting(outGW, liveMeeting, userId, ejectedBy, reason, EjectReasonCode.DUPLICATE_USER, ban = false)
// send a system message to force disconnection
Sender.sendDisconnectClientSysMsg(meetingId, userId, ejectedBy, EjectReasonCode.DUPLICATE_USER, outGW)
}
}

View File

@ -3,7 +3,7 @@ package org.bigbluebutton.core.apps.users
import org.bigbluebutton.common2.msgs._ import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.models._ import org.bigbluebutton.core.models._
import org.bigbluebutton.core.running.{ LiveMeeting, OutMsgRouter } import org.bigbluebutton.core.running.{ LiveMeeting, OutMsgRouter }
import org.bigbluebutton.core2.message.senders.MsgBuilder import org.bigbluebutton.core2.message.senders.{ MsgBuilder, Sender }
trait RegisterUserReqMsgHdlr { trait RegisterUserReqMsgHdlr {
this: UsersApp => this: UsersApp =>
@ -22,12 +22,44 @@ trait RegisterUserReqMsgHdlr {
val event = UserRegisteredRespMsg(header, body) val event = UserRegisteredRespMsg(header, body)
BbbCommonEnvCoreMsg(envelope, event) BbbCommonEnvCoreMsg(envelope, event)
} }
def checkUserConcurrentAccesses(regUser: RegisteredUser): Unit = {
//Remove concurrent accesses over the limit
if (liveMeeting.props.usersProp.maxUserConcurrentAccesses > 0) {
val userConcurrentAccesses = RegisteredUsers.findAllWithExternUserId(regUser.externId, liveMeeting.registeredUsers)
.filter(u => !u.loggedOut)
.sortWith((u1, u2) => u1.registeredOn > u2.registeredOn) //Remove older first
val userAvailableSlots = liveMeeting.props.usersProp.maxUserConcurrentAccesses - userConcurrentAccesses.length
if (userAvailableSlots <= 0) {
(liveMeeting.props.usersProp.maxUserConcurrentAccesses to userConcurrentAccesses.length) foreach {
idxUserToRemove =>
{
val userToRemove = userConcurrentAccesses(idxUserToRemove - 1)
val meetingId = liveMeeting.props.meetingProp.intId
log.info(s"User ${regUser.id} with extId=${regUser.externId} has ${userConcurrentAccesses.length} concurrent accesses and limit is ${liveMeeting.props.usersProp.maxUserConcurrentAccesses}. " +
s"Ejecting the oldest=${userToRemove.id} in meetingId=${meetingId}")
val reason = "user ejected because of duplicate external userid"
UsersApp.ejectUserFromMeeting(outGW, liveMeeting, userToRemove.id, SystemUser.ID, reason, EjectReasonCode.DUPLICATE_USER, ban = false)
// send a system message to force disconnection
Sender.sendDisconnectClientSysMsg(meetingId, userToRemove.id, SystemUser.ID, EjectReasonCode.DUPLICATE_USER, outGW)
}
}
}
}
}
val guestStatus = msg.body.guestStatus val guestStatus = msg.body.guestStatus
val regUser = RegisteredUsers.create(msg.body.intUserId, msg.body.extUserId, val regUser = RegisteredUsers.create(msg.body.intUserId, msg.body.extUserId,
msg.body.name, msg.body.role, msg.body.authToken, msg.body.name, msg.body.role, msg.body.authToken,
msg.body.avatarURL, msg.body.guest, msg.body.authed, guestStatus, msg.body.excludeFromDashboard, false) msg.body.avatarURL, msg.body.guest, msg.body.authed, guestStatus, msg.body.excludeFromDashboard, false)
checkUserConcurrentAccesses(regUser)
RegisteredUsers.add(liveMeeting.registeredUsers, regUser) RegisteredUsers.add(liveMeeting.registeredUsers, regUser)
log.info("Register user success. meetingId=" + liveMeeting.props.meetingProp.intId log.info("Register user success. meetingId=" + liveMeeting.props.meetingProp.intId

View File

@ -3,7 +3,7 @@ package org.bigbluebutton.core.apps.users
import org.bigbluebutton.common2.msgs.UserJoinMeetingReqMsg import org.bigbluebutton.common2.msgs.UserJoinMeetingReqMsg
import org.bigbluebutton.core.apps.breakout.BreakoutHdlrHelpers import org.bigbluebutton.core.apps.breakout.BreakoutHdlrHelpers
import org.bigbluebutton.core.domain.MeetingState2x import org.bigbluebutton.core.domain.MeetingState2x
import org.bigbluebutton.core.models.{ Users2x, VoiceUsers } import org.bigbluebutton.core.models.{ RegisteredUser, RegisteredUsers, Users2x, VoiceUsers }
import org.bigbluebutton.core.running.{ HandlerHelpers, LiveMeeting, MeetingActor, OutMsgRouter } import org.bigbluebutton.core.running.{ HandlerHelpers, LiveMeeting, MeetingActor, OutMsgRouter }
trait UserJoinMeetingReqMsgHdlr extends HandlerHelpers { trait UserJoinMeetingReqMsgHdlr extends HandlerHelpers {
@ -26,16 +26,31 @@ trait UserJoinMeetingReqMsgHdlr extends HandlerHelpers {
state state
case None => case None =>
val newState = userJoinMeeting(outGW, msg.body.authToken, msg.body.clientType, liveMeeting, state) // Check if maxParticipants has been reached
// User are able to reenter if he already joined previously with the same extId
if (liveMeeting.props.meetingProp.isBreakout) { val userHasJoinedAlready = RegisteredUsers.findWithUserId(msg.body.userId, liveMeeting.registeredUsers) match {
BreakoutHdlrHelpers.updateParentMeetingWithUsers(liveMeeting, eventBus) case Some(regUser: RegisteredUser) => RegisteredUsers.checkUserExtIdHasJoined(regUser.externId, liveMeeting.registeredUsers)
case None => false
} }
val hasReachedMaxParticipants = liveMeeting.props.usersProp.maxUsers > 0 &&
RegisteredUsers.numUniqueJoinedUsers(liveMeeting.registeredUsers) >= liveMeeting.props.usersProp.maxUsers &&
userHasJoinedAlready == false
// fresh user joined (not due to reconnection). Clear (pop) the cached voice user if (!hasReachedMaxParticipants) {
VoiceUsers.recoverVoiceUser(liveMeeting.voiceUsers, msg.body.userId) val newState = userJoinMeeting(outGW, msg.body.authToken, msg.body.clientType, liveMeeting, state)
newState if (liveMeeting.props.meetingProp.isBreakout) {
BreakoutHdlrHelpers.updateParentMeetingWithUsers(liveMeeting, eventBus)
}
// fresh user joined (not due to reconnection). Clear (pop) the cached voice user
VoiceUsers.recoverVoiceUser(liveMeeting.voiceUsers, msg.body.userId)
newState
} else {
log.info("Ignoring user {} attempt to join, once the meeting {} has reached max participants: {}", msg.body.userId, msg.header.meetingId, liveMeeting.props.usersProp.maxUsers)
state
}
} }
} }
} }

View File

@ -158,7 +158,6 @@ class UsersApp(
with SelectRandomViewerReqMsgHdlr with SelectRandomViewerReqMsgHdlr
with AssignPresenterReqMsgHdlr with AssignPresenterReqMsgHdlr
with ChangeUserPinStateReqMsgHdlr with ChangeUserPinStateReqMsgHdlr
with EjectDuplicateUserReqMsgHdlr
with EjectUserFromMeetingCmdMsgHdlr with EjectUserFromMeetingCmdMsgHdlr
with EjectUserFromMeetingSysMsgHdlr with EjectUserFromMeetingSysMsgHdlr
with MuteUserCmdMsgHdlr { with MuteUserCmdMsgHdlr {

View File

@ -5,7 +5,7 @@ import org.bigbluebutton.core.bus.InternalEventBus
import org.bigbluebutton.core.domain.MeetingState2x import org.bigbluebutton.core.domain.MeetingState2x
import org.bigbluebutton.core.models._ import org.bigbluebutton.core.models._
import org.bigbluebutton.core.running.{ HandlerHelpers, LiveMeeting, OutMsgRouter } import org.bigbluebutton.core.running.{ HandlerHelpers, LiveMeeting, OutMsgRouter }
import org.bigbluebutton.core2.message.senders.{ MsgBuilder, Sender } import org.bigbluebutton.core2.message.senders.{ MsgBuilder }
trait ValidateAuthTokenReqMsgHdlr extends HandlerHelpers { trait ValidateAuthTokenReqMsgHdlr extends HandlerHelpers {
this: UsersApp => this: UsersApp =>
@ -24,10 +24,16 @@ trait ValidateAuthTokenReqMsgHdlr extends HandlerHelpers {
liveMeeting.registeredUsers) liveMeeting.registeredUsers)
regUser match { regUser match {
case Some(u) => case Some(u) =>
// Check if maxParticipants has been reached
// User are able to reenter if he already joined previously with the same extId
val hasReachedMaxParticipants = liveMeeting.props.usersProp.maxUsers > 0 &&
RegisteredUsers.numUniqueJoinedUsers(liveMeeting.registeredUsers) >= liveMeeting.props.usersProp.maxUsers &&
RegisteredUsers.checkUserExtIdHasJoined(u.externId, liveMeeting.registeredUsers) == false
// Check if banned user is rejoining. // Check if banned user is rejoining.
// Fail validation if ejected user is rejoining. // Fail validation if ejected user is rejoining.
// ralam april 21, 2020 // ralam april 21, 2020
if (u.guestStatus == GuestStatus.ALLOW && !u.banned && !u.loggedOut) { if (u.guestStatus == GuestStatus.ALLOW && !u.banned && !u.loggedOut && !hasReachedMaxParticipants) {
userValidated(u, state) userValidated(u, state)
} else { } else {
if (u.banned) { if (u.banned) {
@ -36,6 +42,9 @@ trait ValidateAuthTokenReqMsgHdlr extends HandlerHelpers {
} else if (u.loggedOut) { } else if (u.loggedOut) {
failReason = "User had logged out" failReason = "User had logged out"
failReasonCode = EjectReasonCode.USER_LOGGED_OUT failReasonCode = EjectReasonCode.USER_LOGGED_OUT
} else if (hasReachedMaxParticipants) {
failReason = "The maximum number of participants allowed for this meeting has been reached."
failReasonCode = EjectReasonCode.MAX_PARTICIPANTS
} }
validateTokenFailed( validateTokenFailed(
outGW, outGW,

View File

@ -28,11 +28,7 @@ trait ClearWhiteboardPubMsgHdlr extends RightsManagementTrait {
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, bus.outGW, liveMeeting) PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, bus.outGW, liveMeeting)
} }
} else { } else {
for { log.error("Ignoring message ClearWhiteboardPubMsg since this functions is not available in the new Whiteboard")
fullClear <- clearWhiteboard(msg.body.whiteboardId, msg.header.userId, liveMeeting)
} yield {
broadcastEvent(msg, fullClear)
}
} }
} }
} }

View File

@ -21,14 +21,24 @@ trait DeleteWhiteboardAnnotationsPubMsgHdlr extends RightsManagementTrait {
bus.outGW.send(msgEvent) bus.outGW.send(msgEvent)
} }
if (filterWhiteboardMessage(msg.body.whiteboardId, msg.header.userId, liveMeeting) && permissionFailed(PermissionCheck.GUEST_LEVEL, PermissionCheck.PRESENTER_LEVEL, liveMeeting.users2x, msg.header.userId)) { val isUserAmongPresenters = !permissionFailed(
PermissionCheck.GUEST_LEVEL,
PermissionCheck.PRESENTER_LEVEL, liveMeeting.users2x, msg.header.userId
)
val isUserModerator = !permissionFailed(
PermissionCheck.MOD_LEVEL,
PermissionCheck.VIEWER_LEVEL, liveMeeting.users2x, msg.header.userId
)
if (filterWhiteboardMessage(msg.body.whiteboardId, msg.header.userId, liveMeeting) && !isUserAmongPresenters) {
if (isNonEjectionGracePeriodOver(msg.body.whiteboardId, msg.header.userId, liveMeeting)) { if (isNonEjectionGracePeriodOver(msg.body.whiteboardId, msg.header.userId, liveMeeting)) {
val meetingId = liveMeeting.props.meetingProp.intId val meetingId = liveMeeting.props.meetingProp.intId
val reason = "No permission to delete an annotation." val reason = "No permission to delete an annotation."
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, bus.outGW, liveMeeting) PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, bus.outGW, liveMeeting)
} }
} else { } else {
val deletedAnnotations = deleteWhiteboardAnnotations(msg.body.whiteboardId, msg.header.userId, msg.body.annotationsIds, liveMeeting) val deletedAnnotations = deleteWhiteboardAnnotations(msg.body.whiteboardId, msg.header.userId, msg.body.annotationsIds, liveMeeting, isUserAmongPresenters, isUserModerator)
if (!deletedAnnotations.isEmpty) { if (!deletedAnnotations.isEmpty) {
broadcastEvent(msg, deletedAnnotations) broadcastEvent(msg, deletedAnnotations)
} }

View File

@ -46,13 +46,18 @@ trait SendWhiteboardAnnotationsPubMsgHdlr extends RightsManagementTrait {
PermissionCheck.PRESENTER_LEVEL, liveMeeting.users2x, msg.header.userId PermissionCheck.PRESENTER_LEVEL, liveMeeting.users2x, msg.header.userId
) )
val isUserModerator = !permissionFailed(
PermissionCheck.MOD_LEVEL,
PermissionCheck.VIEWER_LEVEL, liveMeeting.users2x, msg.header.userId
)
if (isUserOneOfPermited || isUserAmongPresenters) { if (isUserOneOfPermited || isUserAmongPresenters) {
println("============= Printing Sanitized annotations ============") println("============= Printing Sanitized annotations ============")
for (annotation <- msg.body.annotations) { for (annotation <- msg.body.annotations) {
printAnnotationInfo(annotation) printAnnotationInfo(annotation)
} }
println("============= Printed Sanitized annotations ============") println("============= Printed Sanitized annotations ============")
val annotations = sendWhiteboardAnnotations(msg.body.whiteboardId, msg.header.userId, msg.body.annotations, liveMeeting) val annotations = sendWhiteboardAnnotations(msg.body.whiteboardId, msg.header.userId, msg.body.annotations, liveMeeting, isUserAmongPresenters, isUserModerator)
broadcastEvent(msg, msg.body.whiteboardId, annotations, msg.body.html5InstanceId) broadcastEvent(msg, msg.body.whiteboardId, annotations, msg.body.html5InstanceId)
} else { } else {
//val meetingId = liveMeeting.props.meetingProp.intId //val meetingId = liveMeeting.props.meetingProp.intId

View File

@ -11,7 +11,7 @@ case class Whiteboard(
multiUser: Array[String], multiUser: Array[String],
oldMultiUser: Array[String], oldMultiUser: Array[String],
changedModeOn: Long, changedModeOn: Long,
annotationsMap: Map[String, Map[String, AnnotationVO]] annotationsMap: Map[String, AnnotationVO]
) )
class WhiteboardApp2x(implicit val context: ActorContext) class WhiteboardApp2x(implicit val context: ActorContext)
@ -24,9 +24,16 @@ class WhiteboardApp2x(implicit val context: ActorContext)
val log = Logging(context.system, getClass) val log = Logging(context.system, getClass)
def sendWhiteboardAnnotations(whiteboardId: String, requesterId: String, annotations: Array[AnnotationVO], liveMeeting: LiveMeeting): Array[AnnotationVO] = { def sendWhiteboardAnnotations(
whiteboardId: String,
requesterId: String,
annotations: Array[AnnotationVO],
liveMeeting: LiveMeeting,
isPresenter: Boolean,
isModerator: Boolean
): Array[AnnotationVO] = {
// println("Received whiteboard annotation. status=[" + status + "], annotationType=[" + annotationType + "]") // println("Received whiteboard annotation. status=[" + status + "], annotationType=[" + annotationType + "]")
liveMeeting.wbModel.addAnnotations(whiteboardId, requesterId, annotations) liveMeeting.wbModel.addAnnotations(whiteboardId, requesterId, annotations, isPresenter, isModerator)
} }
def getWhiteboardAnnotations(whiteboardId: String, liveMeeting: LiveMeeting): Array[AnnotationVO] = { def getWhiteboardAnnotations(whiteboardId: String, liveMeeting: LiveMeeting): Array[AnnotationVO] = {
@ -34,12 +41,15 @@ class WhiteboardApp2x(implicit val context: ActorContext)
liveMeeting.wbModel.getHistory(whiteboardId) liveMeeting.wbModel.getHistory(whiteboardId)
} }
def clearWhiteboard(whiteboardId: String, requesterId: String, liveMeeting: LiveMeeting): Option[Boolean] = { def deleteWhiteboardAnnotations(
liveMeeting.wbModel.clearWhiteboard(whiteboardId, requesterId) whiteboardId: String,
} requesterId: String,
annotationsIds: Array[String],
def deleteWhiteboardAnnotations(whiteboardId: String, requesterId: String, annotationsIds: Array[String], liveMeeting: LiveMeeting): Array[String] = { liveMeeting: LiveMeeting,
liveMeeting.wbModel.deleteAnnotations(whiteboardId, requesterId, annotationsIds) isPresenter: Boolean,
isModerator: Boolean
): Array[String] = {
liveMeeting.wbModel.deleteAnnotations(whiteboardId, requesterId, annotationsIds, isPresenter, isModerator)
} }
def getWhiteboardAccess(whiteboardId: String, liveMeeting: LiveMeeting): Array[String] = { def getWhiteboardAccess(whiteboardId: String, liveMeeting: LiveMeeting): Array[String] = {

View File

@ -14,10 +14,12 @@ case class BreakoutRoom2x(
users: Vector[BreakoutUser], users: Vector[BreakoutUser],
voiceUsers: Vector[BreakoutVoiceUser], voiceUsers: Vector[BreakoutVoiceUser],
startedOn: Option[Long], startedOn: Option[Long],
started: Boolean started: Boolean,
captureNotes: Boolean,
captureSlides: Boolean,
) { ) {
} }
case class BreakoutUser(id: String, name: String) case class BreakoutUser(id: String, name: String)
case class BreakoutVoiceUser(id: String, extId: String, voiceUserId: String) case class BreakoutVoiceUser(id: String, extId: String, voiceUserId: String)

View File

@ -10,8 +10,8 @@ object BreakoutRooms {
def breakoutRoomsdurationInMinutes(status: BreakoutRooms, duration: Int) = status.breakoutRoomsdurationInMinutes = duration def breakoutRoomsdurationInMinutes(status: BreakoutRooms, duration: Int) = status.breakoutRoomsdurationInMinutes = duration
def newBreakoutRoom(parentRoomId: String, id: String, externalMeetingId: String, name: String, sequence: Integer, freeJoin: Boolean, def newBreakoutRoom(parentRoomId: String, id: String, externalMeetingId: String, name: String, sequence: Integer, freeJoin: Boolean,
voiceConfId: String, assignedUsers: Vector[String], breakoutRooms: BreakoutRooms): Option[BreakoutRoomVO] = { voiceConfId: String, assignedUsers: Vector[String], captureNotes: Boolean, captureSlides: Boolean, breakoutRooms: BreakoutRooms): Option[BreakoutRoomVO] = {
val brvo = new BreakoutRoomVO(id, externalMeetingId, name, parentRoomId, sequence, freeJoin, voiceConfId, assignedUsers, Vector()) val brvo = new BreakoutRoomVO(id, externalMeetingId, name, parentRoomId, sequence, freeJoin, voiceConfId, assignedUsers, Vector(), captureNotes, captureSlides)
breakoutRooms.add(brvo) breakoutRooms.add(brvo)
Some(brvo) Some(brvo)
} }

View File

@ -112,7 +112,7 @@ object Polls {
shape = pollResultToWhiteboardShape(result) shape = pollResultToWhiteboardShape(result)
annot <- send(result, shape) annot <- send(result, shape)
} yield { } yield {
lm.wbModel.addAnnotations(annot.wbId, requesterId, Array[AnnotationVO](annot)) lm.wbModel.addAnnotations(annot.wbId, requesterId, Array[AnnotationVO](annot), false, false)
showPollResult(pollId, lm.polls) showPollResult(pollId, lm.polls)
(result, annot) (result, annot)
} }
@ -238,7 +238,7 @@ object Polls {
private def handleRespondToTypedPoll(poll: SimplePollResultOutVO, requesterId: String, pollId: String, questionId: Int, private def handleRespondToTypedPoll(poll: SimplePollResultOutVO, requesterId: String, pollId: String, questionId: Int,
answer: String, lm: LiveMeeting): Option[SimplePollResultOutVO] = { answer: String, lm: LiveMeeting): Option[SimplePollResultOutVO] = {
addQuestionResponse(poll.id, questionId, answer, lm.polls) addQuestionResponse(poll.id, questionId, answer, requesterId, lm.polls)
for { for {
updatedPoll <- getSimplePollResult(poll.id, lm.polls) updatedPoll <- getSimplePollResult(poll.id, lm.polls)
} yield { } yield {
@ -355,6 +355,45 @@ object Polls {
pvo pvo
} }
def checkUserResponded(pollId: String, userId: String, polls: Polls): Boolean = {
polls.polls.get(pollId) match {
case Some(p) => {
if (p.getResponders().filter(p => p.userId == userId).length > 0) {
true
} else {
false
}
}
case None => false
}
}
def checkUserAddedQuestion(pollId: String, userId: String, polls: Polls): Boolean = {
polls.polls.get(pollId) match {
case Some(p) => {
if (p.getTypedPollResponders().filter(responderId => responderId == userId).length > 0) {
true
} else {
false
}
}
case None => false
}
}
def isResponsePollType(pollId: String, polls: Polls): Boolean = {
polls.polls.get(pollId) match {
case Some(p) => {
if (p.questions.filter(q => q.questionType == PollType.ResponsePollType).length > 0) {
true
} else {
false
}
}
case None => false
}
}
def showPollResult(pollId: String, polls: Polls) { def showPollResult(pollId: String, polls: Polls) {
polls.get(pollId) foreach { polls.get(pollId) foreach {
p => p =>
@ -375,10 +414,13 @@ object Polls {
} }
} }
def addQuestionResponse(pollId: String, questionID: Int, answer: String, polls: Polls) { def addQuestionResponse(pollId: String, questionID: Int, answer: String, requesterId: String, polls: Polls) {
polls.polls.get(pollId) match { polls.polls.get(pollId) match {
case Some(p) => { case Some(p) => {
p.addQuestionResponse(questionID, answer) if (!p.getTypedPollResponders().contains(requesterId)) {
p.addTypedPollResponder(requesterId)
p.addQuestionResponse(questionID, answer)
}
} }
case None => case None =>
} }
@ -545,6 +587,7 @@ class Poll(val id: String, val questions: Array[Question], val numRespondents: I
private var _showResult: Boolean = false private var _showResult: Boolean = false
private var _numResponders: Int = 0 private var _numResponders: Int = 0
private var _responders = new ArrayBuffer[Responder]() private var _responders = new ArrayBuffer[Responder]()
private var _respondersTypedPoll = new ArrayBuffer[String]()
def showingResult() { _showResult = true } def showingResult() { _showResult = true }
def showResult(): Boolean = { _showResult } def showResult(): Boolean = { _showResult }
@ -561,6 +604,8 @@ class Poll(val id: String, val questions: Array[Question], val numRespondents: I
def addResponder(responder: Responder) { _responders += (responder) } def addResponder(responder: Responder) { _responders += (responder) }
def getResponders(): ArrayBuffer[Responder] = { return _responders } def getResponders(): ArrayBuffer[Responder] = { return _responders }
def addTypedPollResponder(responderId: String) { _respondersTypedPoll += (responderId) }
def getTypedPollResponders(): ArrayBuffer[String] = { return _respondersTypedPoll }
def hasResponses(): Boolean = { def hasResponses(): Boolean = {
questions.foreach(q => { questions.foreach(q => {

View File

@ -64,6 +64,14 @@ object RegisteredUsers {
} yield user } yield user
} }
def checkUserExtIdHasJoined(externId: String, regUsers: RegisteredUsers): Boolean = {
regUsers.toVector.filter(_.externId == externId).filter(_.joined).length > 0
}
def numUniqueJoinedUsers(regUsers: RegisteredUsers): Int = {
regUsers.toVector.filter(_.joined).map(_.externId).distinct.length
}
def add(users: RegisteredUsers, user: RegisteredUser): Vector[RegisteredUser] = { def add(users: RegisteredUsers, user: RegisteredUser): Vector[RegisteredUser] = {
findWithExternUserId(user.externId, users) match { findWithExternUserId(user.externId, users) match {

View File

@ -407,4 +407,5 @@ object EjectReasonCode {
val USER_INACTIVITY = "user_inactivity_eject_reason" val USER_INACTIVITY = "user_inactivity_eject_reason"
val BANNED_USER_REJOINING = "banned_user_rejoining_reason" val BANNED_USER_REJOINING = "banned_user_rejoining_reason"
val USER_LOGGED_OUT = "user_logged_out_reason" val USER_LOGGED_OUT = "user_logged_out_reason"
val MAX_PARTICIPANTS = "max_participants_reason"
} }

View File

@ -65,8 +65,6 @@ class ReceivedJsonMsgHandlerActor(
// Route via meeting manager as there is a race condition if we send directly to meeting // Route via meeting manager as there is a race condition if we send directly to meeting
// because the meeting actor might not have been created yet. // because the meeting actor might not have been created yet.
route[RegisterUserReqMsg](meetingManagerChannel, envelope, jsonNode) route[RegisterUserReqMsg](meetingManagerChannel, envelope, jsonNode)
case EjectDuplicateUserReqMsg.NAME =>
route[EjectDuplicateUserReqMsg](meetingManagerChannel, envelope, jsonNode)
case UserJoinMeetingReqMsg.NAME => case UserJoinMeetingReqMsg.NAME =>
routeGenericMsg[UserJoinMeetingReqMsg](envelope, jsonNode) routeGenericMsg[UserJoinMeetingReqMsg](envelope, jsonNode)
case UserJoinMeetingAfterReconnectReqMsg.NAME => case UserJoinMeetingAfterReconnectReqMsg.NAME =>
@ -175,6 +173,8 @@ class ReceivedJsonMsgHandlerActor(
routePadMsg[PadPatchSysMsg](envelope, jsonNode) routePadMsg[PadPatchSysMsg](envelope, jsonNode)
case PadUpdatePubMsg.NAME => case PadUpdatePubMsg.NAME =>
routeGenericMsg[PadUpdatePubMsg](envelope, jsonNode) routeGenericMsg[PadUpdatePubMsg](envelope, jsonNode)
case PadCapturePubMsg.NAME =>
routePadMsg[PadCapturePubMsg](envelope, jsonNode)
// Voice // Voice
case RecordingStartedVoiceConfEvtMsg.NAME => case RecordingStartedVoiceConfEvtMsg.NAME =>
@ -310,8 +310,6 @@ class ReceivedJsonMsgHandlerActor(
routeGenericMsg[AssignPresenterReqMsg](envelope, jsonNode) routeGenericMsg[AssignPresenterReqMsg](envelope, jsonNode)
case MakePresentationWithAnnotationDownloadReqMsg.NAME => case MakePresentationWithAnnotationDownloadReqMsg.NAME =>
routeGenericMsg[MakePresentationWithAnnotationDownloadReqMsg](envelope, jsonNode) routeGenericMsg[MakePresentationWithAnnotationDownloadReqMsg](envelope, jsonNode)
case ExportPresentationWithAnnotationReqMsg.NAME =>
routeGenericMsg[ExportPresentationWithAnnotationReqMsg](envelope, jsonNode)
case NewPresAnnFileAvailableMsg.NAME => case NewPresAnnFileAvailableMsg.NAME =>
routeGenericMsg[NewPresAnnFileAvailableMsg](envelope, jsonNode) routeGenericMsg[NewPresAnnFileAvailableMsg](envelope, jsonNode)

View File

@ -226,7 +226,7 @@ trait HandlerHelpers extends SystemConfiguration {
model <- state.breakout model <- state.breakout
} yield { } yield {
model.rooms.values.foreach { room => model.rooms.values.foreach { room =>
eventBus.publish(BigBlueButtonEvent(room.id, EndBreakoutRoomInternalMsg(liveMeeting.props.breakoutProps.parentId, room.id, reason))) eventBus.publish(BigBlueButtonEvent(room.id, EndBreakoutRoomInternalMsg(liveMeeting.props.meetingProp.intId, room.id, reason)))
} }
} }

View File

@ -250,7 +250,6 @@ class MeetingActor(
// Handling RegisterUserReqMsg as it is forwarded from BBBActor and // Handling RegisterUserReqMsg as it is forwarded from BBBActor and
// its type is not BbbCommonEnvCoreMsg // its type is not BbbCommonEnvCoreMsg
case m: RegisterUserReqMsg => usersApp.handleRegisterUserReqMsg(m) case m: RegisterUserReqMsg => usersApp.handleRegisterUserReqMsg(m)
case m: EjectDuplicateUserReqMsg => usersApp.handleEjectDuplicateUserReqMsg(m)
case m: GetAllMeetingsReqMsg => handleGetAllMeetingsReqMsg(m) case m: GetAllMeetingsReqMsg => handleGetAllMeetingsReqMsg(m)
case m: GetRunningMeetingStateReqMsg => handleGetRunningMeetingStateReqMsg(m) case m: GetRunningMeetingStateReqMsg => handleGetRunningMeetingStateReqMsg(m)
case m: ValidateConnAuthTokenSysMsg => handleValidateConnAuthTokenSysMsg(m) case m: ValidateConnAuthTokenSysMsg => handleValidateConnAuthTokenSysMsg(m)
@ -283,7 +282,8 @@ class MeetingActor(
case msg: SendMessageToBreakoutRoomInternalMsg => state = handleSendMessageToBreakoutRoomInternalMsg(msg, state, liveMeeting, msgBus) case msg: SendMessageToBreakoutRoomInternalMsg => state = handleSendMessageToBreakoutRoomInternalMsg(msg, state, liveMeeting, msgBus)
case msg: SendBreakoutTimeRemainingInternalMsg => case msg: SendBreakoutTimeRemainingInternalMsg =>
handleSendBreakoutTimeRemainingInternalMsg(msg) handleSendBreakoutTimeRemainingInternalMsg(msg)
case msg: CapturePresentationReqInternalMsg => presentationPodsApp.handle(msg, state, liveMeeting, msgBus)
case msg: CaptureSharedNotesReqInternalMsg => presentationPodsApp.handle(msg, liveMeeting, msgBus)
case msg: SendRecordingTimerInternalMsg => case msg: SendRecordingTimerInternalMsg =>
state = usersApp.handleSendRecordingTimerInternalMsg(msg, state) state = usersApp.handleSendRecordingTimerInternalMsg(msg, state)
@ -505,8 +505,8 @@ class MeetingActor(
case m: PreuploadedPresentationsSysPubMsg => presentationApp2x.handle(m, liveMeeting, msgBus) case m: PreuploadedPresentationsSysPubMsg => presentationApp2x.handle(m, liveMeeting, msgBus)
case m: AssignPresenterReqMsg => state = handlePresenterChange(m, state) case m: AssignPresenterReqMsg => state = handlePresenterChange(m, state)
case m: MakePresentationWithAnnotationDownloadReqMsg => presentationPodsApp.handle(m, state, liveMeeting, msgBus) case m: MakePresentationWithAnnotationDownloadReqMsg => presentationPodsApp.handle(m, state, liveMeeting, msgBus)
case m: ExportPresentationWithAnnotationReqMsg => presentationPodsApp.handle(m, state, liveMeeting, msgBus)
case m: NewPresAnnFileAvailableMsg => presentationPodsApp.handle(m, liveMeeting, msgBus) case m: NewPresAnnFileAvailableMsg => presentationPodsApp.handle(m, liveMeeting, msgBus)
case m: PadCapturePubMsg => presentationPodsApp.handle(m, liveMeeting, msgBus)
// Presentation Pods // Presentation Pods
case m: CreateNewPresentationPodPubMsg => state = presentationPodsApp.handle(m, state, liveMeeting, msgBus) case m: CreateNewPresentationPodPubMsg => state = presentationPodsApp.handle(m, state, liveMeeting, msgBus)

View File

@ -117,7 +117,6 @@ class AnalyticsActor(val includeChat: Boolean) extends Actor with ActorLogging {
// case m: StoreAnnotationsInRedisSysMsg => logMessage(msg) // case m: StoreAnnotationsInRedisSysMsg => logMessage(msg)
// case m: StoreExportJobInRedisSysMsg => logMessage(msg) // case m: StoreExportJobInRedisSysMsg => logMessage(msg)
case m: MakePresentationWithAnnotationDownloadReqMsg => logMessage(msg) case m: MakePresentationWithAnnotationDownloadReqMsg => logMessage(msg)
case m: ExportPresentationWithAnnotationReqMsg => logMessage(msg)
case m: NewPresAnnFileAvailableMsg => logMessage(msg) case m: NewPresAnnFileAvailableMsg => logMessage(msg)
case m: PresentationPageConversionStartedSysMsg => logMessage(msg) case m: PresentationPageConversionStartedSysMsg => logMessage(msg)
case m: PresentationConversionEndedSysMsg => logMessage(msg) case m: PresentationConversionEndedSysMsg => logMessage(msg)
@ -201,6 +200,7 @@ class AnalyticsActor(val includeChat: Boolean) extends Actor with ActorLogging {
case m: PadUpdatedEvtMsg => logMessage(msg) case m: PadUpdatedEvtMsg => logMessage(msg)
case m: PadUpdatePubMsg => logMessage(msg) case m: PadUpdatePubMsg => logMessage(msg)
case m: PadUpdateCmdMsg => logMessage(msg) case m: PadUpdateCmdMsg => logMessage(msg)
case m: PadCapturePubMsg => logMessage(msg)
case _ => // ignore message case _ => // ignore message
} }

View File

@ -47,7 +47,7 @@ trait AppsTestFixtures {
val meetingLayout = "" val meetingLayout = ""
val metadata: collection.immutable.Map[String, String] = Map("foo" -> "bar", "bar" -> "baz", "baz" -> "foo") val metadata: collection.immutable.Map[String, String] = Map("foo" -> "bar", "bar" -> "baz", "baz" -> "foo")
val breakoutProps = BreakoutProps(parentId = parentMeetingId, sequence = sequence, freeJoin = false, breakoutRooms = Vector()) val breakoutProps = BreakoutProps(parentId = parentMeetingId, sequence = sequence, freeJoin = false, captureNotes = false, captureSlides = false, breakoutRooms = Vector())
val meetingProp = MeetingProp(name = meetingName, extId = externalMeetingId, intId = meetingId, val meetingProp = MeetingProp(name = meetingName, extId = externalMeetingId, intId = meetingId,
meetingCameraCap = meetingCameraCap, meetingCameraCap = meetingCameraCap,

View File

@ -27,7 +27,9 @@ case class BreakoutProps(
freeJoin: Boolean, freeJoin: Boolean,
breakoutRooms: Vector[String], breakoutRooms: Vector[String],
record: Boolean, record: Boolean,
privateChatEnabled: Boolean privateChatEnabled: Boolean,
captureNotes: Boolean,
captureSlides: Boolean,
) )
case class PasswordProp(moderatorPass: String, viewerPass: String, learningDashboardAccessToken: String) case class PasswordProp(moderatorPass: String, viewerPass: String, learningDashboardAccessToken: String)
@ -39,14 +41,15 @@ case class WelcomeProp(welcomeMsgTemplate: String, welcomeMsg: String, modOnlyMe
case class VoiceProp(telVoice: String, voiceConf: String, dialNumber: String, muteOnStart: Boolean) case class VoiceProp(telVoice: String, voiceConf: String, dialNumber: String, muteOnStart: Boolean)
case class UsersProp( case class UsersProp(
maxUsers: Int, maxUsers: Int,
webcamsOnlyForModerator: Boolean, maxUserConcurrentAccesses:Int,
userCameraCap: Int, webcamsOnlyForModerator: Boolean,
guestPolicy: String, userCameraCap: Int,
meetingLayout: String, guestPolicy: String,
allowModsToUnmuteUsers: Boolean, meetingLayout: String,
allowModsToEjectCameras: Boolean, allowModsToUnmuteUsers: Boolean,
authenticatedGuest: Boolean allowModsToEjectCameras: Boolean,
authenticatedGuest: Boolean
) )
case class MetadataProp(metadata: collection.immutable.Map[String, String]) case class MetadataProp(metadata: collection.immutable.Map[String, String])

View File

@ -13,7 +13,7 @@ case class BreakoutRoomJoinURLEvtMsgBody(parentId: String, breakoutId: String, e
object BreakoutRoomsListEvtMsg { val NAME = "BreakoutRoomsListEvtMsg" } object BreakoutRoomsListEvtMsg { val NAME = "BreakoutRoomsListEvtMsg" }
case class BreakoutRoomsListEvtMsg(header: BbbClientMsgHeader, body: BreakoutRoomsListEvtMsgBody) extends BbbCoreMsg case class BreakoutRoomsListEvtMsg(header: BbbClientMsgHeader, body: BreakoutRoomsListEvtMsgBody) extends BbbCoreMsg
case class BreakoutRoomsListEvtMsgBody(meetingId: String, rooms: Vector[BreakoutRoomInfo], roomsReady: Boolean) 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, html5JoinUrls: Map[String, String]) case class BreakoutRoomInfo(name: String, externalId: String, breakoutId: String, sequence: Int, shortName: String, isDefaultName: Boolean, freeJoin: Boolean, html5JoinUrls: Map[String, String], captureNotes: Boolean, captureSlides: Boolean)
object BreakoutRoomsListMsg { val NAME = "BreakoutRoomsListMsg" } object BreakoutRoomsListMsg { val NAME = "BreakoutRoomsListMsg" }
case class BreakoutRoomsListMsg(header: BbbClientMsgHeader, body: BreakoutRoomsListMsgBody) extends StandardMsg case class BreakoutRoomsListMsg(header: BbbClientMsgHeader, body: BreakoutRoomsListMsgBody) extends StandardMsg
@ -58,7 +58,9 @@ case class BreakoutRoomDetail(
sourcePresentationId: String, sourcePresentationId: String,
sourcePresentationSlide: Int, sourcePresentationSlide: Int,
record: Boolean, record: Boolean,
privateChatEnabled: Boolean privateChatEnabled: Boolean,
captureNotes: Boolean,
captureSlides: Boolean,
) )
/** /**
@ -66,7 +68,7 @@ case class BreakoutRoomDetail(
*/ */
object CreateBreakoutRoomsCmdMsg { val NAME = "CreateBreakoutRoomsCmdMsg" } object CreateBreakoutRoomsCmdMsg { val NAME = "CreateBreakoutRoomsCmdMsg" }
case class CreateBreakoutRoomsCmdMsg(header: BbbClientMsgHeader, body: CreateBreakoutRoomsCmdMsgBody) extends StandardMsg case class CreateBreakoutRoomsCmdMsg(header: BbbClientMsgHeader, body: CreateBreakoutRoomsCmdMsgBody) extends StandardMsg
case class CreateBreakoutRoomsCmdMsgBody(meetingId: String, durationInMinutes: Int, record: Boolean, rooms: Vector[BreakoutRoomMsgBody]) case class CreateBreakoutRoomsCmdMsgBody(meetingId: String, durationInMinutes: Int, record: Boolean, captureNotes: Boolean, captureSlides: Boolean, rooms: Vector[BreakoutRoomMsgBody])
case class BreakoutRoomMsgBody(name: String, sequence: Int, shortName: String, isDefaultName: Boolean, freeJoin: Boolean, users: Vector[String]) case class BreakoutRoomMsgBody(name: String, sequence: Int, shortName: String, isDefaultName: Boolean, freeJoin: Boolean, users: Vector[String])
// Sent by user to request ending all the breakout rooms // Sent by user to request ending all the breakout rooms
@ -123,5 +125,5 @@ case class BreakoutUserVO(id: String, name: String)
case class BreakoutRoomVO(id: String, externalId: String, name: String, parentId: String, case class BreakoutRoomVO(id: String, externalId: String, name: String, parentId: String,
sequence: Int, freeJoin: Boolean, voiceConf: String, sequence: Int, freeJoin: Boolean, voiceConf: String,
assignedUsers: Vector[String], users: Vector[BreakoutUserVO]) assignedUsers: Vector[String], users: Vector[BreakoutUserVO], captureNotes: Boolean, captureSlides: Boolean)

View File

@ -113,3 +113,8 @@ case class PadUpdatePubMsgBody(externalId: String, text: String)
object PadUpdateCmdMsg { val NAME = "PadUpdateCmdMsg" } object PadUpdateCmdMsg { val NAME = "PadUpdateCmdMsg" }
case class PadUpdateCmdMsg(header: BbbCoreHeaderWithMeetingId, body: PadUpdateCmdMsgBody) extends BbbCoreMsg case class PadUpdateCmdMsg(header: BbbCoreHeaderWithMeetingId, body: PadUpdateCmdMsgBody) extends BbbCoreMsg
case class PadUpdateCmdMsgBody(groupId: String, name: String, text: String) case class PadUpdateCmdMsgBody(groupId: String, name: String, text: String)
// pads -> apps
object PadCapturePubMsg { val NAME = "PadCapturePubMsg" }
case class PadCapturePubMsg(header: BbbCoreHeaderWithMeetingId, body: PadCapturePubMsgBody) extends PadStandardMsg
case class PadCapturePubMsgBody(parentMeetingId: String, breakoutId: String, padId: String, meetingName: String, sequence: Int)

View File

@ -14,18 +14,10 @@ object MakePresentationWithAnnotationDownloadReqMsg { val NAME = "MakePresentati
case class MakePresentationWithAnnotationDownloadReqMsg(header: BbbClientMsgHeader, body: MakePresentationWithAnnotationDownloadReqMsgBody) extends StandardMsg case class MakePresentationWithAnnotationDownloadReqMsg(header: BbbClientMsgHeader, body: MakePresentationWithAnnotationDownloadReqMsgBody) extends StandardMsg
case class MakePresentationWithAnnotationDownloadReqMsgBody(presId: String, allPages: Boolean, pages: List[Int]) case class MakePresentationWithAnnotationDownloadReqMsgBody(presId: String, allPages: Boolean, pages: List[Int])
object ExportPresentationWithAnnotationReqMsg { val NAME = "ExportPresentationWithAnnotationReqMsg" }
case class ExportPresentationWithAnnotationReqMsg(header: BbbClientMsgHeader, body: ExportPresentationWithAnnotationReqMsgBody) extends StandardMsg
case class ExportPresentationWithAnnotationReqMsgBody(parentMeetingId: String, allPages: Boolean)
object NewPresAnnFileAvailableMsg { val NAME = "NewPresAnnFileAvailableMsg" } object NewPresAnnFileAvailableMsg { val NAME = "NewPresAnnFileAvailableMsg" }
case class NewPresAnnFileAvailableMsg(header: BbbClientMsgHeader, body: NewPresAnnFileAvailableMsgBody) extends StandardMsg case class NewPresAnnFileAvailableMsg(header: BbbClientMsgHeader, body: NewPresAnnFileAvailableMsgBody) extends StandardMsg
case class NewPresAnnFileAvailableMsgBody(fileURI: String, presId: String) case class NewPresAnnFileAvailableMsgBody(fileURI: String, presId: String)
object NewPresAnnFileAvailableEvtMsg { val NAME = "NewPresAnnFileAvailableEvtMsg" }
case class NewPresAnnFileAvailableEvtMsg(header: BbbClientMsgHeader, body: NewPresAnnFileAvailableEvtMsgBody) extends BbbCoreMsg
case class NewPresAnnFileAvailableEvtMsgBody(fileURI: String, presId: String)
// ------------ bbb-common-web to akka-apps ------------ // ------------ bbb-common-web to akka-apps ------------
// ------------ akka-apps to client ------------ // ------------ akka-apps to client ------------
@ -40,4 +32,13 @@ case class PresenterUnassignedEvtMsgBody(intId: String, name: String, assignedBy
object NewPresentationEvtMsg { val NAME = "NewPresentationEvtMsg" } object NewPresentationEvtMsg { val NAME = "NewPresentationEvtMsg" }
case class NewPresentationEvtMsg(header: BbbClientMsgHeader, body: NewPresentationEvtMsgBody) extends BbbCoreMsg case class NewPresentationEvtMsg(header: BbbClientMsgHeader, body: NewPresentationEvtMsgBody) extends BbbCoreMsg
case class NewPresentationEvtMsgBody(presentation: PresentationVO) case class NewPresentationEvtMsgBody(presentation: PresentationVO)
object NewPresAnnFileAvailableEvtMsg { val NAME = "NewPresAnnFileAvailableEvtMsg" }
case class NewPresAnnFileAvailableEvtMsg(header: BbbClientMsgHeader, body: NewPresAnnFileAvailableEvtMsgBody) extends BbbCoreMsg
case class NewPresAnnFileAvailableEvtMsgBody(fileURI: String, presId: String)
object CaptureSharedNotesReqEvtMsg { val NAME = "CaptureSharedNotesReqEvtMsg" }
case class CaptureSharedNotesReqEvtMsg(header: BbbClientMsgHeader, body: CaptureSharedNotesReqEvtMsgBody) extends BbbCoreMsg
case class CaptureSharedNotesReqEvtMsgBody(parentMeetingId: String, meetingName: String, sequence: Int)
// ------------ akka-apps to client ------------ // ------------ akka-apps to client ------------

View File

@ -1,13 +1,5 @@
package org.bigbluebutton.common2.msgs package org.bigbluebutton.common2.msgs
object EjectDuplicateUserReqMsg { val NAME = "EjectDuplicateUserReqMsg" }
case class EjectDuplicateUserReqMsg(
header: BbbCoreHeaderWithMeetingId,
body: EjectDuplicateUserReqMsgBody
) extends BbbCoreMsg
case class EjectDuplicateUserReqMsgBody(meetingId: String, intUserId: String, name: String,
extUserId: String)
object RegisterUserReqMsg { val NAME = "RegisterUserReqMsg" } object RegisterUserReqMsg { val NAME = "RegisterUserReqMsg" }
case class RegisterUserReqMsg( case class RegisterUserReqMsg(
header: BbbCoreHeaderWithMeetingId, header: BbbCoreHeaderWithMeetingId,

View File

@ -49,7 +49,7 @@ trait TestFixtures {
meetingCameraCap = meetingCameraCap, meetingCameraCap = meetingCameraCap,
maxPinnedCameras = maxPinnedCameras, maxPinnedCameras = maxPinnedCameras,
isBreakout = isBreakout.booleanValue()) isBreakout = isBreakout.booleanValue())
val breakoutProps = BreakoutProps(parentId = parentMeetingId, sequence = sequence, freeJoin = false, breakoutRooms = Vector()) val breakoutProps = BreakoutProps(parentId = parentMeetingId, sequence = sequence, freeJoin = false, captureNotes = false, captureSlides = false, breakoutRooms = Vector())
val durationProps = DurationProps(duration = durationInMinutes, createdTime = createTime, createdDate = createDate, val durationProps = DurationProps(duration = durationInMinutes, createdTime = createTime, createdDate = createDate,
meetingExpireIfNoUserJoinedInMinutes = meetingExpireIfNoUserJoinedInMinutes, meetingExpireWhenLastUserLeftInMinutes = meetingExpireWhenLastUserLeftInMinutes, meetingExpireIfNoUserJoinedInMinutes = meetingExpireIfNoUserJoinedInMinutes, meetingExpireWhenLastUserLeftInMinutes = meetingExpireWhenLastUserLeftInMinutes,

View File

@ -31,7 +31,7 @@ object Dependencies {
val lang = "3.12.0" val lang = "3.12.0"
val io = "2.11.0" val io = "2.11.0"
val pool = "2.11.1" val pool = "2.11.1"
val text = "1.9" val text = "1.10.0"
// BigBlueButton // BigBlueButton
val bbbCommons = "0.0.21-SNAPSHOT" val bbbCommons = "0.0.21-SNAPSHOT"

View File

@ -76,6 +76,8 @@ public class ApiParams {
public static final String UPLOAD_EXTERNAL_DESCRIPTION = "uploadExternalDescription"; public static final String UPLOAD_EXTERNAL_DESCRIPTION = "uploadExternalDescription";
public static final String UPLOAD_EXTERNAL_URL = "uploadExternalUrl"; public static final String UPLOAD_EXTERNAL_URL = "uploadExternalUrl";
public static final String BREAKOUT_ROOMS_CAPTURE_SLIDES = "breakoutRoomsCaptureSlides";
public static final String BREAKOUT_ROOMS_CAPTURE_NOTES = "breakoutRoomsCaptureNotes";
public static final String BREAKOUT_ROOMS_ENABLED = "breakoutRoomsEnabled"; public static final String BREAKOUT_ROOMS_ENABLED = "breakoutRoomsEnabled";
public static final String BREAKOUT_ROOMS_RECORD = "breakoutRoomsRecord"; public static final String BREAKOUT_ROOMS_RECORD = "breakoutRoomsRecord";
public static final String BREAKOUT_ROOMS_PRIVATE_CHAT_ENABLED = "breakoutRoomsPrivateChatEnabled"; public static final String BREAKOUT_ROOMS_PRIVATE_CHAT_ENABLED = "breakoutRoomsPrivateChatEnabled";

View File

@ -377,6 +377,8 @@ public class MeetingService implements MessageListener {
breakoutMetadata.put("meetingId", m.getExternalId()); breakoutMetadata.put("meetingId", m.getExternalId());
breakoutMetadata.put("sequence", m.getSequence().toString()); breakoutMetadata.put("sequence", m.getSequence().toString());
breakoutMetadata.put("freeJoin", m.isFreeJoin().toString()); breakoutMetadata.put("freeJoin", m.isFreeJoin().toString());
breakoutMetadata.put("captureSlides", m.isCaptureSlides().toString());
breakoutMetadata.put("captureNotes", m.isCaptureNotes().toString());
breakoutMetadata.put("parentMeetingId", m.getParentMeetingId()); breakoutMetadata.put("parentMeetingId", m.getParentMeetingId());
storeService.recordBreakoutInfo(m.getInternalId(), breakoutMetadata); storeService.recordBreakoutInfo(m.getInternalId(), breakoutMetadata);
} }
@ -388,6 +390,8 @@ public class MeetingService implements MessageListener {
if (m.isBreakout()) { if (m.isBreakout()) {
logData.put("sequence", m.getSequence()); logData.put("sequence", m.getSequence());
logData.put("freeJoin", m.isFreeJoin()); logData.put("freeJoin", m.isFreeJoin());
logData.put("captureSlides", m.isCaptureSlides());
logData.put("captureNotes", m.isCaptureNotes());
logData.put("parentMeetingId", m.getParentMeetingId()); logData.put("parentMeetingId", m.getParentMeetingId());
} }
logData.put("name", m.getName()); logData.put("name", m.getName());
@ -415,7 +419,7 @@ public class MeetingService implements MessageListener {
m.getLearningDashboardAccessToken(), m.getCreateTime(), m.getLearningDashboardAccessToken(), m.getCreateTime(),
formatPrettyDate(m.getCreateTime()), m.isBreakout(), m.getSequence(), m.isFreeJoin(), m.getMetadata(), formatPrettyDate(m.getCreateTime()), m.isBreakout(), m.getSequence(), m.isFreeJoin(), m.getMetadata(),
m.getGuestPolicy(), m.getAuthenticatedGuest(), m.getMeetingLayout(), m.getWelcomeMessageTemplate(), m.getWelcomeMessage(), m.getModeratorOnlyMessage(), m.getGuestPolicy(), m.getAuthenticatedGuest(), m.getMeetingLayout(), m.getWelcomeMessageTemplate(), m.getWelcomeMessage(), m.getModeratorOnlyMessage(),
m.getDialNumber(), m.getMaxUsers(), m.getDialNumber(), m.getMaxUsers(), m.getMaxUserConcurrentAccesses(),
m.getMeetingExpireIfNoUserJoinedInMinutes(), m.getMeetingExpireWhenLastUserLeftInMinutes(), m.getMeetingExpireIfNoUserJoinedInMinutes(), m.getMeetingExpireWhenLastUserLeftInMinutes(),
m.getUserInactivityInspectTimerInMinutes(), m.getUserInactivityThresholdInMinutes(), m.getUserInactivityInspectTimerInMinutes(), m.getUserInactivityThresholdInMinutes(),
m.getUserActivitySignResponseDelayInMinutes(), m.getEndWhenNoModerator(), m.getEndWhenNoModeratorDelayInMinutes(), m.getUserActivitySignResponseDelayInMinutes(), m.getEndWhenNoModerator(), m.getEndWhenNoModeratorDelayInMinutes(),
@ -434,33 +438,6 @@ public class MeetingService implements MessageListener {
} }
private void processRegisterUser(RegisterUser message) { private void processRegisterUser(RegisterUser message) {
Meeting m = getMeeting(message.meetingID);
if (m != null) {
User prevUser = m.getUserWithExternalId(message.externUserID);
if (prevUser != null) {
Map<String, Object> logData = new HashMap<>();
logData.put("meetingId", m.getInternalId());
logData.put("externalMeetingId", m.getExternalId());
logData.put("name", m.getName());
logData.put("extUserId", prevUser.getExternalUserId());
logData.put("intUserId", prevUser.getInternalUserId());
logData.put("username", prevUser.getFullname());
logData.put("logCode", "duplicate_user_with_external_userid");
logData.put("description", "Duplicate user with external userid.");
Gson gson = new Gson();
String logStr = gson.toJson(logData);
log.info(" --analytics-- data={}", logStr);
if (!m.allowDuplicateExtUserid) {
gw.ejectDuplicateUser(message.meetingID,
prevUser.getInternalUserId(), prevUser.getFullname(),
prevUser.getExternalUserId());
}
}
}
gw.registerUser(message.meetingID, gw.registerUser(message.meetingID,
message.internalUserId, message.fullname, message.role, message.internalUserId, message.fullname, message.role,
message.externUserID, message.authToken, message.avatarURL, message.guest, message.externUserID, message.authToken, message.avatarURL, message.guest,
@ -661,6 +638,8 @@ public class MeetingService implements MessageListener {
params.put(ApiParams.IS_BREAKOUT, "true"); params.put(ApiParams.IS_BREAKOUT, "true");
params.put(ApiParams.SEQUENCE, message.sequence.toString()); params.put(ApiParams.SEQUENCE, message.sequence.toString());
params.put(ApiParams.FREE_JOIN, message.freeJoin.toString()); params.put(ApiParams.FREE_JOIN, message.freeJoin.toString());
params.put(ApiParams.BREAKOUT_ROOMS_CAPTURE_SLIDES, message.captureSlides.toString());
params.put(ApiParams.BREAKOUT_ROOMS_CAPTURE_NOTES, message.captureNotes.toString());
params.put(ApiParams.ATTENDEE_PW, message.viewerPassword); params.put(ApiParams.ATTENDEE_PW, message.viewerPassword);
params.put(ApiParams.MODERATOR_PW, message.moderatorPassword); params.put(ApiParams.MODERATOR_PW, message.moderatorPassword);
params.put(ApiParams.DIAL_NUMBER, message.dialNumber); params.put(ApiParams.DIAL_NUMBER, message.dialNumber);
@ -951,9 +930,8 @@ public class MeetingService implements MessageListener {
message.name, message.role, message.avatarURL, message.guest, message.guestStatus, message.name, message.role, message.avatarURL, message.guest, message.guestStatus,
message.clientType); message.clientType);
if(m.getMaxUsers() > 0 && m.getUsers().size() >= m.getMaxUsers()) { if(m.getMaxUsers() > 0 && m.countUniqueExtIds() >= m.getMaxUsers()) {
m.removeEnteredUser(user.getInternalUserId()); m.removeEnteredUser(user.getInternalUserId());
gw.ejectDuplicateUser(message.meetingId, user.getInternalUserId(), user.getFullname(), user.getExternalUserId());
return; return;
} }

View File

@ -66,6 +66,8 @@ public class ParamsProcessorUtil {
private String apiVersion; private String apiVersion;
private boolean serviceEnabled = false; private boolean serviceEnabled = false;
private String securitySalt; private String securitySalt;
private String supportedChecksumAlgorithms;
private String checksumHash;
private int defaultMaxUsers = 20; private int defaultMaxUsers = 20;
private String defaultWelcomeMessage; private String defaultWelcomeMessage;
private String defaultWelcomeMessageFooter; private String defaultWelcomeMessageFooter;
@ -106,6 +108,8 @@ public class ParamsProcessorUtil {
private boolean defaultBreakoutRoomsEnabled = true; private boolean defaultBreakoutRoomsEnabled = true;
private boolean defaultBreakoutRoomsRecord; private boolean defaultBreakoutRoomsRecord;
private boolean defaultBreakoutRoomsCaptureSlides = false;
private boolean defaultBreakoutRoomsCaptureNotes = false;
private boolean defaultbreakoutRoomsPrivateChatEnabled; private boolean defaultbreakoutRoomsPrivateChatEnabled;
private boolean defaultLockSettingsDisableCam; private boolean defaultLockSettingsDisableCam;
@ -128,6 +132,8 @@ public class ParamsProcessorUtil {
private Integer userInactivityThresholdInMinutes = 30; private Integer userInactivityThresholdInMinutes = 30;
private Integer userActivitySignResponseDelayInMinutes = 5; private Integer userActivitySignResponseDelayInMinutes = 5;
private Boolean defaultAllowDuplicateExtUserid = true; private Boolean defaultAllowDuplicateExtUserid = true;
private Integer maxUserConcurrentAccesses = 0;
private Boolean defaultEndWhenNoModerator = false; private Boolean defaultEndWhenNoModerator = false;
private Integer defaultEndWhenNoModeratorDelayInMinutes = 1; private Integer defaultEndWhenNoModeratorDelayInMinutes = 1;
private Integer defaultHtml5InstanceId = 1; private Integer defaultHtml5InstanceId = 1;
@ -275,7 +281,19 @@ public class ParamsProcessorUtil {
breakoutRoomsPrivateChatEnabled = Boolean.parseBoolean(breakoutRoomsPrivateChatEnabledParam); breakoutRoomsPrivateChatEnabled = Boolean.parseBoolean(breakoutRoomsPrivateChatEnabledParam);
} }
return new BreakoutRoomsParams(breakoutRoomsRecord, breakoutRoomsPrivateChatEnabled); Boolean breakoutRoomsCaptureSlides = defaultBreakoutRoomsCaptureSlides;
String breakoutRoomsCaptureParam = params.get(ApiParams.BREAKOUT_ROOMS_CAPTURE_SLIDES);
if (!StringUtils.isEmpty(breakoutRoomsCaptureParam)) {
breakoutRoomsCaptureSlides = Boolean.parseBoolean(breakoutRoomsCaptureParam);
}
Boolean breakoutRoomsCaptureNotes = defaultBreakoutRoomsCaptureNotes;
String breakoutRoomsCaptureNotesParam = params.get(ApiParams.BREAKOUT_ROOMS_CAPTURE_NOTES);
if (!StringUtils.isEmpty(breakoutRoomsCaptureNotesParam)) {
breakoutRoomsCaptureNotes = Boolean.parseBoolean(breakoutRoomsCaptureNotesParam);
}
return new BreakoutRoomsParams(breakoutRoomsRecord, breakoutRoomsPrivateChatEnabled, breakoutRoomsCaptureNotes, breakoutRoomsCaptureSlides);
} }
private LockSettingsParams processLockSettingsParams(Map<String, String> params) { private LockSettingsParams processLockSettingsParams(Map<String, String> params) {
@ -680,6 +698,11 @@ public class ParamsProcessorUtil {
int html5InstanceId = processHtml5InstanceId(params.get(ApiParams.HTML5_INSTANCE_ID)); int html5InstanceId = processHtml5InstanceId(params.get(ApiParams.HTML5_INSTANCE_ID));
if(defaultAllowDuplicateExtUserid == false) {
log.warn("[DEPRECATION] use `maxUserConcurrentAccesses=1` instead of `allowDuplicateExtUserid=false`");
maxUserConcurrentAccesses = 1;
}
// Create the meeting with all passed in parameters. // Create the meeting with all passed in parameters.
Meeting meeting = new Meeting.Builder(externalMeetingId, Meeting meeting = new Meeting.Builder(externalMeetingId,
internalMeetingId, createTime).withName(meetingName) internalMeetingId, createTime).withName(meetingName)
@ -706,7 +729,7 @@ public class ParamsProcessorUtil {
.withMeetingLayout(meetingLayout) .withMeetingLayout(meetingLayout)
.withBreakoutRoomsParams(breakoutParams) .withBreakoutRoomsParams(breakoutParams)
.withLockSettingsParams(lockSettingsParams) .withLockSettingsParams(lockSettingsParams)
.withAllowDuplicateExtUserid(defaultAllowDuplicateExtUserid) .withMaxUserConcurrentAccesses(maxUserConcurrentAccesses)
.withHTML5InstanceId(html5InstanceId) .withHTML5InstanceId(html5InstanceId)
.withLearningDashboardCleanupDelayInMinutes(learningDashboardCleanupMins) .withLearningDashboardCleanupDelayInMinutes(learningDashboardCleanupMins)
.withLearningDashboardAccessToken(learningDashboardAccessToken) .withLearningDashboardAccessToken(learningDashboardAccessToken)
@ -742,6 +765,8 @@ public class ParamsProcessorUtil {
if (isBreakout) { if (isBreakout) {
meeting.setSequence(Integer.parseInt(params.get(ApiParams.SEQUENCE))); meeting.setSequence(Integer.parseInt(params.get(ApiParams.SEQUENCE)));
meeting.setFreeJoin(Boolean.parseBoolean(params.get(ApiParams.FREE_JOIN))); meeting.setFreeJoin(Boolean.parseBoolean(params.get(ApiParams.FREE_JOIN)));
meeting.setCaptureSlides(Boolean.parseBoolean(params.get(ApiParams.BREAKOUT_ROOMS_CAPTURE_SLIDES)));
meeting.setCaptureNotes(Boolean.parseBoolean(params.get(ApiParams.BREAKOUT_ROOMS_CAPTURE_NOTES)));
meeting.setParentMeetingId(parentMeetingId); meeting.setParentMeetingId(parentMeetingId);
} }
@ -978,11 +1003,39 @@ public class ParamsProcessorUtil {
log.info("CHECKSUM={} length={}", checksum, checksum.length()); log.info("CHECKSUM={} length={}", checksum, checksum.length());
String data = apiCall + queryString + securitySalt; String data = apiCall + queryString + securitySalt;
String cs = DigestUtils.sha1Hex(data);
if (checksum.length() == 64) { int checksumLength = checksum.length();
cs = DigestUtils.sha256Hex(data); String cs = null;
log.info("SHA256 {}", cs);
} switch(checksumLength) {
case 40:
if(supportedChecksumAlgorithms.contains("sha1")) {
cs = DigestUtils.sha1Hex(data);
log.info("SHA1 {}", cs);
}
break;
case 64:
if(supportedChecksumAlgorithms.contains("sha256")) {
cs = DigestUtils.sha256Hex(data);
log.info("SHA256 {}", cs);
}
break;
case 96:
if(supportedChecksumAlgorithms.contains("sha384")) {
cs = DigestUtils.sha384Hex(data);
log.info("SHA384 {}", cs);
}
break;
case 128:
if(supportedChecksumAlgorithms.contains("sha512")) {
cs = DigestUtils.sha512Hex(data);
log.info("SHA512 {}", cs);
}
break;
default:
log.info("No algorithm could be found that matches the provided checksum length");
}
if (cs == null || !cs.equals(checksum)) { if (cs == null || !cs.equals(checksum)) {
log.info("query string after checksum removed: [{}]", queryString); log.info("query string after checksum removed: [{}]", queryString);
log.info("checksumError: query string checksum failed. our: [{}], client: [{}]", cs, checksum); log.info("checksumError: query string checksum failed. our: [{}], client: [{}]", cs, checksum);
@ -1068,6 +1121,10 @@ public class ParamsProcessorUtil {
this.securitySalt = securitySalt; this.securitySalt = securitySalt;
} }
public void setSupportedChecksumAlgorithms(String supportedChecksumAlgorithms) { this.supportedChecksumAlgorithms = supportedChecksumAlgorithms; }
public void setChecksumHash(String checksumHash) { this.checksumHash = checksumHash; }
public void setDefaultMaxUsers(int defaultMaxUsers) { public void setDefaultMaxUsers(int defaultMaxUsers) {
this.defaultMaxUsers = defaultMaxUsers; this.defaultMaxUsers = defaultMaxUsers;
} }
@ -1367,6 +1424,10 @@ public class ParamsProcessorUtil {
this.defaultAllowDuplicateExtUserid = allow; this.defaultAllowDuplicateExtUserid = allow;
} }
public void setMaxUserConcurrentAccesses(Integer maxUserConcurrentAccesses) {
this.maxUserConcurrentAccesses = maxUserConcurrentAccesses;
}
public void setEndWhenNoModerator(Boolean val) { public void setEndWhenNoModerator(Boolean val) {
this.defaultEndWhenNoModerator = val; this.defaultEndWhenNoModerator = val;
} }

View File

@ -3,9 +3,13 @@ package org.bigbluebutton.api.domain;
public class BreakoutRoomsParams { public class BreakoutRoomsParams {
public final Boolean record; public final Boolean record;
public final Boolean privateChatEnabled; public final Boolean privateChatEnabled;
public final Boolean captureNotes;
public final Boolean captureSlides;
public BreakoutRoomsParams(Boolean record, Boolean privateChatEnabled) { public BreakoutRoomsParams(Boolean record, Boolean privateChatEnabled, Boolean captureNotes, Boolean captureSlides) {
this.record = record; this.record = record;
this.privateChatEnabled = privateChatEnabled; this.privateChatEnabled = privateChatEnabled;
this.captureNotes = captureNotes;
this.captureSlides = captureSlides;
} }
} }

View File

@ -42,7 +42,9 @@ public class Meeting {
private String parentMeetingId = "bbb-none"; // Initialize so we don't send null in the json message. private String parentMeetingId = "bbb-none"; // Initialize so we don't send null in the json message.
private Integer sequence = 0; private Integer sequence = 0;
private Boolean freeJoin = false; private Boolean freeJoin = false;
private Integer duration = 0; private Boolean captureSlides = false;
private Boolean captureNotes = false;
private Integer duration = 0;
private long createdTime = 0; private long createdTime = 0;
private long startTime = 0; private long startTime = 0;
private long endTime = 0; private long endTime = 0;
@ -109,7 +111,7 @@ public class Meeting {
public final BreakoutRoomsParams breakoutRoomsParams; public final BreakoutRoomsParams breakoutRoomsParams;
public final LockSettingsParams lockSettingsParams; public final LockSettingsParams lockSettingsParams;
public final Boolean allowDuplicateExtUserid; public final Integer maxUserConcurrentAccesses;
private String meetingEndedCallbackURL = ""; private String meetingEndedCallbackURL = "";
@ -163,7 +165,7 @@ public class Meeting {
allowRequestsWithoutSession = builder.allowRequestsWithoutSession; allowRequestsWithoutSession = builder.allowRequestsWithoutSession;
breakoutRoomsParams = builder.breakoutRoomsParams; breakoutRoomsParams = builder.breakoutRoomsParams;
lockSettingsParams = builder.lockSettingsParams; lockSettingsParams = builder.lockSettingsParams;
allowDuplicateExtUserid = builder.allowDuplicateExtUserid; maxUserConcurrentAccesses = builder.maxUserConcurrentAccesses;
endWhenNoModerator = builder.endWhenNoModerator; endWhenNoModerator = builder.endWhenNoModerator;
endWhenNoModeratorDelayInMinutes = builder.endWhenNoModeratorDelayInMinutes; endWhenNoModeratorDelayInMinutes = builder.endWhenNoModeratorDelayInMinutes;
html5InstanceId = builder.html5InstanceId; html5InstanceId = builder.html5InstanceId;
@ -197,6 +199,28 @@ public class Meeting {
return users; return users;
} }
public Integer countUniqueExtIds() {
List<String> uniqueExtIds = new ArrayList<String>();
for (User user : users.values()) {
if(!uniqueExtIds.contains(user.getExternalUserId())) {
uniqueExtIds.add(user.getExternalUserId());
}
}
return uniqueExtIds.size();
}
public List<String> getUsersWithExtId(String externalUserId) {
List<String> usersWithExtId = new ArrayList<String>();
for (User user : users.values()) {
if(user.getExternalUserId().equals(externalUserId)) {
usersWithExtId.add(user.getInternalUserId());
}
}
return usersWithExtId;
}
public void guestIsWaiting(String userId) { public void guestIsWaiting(String userId) {
RegisteredUser ruser = registeredUsers.get(userId); RegisteredUser ruser = registeredUsers.get(userId);
if (ruser != null) { if (ruser != null) {
@ -288,6 +312,22 @@ public class Meeting {
this.freeJoin = freeJoin; this.freeJoin = freeJoin;
} }
public Boolean isCaptureSlides() {
return captureSlides;
}
public void setCaptureSlides(Boolean capture) {
this.captureSlides = captureSlides;
}
public Boolean isCaptureNotes() {
return captureNotes;
}
public void setCaptureNotes(Boolean capture) {
this.captureNotes = captureNotes;
}
public Integer getDuration() { public Integer getDuration() {
return duration; return duration;
} }
@ -504,6 +544,10 @@ public class Meeting {
return maxUsers; return maxUsers;
} }
public Integer getMaxUserConcurrentAccesses() {
return maxUserConcurrentAccesses;
}
public int getLogoutTimer() { public int getLogoutTimer() {
return logoutTimer; return logoutTimer;
} }
@ -633,17 +677,6 @@ public class Meeting {
return this.users.get(id); return this.users.get(id);
} }
public User getUserWithExternalId(String externalUserId) {
for (Map.Entry<String, User> entry : users.entrySet()) {
User u = entry.getValue();
if (u.getExternalUserId().equals(externalUserId)) {
return u;
}
}
return null;
}
public int getNumUsers(){ public int getNumUsers(){
return this.users.size(); return this.users.size();
} }
@ -843,7 +876,8 @@ public class Meeting {
private String meetingLayout; private String meetingLayout;
private BreakoutRoomsParams breakoutRoomsParams; private BreakoutRoomsParams breakoutRoomsParams;
private LockSettingsParams lockSettingsParams; private LockSettingsParams lockSettingsParams;
private Boolean allowDuplicateExtUserid;
private Integer maxUserConcurrentAccesses;
private Boolean endWhenNoModerator; private Boolean endWhenNoModerator;
private Integer endWhenNoModeratorDelayInMinutes; private Integer endWhenNoModeratorDelayInMinutes;
private int html5InstanceId; private int html5InstanceId;
@ -1035,8 +1069,8 @@ public class Meeting {
return this; return this;
} }
public Builder withAllowDuplicateExtUserid(Boolean allowDuplicateExtUserid) { public Builder withMaxUserConcurrentAccesses(Integer maxUserConcurrentAccesses) {
this.allowDuplicateExtUserid = allowDuplicateExtUserid; this.maxUserConcurrentAccesses = maxUserConcurrentAccesses;
return this; return this;
} }

View File

@ -19,6 +19,8 @@ public class CreateBreakoutRoom implements IMessage {
public final Integer sourcePresentationSlide; public final Integer sourcePresentationSlide;
public final Boolean record; public final Boolean record;
public final Boolean privateChatEnabled; public final Boolean privateChatEnabled;
public final Boolean captureNotes; // Upload shared notes to main room after breakout room end
public final Boolean captureSlides; // Upload annotated breakout slides to main room after breakout room end
public CreateBreakoutRoom(String meetingId, public CreateBreakoutRoom(String meetingId,
String parentMeetingId, String parentMeetingId,
@ -35,7 +37,9 @@ public class CreateBreakoutRoom implements IMessage {
String sourcePresentationId, String sourcePresentationId,
Integer sourcePresentationSlide, Integer sourcePresentationSlide,
Boolean record, Boolean record,
Boolean privateChatEnabled) { Boolean privateChatEnabled,
Boolean captureNotes,
Boolean captureSlides) {
this.meetingId = meetingId; this.meetingId = meetingId;
this.parentMeetingId = parentMeetingId; this.parentMeetingId = parentMeetingId;
this.name = name; this.name = name;
@ -52,5 +56,7 @@ public class CreateBreakoutRoom implements IMessage {
this.sourcePresentationSlide = sourcePresentationSlide; this.sourcePresentationSlide = sourcePresentationSlide;
this.record = record; this.record = record;
this.privateChatEnabled = privateChatEnabled; this.privateChatEnabled = privateChatEnabled;
this.captureNotes = captureNotes;
this.captureSlides = captureSlides;
} }
} }

View File

@ -1,22 +0,0 @@
package org.bigbluebutton.api.model.constraint;
import org.bigbluebutton.api.model.validator.MaxParticipantsValidator;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
@Constraint(validatedBy = { MaxParticipantsValidator.class })
@Target(FIELD)
@Retention(RUNTIME)
public @interface MaxParticipantsConstraint {
String key() default "maxParticipantsReached";
String message() default "The maximum number of participants for the meeting has been reached";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

View File

@ -1,6 +1,5 @@
package org.bigbluebutton.api.model.request; package org.bigbluebutton.api.model.request;
import org.bigbluebutton.api.model.constraint.MaxParticipantsConstraint;
import org.bigbluebutton.api.model.constraint.MeetingEndedConstraint; import org.bigbluebutton.api.model.constraint.MeetingEndedConstraint;
import org.bigbluebutton.api.model.constraint.MeetingExistsConstraint; import org.bigbluebutton.api.model.constraint.MeetingExistsConstraint;
import org.bigbluebutton.api.model.constraint.UserSessionConstraint; import org.bigbluebutton.api.model.constraint.UserSessionConstraint;

View File

@ -20,6 +20,7 @@ public class GetChecksumValidator implements ConstraintValidator<GetChecksumCons
@Override @Override
public boolean isValid(GetChecksum checksum, ConstraintValidatorContext context) { public boolean isValid(GetChecksum checksum, ConstraintValidatorContext context) {
String securitySalt = ServiceUtils.getValidationService().getSecuritySalt(); String securitySalt = ServiceUtils.getValidationService().getSecuritySalt();
String supportedChecksumAlgorithms = ServiceUtils.getValidationService().getSupportedChecksumAlgorithms();
if (securitySalt.isEmpty()) { if (securitySalt.isEmpty()) {
log.warn("Security is disabled in this service. Make sure this is intentional."); log.warn("Security is disabled in this service. Make sure this is intentional.");
@ -41,12 +42,37 @@ public class GetChecksumValidator implements ConstraintValidator<GetChecksumCons
} }
String data = checksum.getApiCall() + queryStringWithoutChecksum + securitySalt; String data = checksum.getApiCall() + queryStringWithoutChecksum + securitySalt;
String createdCheckSum = DigestUtils.sha1Hex(data);
if (providedChecksum.length() == 64) { int checksumLength = providedChecksum.length();
log.debug("providedChecksum.length() == 64"); String createdCheckSum = null;
createdCheckSum = DigestUtils.sha256Hex(data);
log.info("SHA256 {}", createdCheckSum); switch(checksumLength) {
case 40:
if(supportedChecksumAlgorithms.contains("sha1")) {
createdCheckSum = DigestUtils.sha1Hex(data);
log.info("SHA1 {}", createdCheckSum);
}
break;
case 64:
if(supportedChecksumAlgorithms.contains("sha256")) {
createdCheckSum = DigestUtils.sha256Hex(data);
log.info("SHA256 {}", createdCheckSum);
}
break;
case 96:
if(supportedChecksumAlgorithms.contains("sha384")) {
createdCheckSum = DigestUtils.sha384Hex(data);
log.info("SHA384 {}", createdCheckSum);
}
break;
case 128:
if(supportedChecksumAlgorithms.contains("sha512")) {
createdCheckSum = DigestUtils.sha512Hex(data);
log.info("SHA512 {}", createdCheckSum);
}
break;
default:
log.info("No algorithm could be found that matches the provided checksum length");
} }
if (createdCheckSum == null || !createdCheckSum.equals(providedChecksum)) { if (createdCheckSum == null || !createdCheckSum.equals(providedChecksum)) {

View File

@ -1,50 +0,0 @@
package org.bigbluebutton.api.model.validator;
import org.bigbluebutton.api.MeetingService;
import org.bigbluebutton.api.domain.Meeting;
import org.bigbluebutton.api.domain.UserSession;
import org.bigbluebutton.api.model.constraint.MaxParticipantsConstraint;
import org.bigbluebutton.api.service.ServiceUtils;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
public class MaxParticipantsValidator implements ConstraintValidator<MaxParticipantsConstraint, String> {
@Override
public void initialize(MaxParticipantsConstraint constraintAnnotation) {}
@Override
public boolean isValid(String sessionToken, ConstraintValidatorContext constraintValidatorContext) {
if(sessionToken == null) {
return false;
}
MeetingService meetingService = ServiceUtils.getMeetingService();
UserSession userSession = meetingService.getUserSessionWithAuthToken(sessionToken);
if(userSession == null) {
return false;
}
Meeting meeting = meetingService.getMeeting(userSession.meetingID);
if(meeting == null) {
return false;
}
int maxParticipants = meeting.getMaxUsers();
boolean enabled = maxParticipants > 0;
boolean rejoin = meeting.getUserById(userSession.internalUserId) != null;
boolean reenter = meeting.getEnteredUserById(userSession.internalUserId) != null;
int joinedUsers = meeting.getUsers().size();
boolean reachedMax = joinedUsers >= maxParticipants;
if(enabled && !rejoin && !reenter && reachedMax) {
return false;
}
return true;
}
}

View File

@ -55,6 +55,7 @@ public class ValidationService {
} }
private String securitySalt; private String securitySalt;
private String supportedChecksumAlgorithms;
private Boolean allowRequestsWithoutSession; private Boolean allowRequestsWithoutSession;
private ValidatorFactory validatorFactory; private ValidatorFactory validatorFactory;
@ -266,6 +267,9 @@ public class ValidationService {
public void setSecuritySalt(String securitySalt) { this.securitySalt = securitySalt; } public void setSecuritySalt(String securitySalt) { this.securitySalt = securitySalt; }
public String getSecuritySalt() { return securitySalt; } public String getSecuritySalt() { return securitySalt; }
public void setSupportedChecksumAlgorithms(String supportedChecksumAlgorithms) { this.supportedChecksumAlgorithms = supportedChecksumAlgorithms; }
public String getSupportedChecksumAlgorithms() { return supportedChecksumAlgorithms; }
public void setAllowRequestsWithoutSession(Boolean allowRequestsWithoutSession) { public void setAllowRequestsWithoutSession(Boolean allowRequestsWithoutSession) {
this.allowRequestsWithoutSession = allowRequestsWithoutSession; this.allowRequestsWithoutSession = allowRequestsWithoutSession;
} }

View File

@ -26,7 +26,7 @@ public interface IBbbWebApiGWApp {
String moderatorPass, String viewerPass, String learningDashboardAccessToken, Long createTime, String moderatorPass, String viewerPass, String learningDashboardAccessToken, Long createTime,
String createDate, Boolean isBreakout, Integer sequence, Boolean freejoin, Map<String, String> metadata, String createDate, Boolean isBreakout, Integer sequence, Boolean freejoin, Map<String, String> metadata,
String guestPolicy, Boolean authenticatedGuest, String meetingLayout, String welcomeMsgTemplate, String welcomeMsg, String modOnlyMessage, String guestPolicy, Boolean authenticatedGuest, String meetingLayout, String welcomeMsgTemplate, String welcomeMsg, String modOnlyMessage,
String dialNumber, Integer maxUsers, String dialNumber, Integer maxUsers, Integer maxUserConcurrentAccesses,
Integer meetingExpireIfNoUserJoinedInMinutes, Integer meetingExpireIfNoUserJoinedInMinutes,
Integer meetingExpireWhenLastUserLeftInMinutes, Integer meetingExpireWhenLastUserLeftInMinutes,
Integer userInactivityInspectTimerInMinutes, Integer userInactivityInspectTimerInMinutes,
@ -50,8 +50,6 @@ public interface IBbbWebApiGWApp {
void registerUser(String meetingID, String internalUserId, String fullname, String role, void registerUser(String meetingID, String internalUserId, String fullname, String role,
String externUserID, String authToken, String avatarURL, String externUserID, String authToken, String avatarURL,
Boolean guest, Boolean authed, String guestStatus, Boolean excludeFromDashboard); Boolean guest, Boolean authed, String guestStatus, Boolean excludeFromDashboard);
void ejectDuplicateUser(String meetingID, String internalUserId, String fullname,
String externUserID);
void guestWaitingLeft(String meetingID, String internalUserId); void guestWaitingLeft(String meetingID, String internalUserId);
void destroyMeeting(DestroyMeetingMessage msg); void destroyMeeting(DestroyMeetingMessage msg);

View File

@ -21,6 +21,7 @@
* @version $Id: $ * @version $Id: $
*/ */
package org.bigbluebutton.presentation; package org.bigbluebutton.presentation;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;

View File

@ -50,7 +50,6 @@ public class SwfSlidesGenerationProgressNotifier {
maxUploadFileSize); maxUploadFileSize);
messagingService.sendDocConversionMsg(progress); messagingService.sendDocConversionMsg(progress);
} }
public void sendUploadFileTimedout(UploadedPresentation pres, int page) { public void sendUploadFileTimedout(UploadedPresentation pres, int page) {
UploadFileTimedoutMessage errorMessage = new UploadFileTimedoutMessage( UploadFileTimedoutMessage errorMessage = new UploadFileTimedoutMessage(
pres.getPodId(), pres.getPodId(),

View File

@ -131,7 +131,9 @@ class BbbWebApiGWApp(
freeJoin: java.lang.Boolean, freeJoin: java.lang.Boolean,
metadata: java.util.Map[String, String], guestPolicy: String, authenticatedGuest: java.lang.Boolean, meetingLayout: String, metadata: java.util.Map[String, String], guestPolicy: String, authenticatedGuest: java.lang.Boolean, meetingLayout: String,
welcomeMsgTemplate: String, welcomeMsg: String, modOnlyMessage: String, welcomeMsgTemplate: String, welcomeMsg: String, modOnlyMessage: String,
dialNumber: String, maxUsers: java.lang.Integer, dialNumber: String,
maxUsers: java.lang.Integer,
maxUserConcurrentAccesses: java.lang.Integer,
meetingExpireIfNoUserJoinedInMinutes: java.lang.Integer, meetingExpireIfNoUserJoinedInMinutes: java.lang.Integer,
meetingExpireWhenLastUserLeftInMinutes: java.lang.Integer, meetingExpireWhenLastUserLeftInMinutes: java.lang.Integer,
userInactivityInspectTimerInMinutes: java.lang.Integer, userInactivityInspectTimerInMinutes: java.lang.Integer,
@ -189,17 +191,23 @@ class BbbWebApiGWApp(
freeJoin = freeJoin.booleanValue(), freeJoin = freeJoin.booleanValue(),
breakoutRooms = Vector(), breakoutRooms = Vector(),
record = breakoutParams.record.booleanValue(), record = breakoutParams.record.booleanValue(),
privateChatEnabled = breakoutParams.privateChatEnabled.booleanValue() privateChatEnabled = breakoutParams.privateChatEnabled.booleanValue(),
captureNotes = breakoutParams.captureNotes.booleanValue(),
captureSlides = breakoutParams.captureSlides.booleanValue(),
) )
val welcomeProp = WelcomeProp(welcomeMsgTemplate = welcomeMsgTemplate, welcomeMsg = welcomeMsg, val welcomeProp = WelcomeProp(welcomeMsgTemplate = welcomeMsgTemplate, welcomeMsg = welcomeMsg,
modOnlyMessage = modOnlyMessage) modOnlyMessage = modOnlyMessage)
val voiceProp = VoiceProp(telVoice = voiceBridge, voiceConf = voiceBridge, dialNumber = dialNumber, muteOnStart = muteOnStart.booleanValue()) val voiceProp = VoiceProp(telVoice = voiceBridge, voiceConf = voiceBridge, dialNumber = dialNumber, muteOnStart = muteOnStart.booleanValue())
val usersProp = UsersProp(maxUsers = maxUsers.intValue(), webcamsOnlyForModerator = webcamsOnlyForModerator.booleanValue(), val usersProp = UsersProp(
maxUsers = maxUsers.intValue(),
maxUserConcurrentAccesses = maxUserConcurrentAccesses,
webcamsOnlyForModerator = webcamsOnlyForModerator.booleanValue(),
userCameraCap = userCameraCap.intValue(), userCameraCap = userCameraCap.intValue(),
guestPolicy = guestPolicy, meetingLayout = meetingLayout, allowModsToUnmuteUsers = allowModsToUnmuteUsers.booleanValue(), guestPolicy = guestPolicy, meetingLayout = meetingLayout, allowModsToUnmuteUsers = allowModsToUnmuteUsers.booleanValue(),
allowModsToEjectCameras = allowModsToEjectCameras.booleanValue(), allowModsToEjectCameras = allowModsToEjectCameras.booleanValue(),
authenticatedGuest = authenticatedGuest.booleanValue()) authenticatedGuest = authenticatedGuest.booleanValue()
)
val metadataProp = MetadataProp(mapAsScalaMap(metadata).toMap) val metadataProp = MetadataProp(mapAsScalaMap(metadata).toMap)
val lockSettingsProps = LockSettingsProps( val lockSettingsProps = LockSettingsProps(
@ -261,11 +269,6 @@ class BbbWebApiGWApp(
msgToAkkaAppsEventBus.publish(MsgToAkkaApps(toAkkaAppsChannel, event)) msgToAkkaAppsEventBus.publish(MsgToAkkaApps(toAkkaAppsChannel, event))
} }
def ejectDuplicateUser(meetingId: String, intUserId: String, name: String, extUserId: String): Unit = {
val event = MsgBuilder.buildEjectDuplicateUserRequestToAkkaApps(meetingId, intUserId, name, extUserId)
msgToAkkaAppsEventBus.publish(MsgToAkkaApps(toAkkaAppsChannel, event))
}
def guestWaitingLeft(meetingId: String, intUserId: String): Unit = { def guestWaitingLeft(meetingId: String, intUserId: String): Unit = {
val event = MsgBuilder.buildGuestWaitingLeftMsg(meetingId, intUserId) val event = MsgBuilder.buildGuestWaitingLeftMsg(meetingId, intUserId)
msgToAkkaAppsEventBus.publish(MsgToAkkaApps(toAkkaAppsChannel, event)) msgToAkkaAppsEventBus.publish(MsgToAkkaApps(toAkkaAppsChannel, event))

View File

@ -34,16 +34,6 @@ object MsgBuilder {
BbbCommonEnvCoreMsg(envelope, req) BbbCommonEnvCoreMsg(envelope, req)
} }
def buildEjectDuplicateUserRequestToAkkaApps(meetingId: String, intUserId: String, name: String, extUserId: String): BbbCommonEnvCoreMsg = {
val routing = collection.immutable.HashMap("sender" -> "bbb-web")
val envelope = BbbCoreEnvelope(EjectDuplicateUserReqMsg.NAME, routing)
val header = BbbCoreHeaderWithMeetingId(EjectDuplicateUserReqMsg.NAME, meetingId)
val body = EjectDuplicateUserReqMsgBody(meetingId = meetingId, intUserId = intUserId,
name = name, extUserId = extUserId)
val req = EjectDuplicateUserReqMsg(header, body)
BbbCommonEnvCoreMsg(envelope, req)
}
def buildRegisterUserRequestToAkkaApps(msg: RegisterUser): BbbCommonEnvCoreMsg = { def buildRegisterUserRequestToAkkaApps(msg: RegisterUser): BbbCommonEnvCoreMsg = {
val routing = collection.immutable.HashMap("sender" -> "bbb-web") val routing = collection.immutable.HashMap("sender" -> "bbb-web")
val envelope = BbbCoreEnvelope(RegisterUserReqMsg.NAME, routing) val envelope = BbbCoreEnvelope(RegisterUserReqMsg.NAME, routing)

View File

@ -12,7 +12,7 @@ case class CreateBreakoutRoomMsg(meetingId: String, parentMeetingId: String,
name: String, sequence: Integer, freeJoin: Boolean, dialNumber: String, name: String, sequence: Integer, freeJoin: Boolean, dialNumber: String,
voiceConfId: String, viewerPassword: String, moderatorPassword: String, duration: Int, voiceConfId: String, viewerPassword: String, moderatorPassword: String, duration: Int,
sourcePresentationId: String, sourcePresentationSlide: Int, sourcePresentationId: String, sourcePresentationSlide: Int,
record: Boolean) extends ApiMsg record: Boolean, captureNotes: Boolean, captureSlides: Boolean) extends ApiMsg
case class AddUserSession(token: String, session: UserSession) case class AddUserSession(token: String, session: UserSession)
case class RegisterUser(meetingId: String, intUserId: String, name: String, role: String, case class RegisterUser(meetingId: String, intUserId: String, name: String, role: String,

View File

@ -102,9 +102,11 @@ class OldMeetingMsgHdlrActor(val olgMsgGW: OldMessageReceivedGW)
msg.body.room.sourcePresentationId, msg.body.room.sourcePresentationId,
msg.body.room.sourcePresentationSlide, msg.body.room.sourcePresentationSlide,
msg.body.room.record, msg.body.room.record,
msg.body.room.privateChatEnabled msg.body.room.privateChatEnabled,
msg.body.room.captureNotes,
msg.body.room.captureSlides,
)) ))
} }
def handleRecordingStatusChangedEvtMsg(msg: RecordingStatusChangedEvtMsg): Unit = { def handleRecordingStatusChangedEvtMsg(msg: RecordingStatusChangedEvtMsg): Unit = {

View File

@ -10,6 +10,9 @@
"imagemagick": "/usr/bin/convert", "imagemagick": "/usr/bin/convert",
"pdftocairo": "/usr/bin/pdftocairo" "pdftocairo": "/usr/bin/pdftocairo"
}, },
"captureNotes": {
"timeout": 5000
},
"collector": { "collector": {
"pngWidthRasterizedSlides": 2560 "pngWidthRasterizedSlides": 2560
}, },
@ -25,6 +28,7 @@
"msgName": "NewPresAnnFileAvailableMsg" "msgName": "NewPresAnnFileAvailableMsg"
}, },
"bbbWebAPI": "http://127.0.0.1:8090", "bbbWebAPI": "http://127.0.0.1:8090",
"bbbPadsAPI": "http://127.0.0.1:9002",
"redis": { "redis": {
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 6379, "port": 6379,

View File

@ -1,24 +1,32 @@
const Logger = require('../lib/utils/logger'); const Logger = require('../lib/utils/logger');
const axios = require('axios').default;
const config = require('../config'); const config = require('../config');
const fs = require('fs');
const redis = require('redis');
const {Worker, workerData} = require('worker_threads');
const path = require('path');
const cp = require('child_process'); const cp = require('child_process');
const fs = require('fs');
const path = require('path');
const redis = require('redis');
const sanitize = require('sanitize-filename');
const stream = require('stream');
const {Worker, workerData} = require('worker_threads');
const {promisify} = require('util');
const WorkerTypes = Object.freeze({
Notifier: 'notifier',
Process: 'process',
});
const jobId = workerData; const jobId = workerData;
const logger = new Logger('presAnn Collector'); const logger = new Logger('presAnn Collector');
logger.info('Collecting job ' + jobId); logger.info(`Collecting job ${jobId}`);
const kickOffProcessWorker = (jobId) => { const kickOffWorker = (workerType, data) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const worker = new Worker('./workers/process.js', {workerData: jobId}); const worker = new Worker(`./workers/${workerType}.js`, {workerData: data});
worker.on('message', resolve); worker.on('message', resolve);
worker.on('error', reject); worker.on('error', reject);
worker.on('exit', (code) => { worker.on('exit', (code) => {
if (code !== 0) { if (code !== 0) {
reject(new Error(`Process Worker stopped with exit code ${code}`)); reject(new Error(`Worker '${workerType}' stopped with exit code ${code}`));
} }
}); });
}); });
@ -30,8 +38,7 @@ const dropbox = path.join(config.shared.presAnnDropboxDir, jobId);
const job = fs.readFileSync(path.join(dropbox, 'job')); const job = fs.readFileSync(path.join(dropbox, 'job'));
const exportJob = JSON.parse(job); const exportJob = JSON.parse(job);
// Collect the annotations from Redis async function collectAnnotationsFromRedis() {
(async () => {
const client = redis.createClient({ const client = redis.createClient({
host: config.redis.host, host: config.redis.host,
port: config.redis.port, port: config.redis.port,
@ -42,7 +49,7 @@ const exportJob = JSON.parse(job);
await client.connect(); await client.connect();
const presAnn = await client.hGetAll(exportJob.jobId); const presAnn = await client.hGetAll(jobId);
// Remove annotations from Redis // Remove annotations from Redis
await client.del(jobId); await client.del(jobId);
@ -95,8 +102,66 @@ const exportJob = JSON.parse(job);
} else if (fs.existsSync(`${presFile}.jpeg`)) { } else if (fs.existsSync(`${presFile}.jpeg`)) {
fs.copyFileSync(`${presFile}.jpeg`, path.join(dropbox, 'slide1.jpeg')); fs.copyFileSync(`${presFile}.jpeg`, path.join(dropbox, 'slide1.jpeg'));
} else { } else {
return logger.error(`Could not find presentation file ${exportJob.jobId}`); return logger.error(`Could not find presentation file ${jobId}`);
} }
kickOffProcessWorker(exportJob.jobId); kickOffWorker(WorkerTypes.Process, jobId);
})(); }
async function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
/** Export shared notes via bbb-pads in the desired format
* @param {Integer} retries - Number of retries to get the shared notes
*/
async function collectSharedNotes(retries) {
/** One of the following formats is supported:
etherpad / html / pdf / txt / doc / odf */
const padId = exportJob.presId;
const notesFormat = 'pdf';
const filename = `${sanitize(exportJob.filename.replace(/\s/g, '_'))}.${notesFormat}`;
const notes_endpoint = `${config.bbbPadsAPI}/p/${padId}/export/${notesFormat}`;
const filePath = path.join(dropbox, filename);
const [sequence] = JSON.parse(exportJob.pages);
const timeout = (sequence - 1) * config.captureNotes.timeout;
// Wait for the bbb-pads API to be available
await sleep(timeout);
const finishedDownload = promisify(stream.finished);
const writer = fs.createWriteStream(filePath);
try {
const response = await axios({
method: 'GET',
url: notes_endpoint,
responseType: 'stream',
timeout: timeout,
});
response.data.pipe(writer);
await finishedDownload(writer);
} catch (err) {
if (retries > 0) {
logger.info(`Retrying ${jobId} in ${timeout}ms...`);
return collectSharedNotes(retries - 1);
} else {
logger.error(`Could not download notes in job ${jobId}`);
return;
}
}
kickOffWorker(WorkerTypes.Notifier, [exportJob.jobType, jobId, filename]);
}
switch (exportJob.jobType) {
case 'PresentationWithAnnotationExportJob': return collectAnnotationsFromRedis();
case 'PresentationWithAnnotationDownloadJob': return collectAnnotationsFromRedis();
case 'PadCaptureJob': return collectSharedNotes(3);
default: return logger.error(`Unknown job type ${exportJob.jobType}`);
}

View File

@ -8,7 +8,7 @@ const path = require('path');
const {workerData} = require('worker_threads'); const {workerData} = require('worker_threads');
const [jobType, jobId, filename_with_extension] = workerData; const [jobType, jobId, filename] = workerData;
const logger = new Logger('presAnn Notifier Worker'); const logger = new Logger('presAnn Notifier Worker');
@ -30,7 +30,7 @@ async function notifyMeetingActor() {
const link = path.join(`${path.sep}bigbluebutton`, 'presentation', const link = path.join(`${path.sep}bigbluebutton`, 'presentation',
exportJob.parentMeetingId, exportJob.parentMeetingId, exportJob.parentMeetingId, exportJob.parentMeetingId,
exportJob.presId, 'pdf', jobId, filename_with_extension); exportJob.presId, 'pdf', jobId, filename);
const notification = { const notification = {
envelope: { envelope: {
@ -59,27 +59,35 @@ async function notifyMeetingActor() {
client.disconnect(); client.disconnect();
} }
/** Upload PDF to a BBB room */ /** Upload PDF to a BBB room
async function upload() { * @param {String} filePath - Absolute path to the file, including the extension
*/
async function upload(filePath) {
const callbackUrl = `${config.bbbWebAPI}/bigbluebutton/presentation/${exportJob.presentationUploadToken}/upload`; const callbackUrl = `${config.bbbWebAPI}/bigbluebutton/presentation/${exportJob.presentationUploadToken}/upload`;
const formData = new FormData(); const formData = new FormData();
const file = `${exportJob.presLocation}/pdfs/${jobId}/${filename_with_extension}`;
formData.append('conference', exportJob.parentMeetingId); formData.append('conference', exportJob.parentMeetingId);
formData.append('pod_id', config.notifier.pod_id); formData.append('pod_id', config.notifier.pod_id);
formData.append('is_downloadable', config.notifier.is_downloadable); formData.append('is_downloadable', config.notifier.is_downloadable);
formData.append('temporaryPresentationId', jobId); formData.append('temporaryPresentationId', jobId);
formData.append('fileUpload', fs.createReadStream(file)); formData.append('fileUpload', fs.createReadStream(filePath));
const res = await axios.post(callbackUrl, formData, try {
{headers: formData.getHeaders()}); const res = await axios.post(callbackUrl, formData,
logger.info(`Upload of job ${exportJob.jobId} returned ${res.data}`); {headers: formData.getHeaders()});
logger.info(`Upload of job ${exportJob.jobId} returned ${res.data}`);
} catch (error) {
return logger.error(`Could upload job ${exportJob.jobId}: ${error}`);
}
} }
if (jobType == 'PresentationWithAnnotationDownloadJob') { if (jobType == 'PresentationWithAnnotationDownloadJob') {
notifyMeetingActor(); notifyMeetingActor();
} else if (jobType == 'PresentationWithAnnotationExportJob') { } else if (jobType == 'PresentationWithAnnotationExportJob') {
upload(); const filePath = `${exportJob.presLocation}/pdfs/${jobId}/${filename}`;
upload(filePath);
} else if (jobType == 'PadCaptureJob') {
const filePath = `${dropbox}/${filename}`;
upload(filePath);
} else { } else {
logger.error(`Notifier received unknown job type ${jobType}`); logger.error(`Notifier received unknown job type ${jobType}`);
} }

View File

@ -6,7 +6,7 @@ const cp = require('child_process');
const {Worker, workerData} = require('worker_threads'); const {Worker, workerData} = require('worker_threads');
const path = require('path'); const path = require('path');
const sanitize = require('sanitize-filename'); const sanitize = require('sanitize-filename');
const {getStroke, getStrokePoints} = require('perfect-freehand'); const {getStrokePoints, getStrokeOutlinePoints} = require('perfect-freehand');
const probe = require('probe-image-size'); const probe = require('probe-image-size');
const jobId = workerData; const jobId = workerData;
@ -193,17 +193,17 @@ function get_gap(dash, size) {
function get_stroke_width(dash, size) { function get_stroke_width(dash, size) {
switch (size) { switch (size) {
case 'small': if (dash === 'draw') { case 'small': if (dash === 'draw') {
return 1; return 2;
} else { } else {
return 4; return 4;
} }
case 'medium': if (dash === 'draw') { case 'medium': if (dash === 'draw') {
return 1.75; return 3.5;
} else { } else {
return 6.25; return 6.25;
} }
case 'large': if (dash === 'draw') { case 'large': if (dash === 'draw') {
return 2.5; return 5;
} else { } else {
return 8.5; return 8.5;
} }
@ -236,61 +236,26 @@ function text_size_to_px(size, scale = 1, isStickyNote = false) {
} }
} }
// Methods based on tldraw's utilities /**
function getPath(annotationPoints) { * Turns an array of points into a path of quadradic curves.
// Gets inner path of a stroke outline * @param {Array} annotationPoints
// For solid, dashed, and dotted types * @param {Boolean} closed - whether the path end and start should be connected (default)
const stroke = getStrokePoints(annotationPoints) * @return {Array} - an SVG quadratic curve path
.map((strokePoint) => strokePoint.point); */
function getSvgPath(annotationPoints, closed = true) {
let [max_x, max_y] = [0, 0]; const svgPath = annotationPoints.reduce(
const inner_path = stroke.reduce(
(acc, [x0, y0], i, arr) => { (acc, [x0, y0], i, arr) => {
if (!arr[i + 1]) return acc; if (!arr[i + 1]) return acc;
const [x1, y1] = arr[i + 1]; const [x1, y1] = arr[i + 1];
if (x1 >= max_x) { acc.push(x0.toFixed(2), y0.toFixed(2), ((x0 + x1) / 2).toFixed(2), ((y0 + y1) / 2).toFixed(2));
max_x = x1;
}
if (y1 >= max_y) {
max_y = y1;
}
acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2);
return acc; return acc;
}, },
['M', ...stroke[0], 'Q'], ['M', ...annotationPoints[0], 'Q'],
); );
return [inner_path, max_x, max_y]; if (closed) svgPath.push('Z');
} return svgPath;
function getOutlinePath(annotationPoints) {
// Gets outline of a hand-drawn input, with pressure
const stroke = getStroke(annotationPoints, {
simulatePressure: true,
size: 8,
});
let [max_x, max_y] = [0, 0];
const outline_path = stroke.reduce(
(acc, [x0, y0], i, arr) => {
const [x1, y1] = arr[(i + 1) % arr.length];
if (x1 >= max_x) {
max_x = x1;
}
if (y1 >= max_y) {
max_y = y1;
}
acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2);
return acc;
},
['M', ...stroke[0], 'Q'],
);
outline_path.push('Z');
return [outline_path, max_x, max_y];
} }
function circleFromThreePoints(A, B, C) { function circleFromThreePoints(A, B, C) {
@ -471,49 +436,94 @@ function overlay_arrow(svg, annotation) {
} }
function overlay_draw(svg, annotation) { function overlay_draw(svg, annotation) {
const shapePoints = annotation.points;
const shapePointsLength = shapePoints.length;
if (shapePointsLength < 2) return;
const dash = annotation.style.dash; const dash = annotation.style.dash;
const [calculated_path, max_x, max_y] = (dash == 'draw') ? getOutlinePath(annotation.points) : getPath(annotation.points); const isDashDraw = (dash == 'draw');
if (!calculated_path.length) return;
const shapeColor = color_to_hex(annotation.style.color);
const rotation = rad_to_degree(annotation.rotation);
const thickness = get_stroke_width(dash, annotation.style.size); const thickness = get_stroke_width(dash, annotation.style.size);
const gap = get_gap(dash, annotation.style.size); const gap = get_gap(dash, annotation.style.size);
const [x, y] = annotation.point;
const stroke_dasharray = determine_dasharray(dash, gap); const stroke_dasharray = determine_dasharray(dash, gap);
const fill = (dash === 'draw') ? shapeColor : 'none';
const shapeColor = color_to_hex(annotation.style.color);
const shapeFillColor = color_to_hex(`fill-${annotation.style.color}`); const shapeFillColor = color_to_hex(`fill-${annotation.style.color}`);
const shapeTransform = `translate(${x} ${y}), rotate(${rotation} ${max_x / 2} ${max_y / 2})`; const fill = isDashDraw ? shapeColor : 'none';
// Fill assuming solid, small pencil used const rotation = rad_to_degree(annotation.rotation);
// when path start- and end points overlap const [x, y] = annotation.point;
const shapeIsFilled = const [width, height] = annotation.size;
const shapeTransform = `translate(${x} ${y}), rotate(${rotation} ${width / 2} ${height / 2})`;
const simulatePressure = {
easing: (t) => Math.sin((t * Math.PI) / 2),
simulatePressure: true,
};
const realPressure = {
easing: (t) => t * t,
simulatePressure: false,
};
const options = {
size: 1 + thickness * 1.5,
thinning: 0.65,
streamline: 0.65,
smoothing: 0.65,
...(shapePoints[1][2] === 0.5 ? simulatePressure : realPressure),
last: annotation.isComplete,
};
const strokePoints = getStrokePoints(shapePoints, options);
// Fill when path start- and end points overlap
const isShapeFilled =
annotation.style.isFilled && annotation.style.isFilled &&
annotation.points.length > 3 && shapePointsLength > 3 &&
Math.round(distance( Math.round(distance(
annotation.points[0][0], shapePoints[0][0],
annotation.points[0][1], shapePoints[0][1],
annotation.points[annotation.points.length - 1][0], shapePoints[shapePointsLength - 1][0],
annotation.points[annotation.points.length - 1][1], shapePoints[shapePointsLength - 1][1],
)) <= 2 * get_stroke_width('solid', 'small'); )) <= 2 * thickness;
if (shapeIsFilled) { if (isShapeFilled) {
const shapeArea = strokePoints.map((strokePoint) => strokePoint.point);
svg.ele('path', { svg.ele('path', {
style: `fill:${shapeFillColor};`, style: `fill:${shapeFillColor};`,
d: getPath(annotation.points)[0] + 'Z', d: getSvgPath(shapeArea),
transform: shapeTransform, transform: shapeTransform,
}).up(); }).up();
} }
svg.ele('path', { if (isDashDraw) {
style: `stroke:${shapeColor};stroke-width:${thickness};fill:${fill};${stroke_dasharray}`, const strokeOutlinePoints = getStrokeOutlinePoints(strokePoints, options);
d: calculated_path, const svgPath = getSvgPath(strokeOutlinePoints);
transform: shapeTransform,
}); svg.ele('path', {
style: `fill:${fill};${stroke_dasharray}`,
d: svgPath,
transform: shapeTransform,
});
} else {
const last = shapePoints[shapePointsLength - 1];
// Avoid single dots from not being drawn
if (strokePoints[0].point[0] == last[0] && strokePoints[0].point[1] == last[1]) {
strokePoints.push({point: last});
}
const solidPath = strokePoints.map((strokePoint) => strokePoint.point);
const svgPath = getSvgPath(solidPath, false);
svg.ele('path', {
style: `stroke:${shapeColor};stroke-width:${thickness};fill:${fill};${stroke_dasharray}`,
d: svgPath,
transform: shapeTransform,
});
}
} }
function overlay_ellipse(svg, annotation) { function overlay_ellipse(svg, annotation) {
@ -603,19 +613,23 @@ function overlay_shape_label(svg, annotation) {
render_textbox(fontColor, font, fontSize, textAlign, text, id); render_textbox(fontColor, font, fontSize, textAlign, text, id);
const dimensions = probe.sync(fs.readFileSync(path.join(dropbox, `text${id}.png`))); const shape_label = path.join(dropbox, `text${id}.png`);
const labelWidth = dimensions.width / config.process.textScaleFactor;
const labelHeight = dimensions.height / config.process.textScaleFactor;
svg.ele('g', { if (fs.existsSync(shape_label)) {
transform: `rotate(${rotation} ${label_center_x} ${label_center_y})`, const dimensions = probe.sync(fs.readFileSync(shape_label));
}).ele('image', { const labelWidth = dimensions.width / config.process.textScaleFactor;
'x': label_center_x - (labelWidth * x_offset), const labelHeight = dimensions.height / config.process.textScaleFactor;
'y': label_center_y - (labelHeight * y_offset),
'width': labelWidth, svg.ele('g', {
'height': labelHeight, transform: `rotate(${rotation} ${label_center_x} ${label_center_y})`,
'xlink:href': `file://${dropbox}/text${id}.png`, }).ele('image', {
}).up(); 'x': label_center_x - (labelWidth * x_offset),
'y': label_center_y - (labelHeight * y_offset),
'width': labelWidth,
'height': labelHeight,
'xlink:href': `file://${dropbox}/text${id}.png`,
}).up();
}
} }
function overlay_sticky(svg, annotation) { function overlay_sticky(svg, annotation) {
@ -712,32 +726,30 @@ function overlay_text(svg, annotation) {
} }
function overlay_annotation(svg, currentAnnotation) { function overlay_annotation(svg, currentAnnotation) {
if (currentAnnotation.childIndex >= 1) { switch (currentAnnotation.type) {
switch (currentAnnotation.type) { case 'arrow':
case 'arrow': overlay_arrow(svg, currentAnnotation);
overlay_arrow(svg, currentAnnotation); break;
break; case 'draw':
case 'draw': overlay_draw(svg, currentAnnotation);
overlay_draw(svg, currentAnnotation); break;
break; case 'ellipse':
case 'ellipse': overlay_ellipse(svg, currentAnnotation);
overlay_ellipse(svg, currentAnnotation); break;
break; case 'rectangle':
case 'rectangle': overlay_rectangle(svg, currentAnnotation);
overlay_rectangle(svg, currentAnnotation); break;
break; case 'sticky':
case 'sticky': overlay_sticky(svg, currentAnnotation);
overlay_sticky(svg, currentAnnotation); break;
break; case 'triangle':
case 'triangle': overlay_triangle(svg, currentAnnotation);
overlay_triangle(svg, currentAnnotation); break;
break; case 'text':
case 'text': overlay_text(svg, currentAnnotation);
overlay_text(svg, currentAnnotation); break;
break; default:
default: logger.info(`Unknown annotation type ${currentAnnotation.type}.`);
logger.info(`Unknown annotation type ${currentAnnotation.type}.`);
}
} }
} }

View File

@ -51,6 +51,44 @@ class PollsTable extends React.Component {
); );
} }
// Here we count each poll vote in order to find out the most common answer.
const pollVotesCount = Object.keys(polls || {}).reduce((prevPollVotesCount, pollId) => {
const currPollVotesCount = { ...prevPollVotesCount };
currPollVotesCount[pollId] = {};
if (polls[pollId].anonymous) {
polls[pollId].anonymousAnswers.forEach((answer) => {
const answerLowerCase = answer.toLowerCase();
if (currPollVotesCount[pollId][answerLowerCase] === undefined) {
currPollVotesCount[pollId][answerLowerCase] = 1;
} else {
currPollVotesCount[pollId][answerLowerCase] += 1;
}
});
return currPollVotesCount;
}
Object.values(allUsers).forEach((currUser) => {
if (currUser.answers[pollId] !== undefined) {
const userAnswers = Array.isArray(currUser.answers[pollId])
? currUser.answers[pollId]
: [currUser.answers[pollId]];
userAnswers.forEach((answer) => {
const answerLowerCase = answer.toLowerCase();
if (currPollVotesCount[pollId][answerLowerCase] === undefined) {
currPollVotesCount[pollId][answerLowerCase] = 1;
} else {
currPollVotesCount[pollId][answerLowerCase] += 1;
}
});
}
});
return currPollVotesCount;
}, {});
return ( return (
<table className="w-full"> <table className="w-full">
<thead> <thead>
@ -104,7 +142,16 @@ class PollsTable extends React.Component {
.sort((a, b) => ((a.createdOn > b.createdOn) ? 1 : -1)) .sort((a, b) => ((a.createdOn > b.createdOn) ? 1 : -1))
.map((poll) => ( .map((poll) => (
<td className="px-4 py-3 text-sm text-center"> <td className="px-4 py-3 text-sm text-center">
{ getUserAnswer(user, poll).map((answer) => <p>{answer}</p>) } { getUserAnswer(user, poll).map((answer) => {
const answersSorted = Object
.entries(pollVotesCount[poll?.pollId])
.sort(([, countA], [, countB]) => countB - countA);
const isMostCommonAnswer = (
answersSorted[0]?.[0]?.toLowerCase() === answer?.toLowerCase()
&& answersSorted[0]?.[1] > 1
);
return <p className={isMostCommonAnswer ? 'font-bold' : ''}>{answer}</p>;
}) }
{ poll.anonymous { poll.anonymous
? ( ? (
<span title={intl.formatMessage({ <span title={intl.formatMessage({

View File

@ -1 +1 @@
git clone --branch v1.3.0 --depth 1 https://github.com/bigbluebutton/bbb-pads bbb-pads git clone --branch v1.3.2 --depth 1 https://github.com/bigbluebutton/bbb-pads bbb-pads

View File

@ -1 +1 @@
git clone --branch v5.0.0-alpha.2 --depth 1 https://github.com/bigbluebutton/bbb-playback bbb-playback git clone --branch v5.0.0-alpha.3 --depth 1 https://github.com/bigbluebutton/bbb-playback bbb-playback

View File

@ -1,9 +0,0 @@
<include>
<extension name="bbb_sp_call" continue="true">
<condition field="network_addr" expression="${domain}" break="on-false">
<action application="set" data="bbb_authorized=true"/>
<action application="transfer" data="${destination_number} XML default"/>
</condition>
</extension>
</include>

View File

@ -1 +1 @@
git clone --branch v2.9.1 --depth 1 https://github.com/bigbluebutton/bbb-webrtc-sfu bbb-webrtc-sfu git clone --branch v2.9.2 --depth 1 https://github.com/bigbluebutton/bbb-webrtc-sfu bbb-webrtc-sfu

View File

@ -1249,19 +1249,34 @@ check_state() {
if [ ! -z "$STUN" ]; then if [ ! -z "$STUN" ]; then
for i in $STUN; do for i in $STUN; do
STUN_SERVER="$(xmlstarlet sel -N x="http://www.springframework.org/schema/beans" -t -m "_:beans/_:bean[@id=\"$i\"]/_:constructor-arg[@index=\"0\"]" -v @value $TURN | sed 's/stun://g')" STUN_SERVER="$(xmlstarlet sel -N x="http://www.springframework.org/schema/beans" -t -m "_:beans/_:bean[@id=\"$i\"]/_:constructor-arg[@index=\"0\"]" -v @value $TURN | sed 's/stun://g')"
if echo $STUN_SERVER | grep -q ':'; then
STUN_SERVER="$(echo $STUN_SERVER | sed 's/:.*//g') $(echo $STUN_SERVER | sed 's/.*://g')"
else
STUN_SERVER="$STUN_SERVER 3478"
fi
if which stunclient > /dev/null 2>&1; then # stun is from the stun-client package, which is available on both bionic and focal
if stunclient --mode full --localport 30000 $STUN_SERVER | grep -q "fail\|Unable\ to\ resolve"; then # stunclient is from the stuntman-client package, which is available on bionic but was removed from focal
if which stun > /dev/null 2>&1; then
# stun return codes, from its client.cxx
# low nibble: open (0), various STUN combinations (2-9), firewall (a), blocked (c), unknown (e), error (f)
# high nibble: hairpin (1)
stun $STUN_SERVER > /dev/null
if (( ($? & 0xf) > 9 )); then
echo echo
echo "#" echo "#"
echo "# Warning: Failed to verify STUN server at $STUN_SERVER with command" echo "# Warning: Failed to verify STUN server at $STUN_SERVER with command"
echo "#" echo "#"
echo "# stunclient --mode full --localport 30000 $STUN_SERVER" echo "# stun $STUN_SERVER"
echo "#"
fi
elif which stunclient > /dev/null 2>&1; then
if echo $STUN_SERVER | grep -q ':'; then
STUN_SERVER="$(echo $STUN_SERVER | sed 's/:.*//g') $(echo $STUN_SERVER | sed 's/.*://g')"
else
STUN_SERVER="$STUN_SERVER 3478"
fi
if stunclient $STUN_SERVER | grep -q "fail\|Unable\ to\ resolve"; then
echo
echo "#"
echo "# Warning: Failed to verify STUN server at $STUN_SERVER with command"
echo "#"
echo "# stunclient $STUN_SERVER"
echo "#" echo "#"
fi fi
fi fi

View File

@ -1,20 +1,27 @@
import { check } from 'meteor/check'; import { check } from 'meteor/check';
import _ from "lodash";
export default function addAnnotation(meetingId, whiteboardId, userId, annotation) { export default function addAnnotation(meetingId, whiteboardId, userId, annotation, Annotations) {
check(meetingId, String); check(meetingId, String);
check(whiteboardId, String); check(whiteboardId, String);
check(annotation, Object); check(annotation, Object);
const { const {
id, annotationInfo, wbId, id, wbId,
} = annotation; } = annotation;
let { annotationInfo } = annotation;
const selector = { const selector = {
meetingId, meetingId,
id, id,
userId,
}; };
const oldAnnotation = Annotations.findOne(selector);
if (oldAnnotation) {
annotationInfo = _.merge(oldAnnotation.annotationInfo, annotationInfo)
}
const modifier = { const modifier = {
$set: { $set: {
whiteboardId, whiteboardId,

View File

@ -8,7 +8,7 @@ export default function addAnnotation(meetingId, whiteboardId, userId, annotatio
check(whiteboardId, String); check(whiteboardId, String);
check(annotation, Object); check(annotation, Object);
const query = addAnnotationQuery(meetingId, whiteboardId, userId, annotation); const query = addAnnotationQuery(meetingId, whiteboardId, userId, annotation, Annotations);
try { try {
const { insertedId } = Annotations.upsert(query.selector, query.modifier); const { insertedId } = Annotations.upsert(query.selector, query.modifier);

View File

@ -1,6 +1,6 @@
import Settings from '/imports/ui/services/settings'; import Settings from '/imports/ui/services/settings';
import logger from '/imports/startup/client/logger'; import logger from '/imports/startup/client/logger';
import Storage from '/imports/ui/services/storage/session'; import BBBStorage from '/imports/ui/services/storage';
const AUDIO_SESSION_NUM_KEY = 'AudioSessionNumber'; const AUDIO_SESSION_NUM_KEY = 'AudioSessionNumber';
const DEFAULT_INPUT_DEVICE_ID = ''; const DEFAULT_INPUT_DEVICE_ID = '';
@ -38,10 +38,10 @@ const getCurrentAudioSinkId = () => {
return audioElement?.sinkId || DEFAULT_OUTPUT_DEVICE_ID; return audioElement?.sinkId || DEFAULT_OUTPUT_DEVICE_ID;
}; };
const getStoredAudioInputDeviceId = () => Storage.getItem(INPUT_DEVICE_ID_KEY); const getStoredAudioInputDeviceId = () => BBBStorage.getItem(INPUT_DEVICE_ID_KEY);
const getStoredAudioOutputDeviceId = () => Storage.getItem(OUTPUT_DEVICE_ID_KEY); const getStoredAudioOutputDeviceId = () => BBBStorage.getItem(OUTPUT_DEVICE_ID_KEY);
const storeAudioInputDeviceId = (deviceId) => Storage.setItem(INPUT_DEVICE_ID_KEY, deviceId); const storeAudioInputDeviceId = (deviceId) => BBBStorage.setItem(INPUT_DEVICE_ID_KEY, deviceId);
const storeAudioOutputDeviceId = (deviceId) => Storage.setItem(OUTPUT_DEVICE_ID_KEY, deviceId); const storeAudioOutputDeviceId = (deviceId) => BBBStorage.setItem(OUTPUT_DEVICE_ID_KEY, deviceId);
/** /**
* Filter constraints set in audioDeviceConstraints, based on * Filter constraints set in audioDeviceConstraints, based on

View File

@ -4,7 +4,7 @@ import Logger from '/imports/startup/server/logger';
import { extractCredentials } from '/imports/api/common/server/helpers'; import { extractCredentials } from '/imports/api/common/server/helpers';
import { check } from 'meteor/check'; import { check } from 'meteor/check';
export default function createBreakoutRoom(rooms, durationInMinutes, record = false) { export default function createBreakoutRoom(rooms, durationInMinutes, record = false, captureNotes = false, captureSlides = false) {
const REDIS_CONFIG = Meteor.settings.private.redis; const REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps; const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const BREAKOUT_LIM = Meteor.settings.public.app.breakouts.breakoutRoomLimit; const BREAKOUT_LIM = Meteor.settings.public.app.breakouts.breakoutRoomLimit;
@ -24,6 +24,8 @@ export default function createBreakoutRoom(rooms, durationInMinutes, record = fa
} }
const payload = { const payload = {
record, record,
captureNotes,
captureSlides,
durationInMinutes, durationInMinutes,
rooms, rooms,
meetingId, meetingId,

View File

@ -74,6 +74,8 @@ export default function addMeeting(meeting) {
parentId: String, parentId: String,
record: Boolean, record: Boolean,
privateChatEnabled: Boolean, privateChatEnabled: Boolean,
captureNotes: Boolean,
captureSlides: Boolean,
}, },
meetingProp: { meetingProp: {
intId: String, intId: String,
@ -88,11 +90,12 @@ export default function addMeeting(meeting) {
uploadExternalUrl: String, uploadExternalUrl: String,
}, },
usersProp: { usersProp: {
maxUsers: Number,
maxUserConcurrentAccesses: Number,
webcamsOnlyForModerator: Boolean, webcamsOnlyForModerator: Boolean,
userCameraCap: Number, userCameraCap: Number,
guestPolicy: String, guestPolicy: String,
authenticatedGuest: Boolean, authenticatedGuest: Boolean,
maxUsers: Number,
allowModsToUnmuteUsers: Boolean, allowModsToUnmuteUsers: Boolean,
allowModsToEjectCameras: Boolean, allowModsToEjectCameras: Boolean,
meetingLayout: String, meetingLayout: String,

View File

@ -6,6 +6,7 @@ import padUpdated from './handlers/padUpdated';
import padContent from './handlers/padContent'; import padContent from './handlers/padContent';
import padTail from './handlers/padTail'; import padTail from './handlers/padTail';
import sessionDeleted from './handlers/sessionDeleted'; import sessionDeleted from './handlers/sessionDeleted';
import captureSharedNotes from './handlers/captureSharedNotes';
RedisPubSub.on('PadGroupCreatedRespMsg', groupCreated); RedisPubSub.on('PadGroupCreatedRespMsg', groupCreated);
RedisPubSub.on('PadCreatedRespMsg', padCreated); RedisPubSub.on('PadCreatedRespMsg', padCreated);
@ -14,3 +15,4 @@ RedisPubSub.on('PadUpdatedEvtMsg', padUpdated);
RedisPubSub.on('PadContentEvtMsg', padContent); RedisPubSub.on('PadContentEvtMsg', padContent);
RedisPubSub.on('PadTailEvtMsg', padTail); RedisPubSub.on('PadTailEvtMsg', padTail);
RedisPubSub.on('PadSessionDeletedEvtMsg', sessionDeleted); RedisPubSub.on('PadSessionDeletedEvtMsg', sessionDeleted);
RedisPubSub.on('CaptureSharedNotesReqEvtMsg', captureSharedNotes);

View File

@ -0,0 +1,15 @@
import { check } from 'meteor/check';
import padCapture from '../methods/padCapture';
export default function captureSharedNotes({ body }, meetingId) {
check(body, Object);
check(meetingId, String);
const { parentMeetingId, meetingName, sequence } = body;
check(parentMeetingId, String);
check(meetingName, String);
check(sequence, Number);
padCapture(meetingId, parentMeetingId, meetingName, sequence);
}

View File

@ -0,0 +1,48 @@
import { check } from 'meteor/check';
import Pads from '/imports/api/pads';
import RedisPubSub from '/imports/startup/server/redis';
import Logger from '/imports/startup/server/logger';
export default function padCapture(meetingId, parentMeetingId, meetingName, sequence) {
const REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'PadCapturePubMsg';
const EXTERNAL_ID = Meteor.settings.public.notes.id;
try {
check(meetingId, String);
check(parentMeetingId, String);
check(meetingName, String);
check(sequence, Number);
const pad = Pads.findOne(
{
meetingId,
externalId: EXTERNAL_ID,
},
{
fields: {
padId: 1,
},
},
);
const payload = {
parentMeetingId,
breakoutId: meetingId,
padId: pad.padId,
meetingName,
sequence,
};
Logger.info(`Sending PadCapturePubMsg for meetingId=${meetingId} parentMeetingId=${parentMeetingId} padId=${pad.padId}`);
if (pad && pad.padId) {
return RedisPubSub.publishMeetingMessage(CHANNEL, EVENT_NAME, parentMeetingId, payload);
}
return null;
} catch (err) {
Logger.error(`Exception while invoking method padCapture ${err.stack}`);
return null;
}
}

View File

@ -256,21 +256,12 @@ class Base extends Component {
meetingEndedReason, meetingEndedReason,
meetingIsBreakout, meetingIsBreakout,
subscriptionsReady, subscriptionsReady,
User,
} = this.props; } = this.props;
if ((loading || !subscriptionsReady) && !meetingHasEnded && meetingExist) { if ((loading || !subscriptionsReady) && !meetingHasEnded && meetingExist) {
return (<LoadingScreen>{loading}</LoadingScreen>); return (<LoadingScreen>{loading}</LoadingScreen>);
} }
if (meetingIsBreakout && (ejected || userRemoved)) {
Base.setExitReason('removedFromBreakout').finally(() => {
Meteor.disconnect();
window.close();
});
return null;
}
if (ejected) { if (ejected) {
return ( return (
<MeetingEnded <MeetingEnded
@ -281,21 +272,19 @@ class Base extends Component {
); );
} }
if ((meetingHasEnded || User?.loggedOut) && meetingIsBreakout) { if (meetingHasEnded && meetingIsBreakout) {
const reason = meetingHasEnded ? 'breakoutEnded' : 'logout'; Base.setExitReason('breakoutEnded').finally(() => {
Base.setExitReason(reason).finally(() => {
Meteor.disconnect(); Meteor.disconnect();
window.close(); window.close();
}); });
return null; return null;
} }
if ((meetingHasEnded && !meetingIsBreakout) || (codeError && User?.loggedOut)) { if (meetingHasEnded && !meetingIsBreakout) {
return ( return (
<MeetingEnded <MeetingEnded
code={codeError} code={codeError}
endedReason={meetingEndedReason} endedReason={meetingEndedReason}
ejectedReason={ejectedReason}
callback={() => Base.setExitReason('meetingEnded')} callback={() => Base.setExitReason('meetingEnded')}
/> />
); );

View File

@ -9,6 +9,7 @@ import _ from 'lodash';
import { Session } from 'meteor/session'; import { Session } from 'meteor/session';
import Logger from '/imports/startup/client/logger'; import Logger from '/imports/startup/client/logger';
import { formatLocaleCode } from '/imports/utils/string-utils'; import { formatLocaleCode } from '/imports/utils/string-utils';
import Intl from '/imports/ui/services/locale';
const propTypes = { const propTypes = {
locale: PropTypes.string, locale: PropTypes.string,
@ -66,6 +67,7 @@ class IntlStartup extends Component {
const url = `./locale?locale=${locale}&init=${init}`; const url = `./locale?locale=${locale}&init=${init}`;
const localesPath = 'locales'; const localesPath = 'locales';
Intl.fetching = true;
this.setState({ fetching: true }, () => { this.setState({ fetching: true }, () => {
fetch(url) fetch(url)
.then((response) => { .then((response) => {
@ -138,6 +140,7 @@ class IntlStartup extends Component {
const dasherizedLocale = normalizedLocale.replace('_', '-'); const dasherizedLocale = normalizedLocale.replace('_', '-');
const { language, formattedLocale } = formatLocaleCode(dasherizedLocale); const { language, formattedLocale } = formatLocaleCode(dasherizedLocale);
Intl.setLocale(formattedLocale, mergedMessages);
this.setState({ messages: mergedMessages, fetching: false, normalizedLocale: dasherizedLocale }, () => { this.setState({ messages: mergedMessages, fetching: false, normalizedLocale: dasherizedLocale }, () => {
Settings.application.locale = dasherizedLocale; Settings.application.locale = dasherizedLocale;

View File

@ -318,7 +318,7 @@ class ActionsDropdown extends PureComponent {
} }
actions={children} actions={children}
opts={{ opts={{
id: "default-dropdown-menu", id: "actions-dropdown-menu",
keepMounted: true, keepMounted: true,
transitionDuration: 0, transitionDuration: 0,
elevation: 3, elevation: 3,

View File

@ -10,6 +10,8 @@ import { withModalMounter } from '/imports/ui/components/common/modal/service';
import SortList from './sort-user-list/component'; import SortList from './sort-user-list/component';
import Styled from './styles'; import Styled from './styles';
import Icon from '/imports/ui/components/common/icon/component.jsx'; import Icon from '/imports/ui/components/common/icon/component.jsx';
import { isImportSharedNotesFromBreakoutRoomsEnabled, isImportPresentationWithAnnotationsFromBreakoutRoomsEnabled } from '/imports/ui/services/features';
import { addNewAlert } from '/imports/ui/components/screenreader-alert/service';
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator; const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
@ -82,6 +84,14 @@ const intlMessages = defineMessages({
id: 'app.createBreakoutRoom.freeJoin', id: 'app.createBreakoutRoom.freeJoin',
description: 'free join label', description: 'free join label',
}, },
captureNotesLabel: {
id: 'app.createBreakoutRoom.captureNotes',
description: 'capture shared notes label',
},
captureSlidesLabel: {
id: 'app.createBreakoutRoom.captureSlides',
description: 'capture slides label',
},
roomLabel: { roomLabel: {
id: 'app.createBreakoutRoom.room', id: 'app.createBreakoutRoom.room',
description: 'Room label', description: 'Room label',
@ -150,6 +160,10 @@ const intlMessages = defineMessages({
id: 'app.createBreakoutRoom.roomNameInputDesc', id: 'app.createBreakoutRoom.roomNameInputDesc',
description: 'aria description for room name change', description: 'aria description for room name change',
}, },
movedUserLabel: {
id: 'app.createBreakoutRoom.movedUserLabel',
description: 'screen reader alert when users are moved to rooms',
}
}); });
const BREAKOUT_LIM = Meteor.settings.public.app.breakouts.breakoutRoomLimit; const BREAKOUT_LIM = Meteor.settings.public.app.breakouts.breakoutRoomLimit;
@ -200,6 +214,8 @@ class BreakoutRoom extends PureComponent {
this.handleDismiss = this.handleDismiss.bind(this); this.handleDismiss = this.handleDismiss.bind(this);
this.setInvitationConfig = this.setInvitationConfig.bind(this); this.setInvitationConfig = this.setInvitationConfig.bind(this);
this.setRecord = this.setRecord.bind(this); this.setRecord = this.setRecord.bind(this);
this.setCaptureNotes = this.setCaptureNotes.bind(this);
this.setCaptureSlides = this.setCaptureSlides.bind(this);
this.blurDurationTime = this.blurDurationTime.bind(this); this.blurDurationTime = this.blurDurationTime.bind(this);
this.removeRoomUsers = this.removeRoomUsers.bind(this); this.removeRoomUsers = this.removeRoomUsers.bind(this);
this.renderErrorMessages = this.renderErrorMessages.bind(this); this.renderErrorMessages = this.renderErrorMessages.bind(this);
@ -220,6 +236,8 @@ class BreakoutRoom extends PureComponent {
roomNameDuplicatedIsValid: true, roomNameDuplicatedIsValid: true,
roomNameEmptyIsValid: true, roomNameEmptyIsValid: true,
record: false, record: false,
captureNotes: false,
captureSlides: false,
durationIsValid: true, durationIsValid: true,
breakoutJoinedUsers: null, breakoutJoinedUsers: null,
}; };
@ -310,6 +328,7 @@ class BreakoutRoom extends PureComponent {
users.forEach((u, index) => { users.forEach((u, index) => {
if (`roomUserItem-${u.userId}` === document.activeElement.id) { if (`roomUserItem-${u.userId}` === document.activeElement.id) {
users[index].room = text.substr(text.length - 1).includes(')') ? 0 : parseInt(roomNumber, 10); users[index].room = text.substr(text.length - 1).includes(')') ? 0 : parseInt(roomNumber, 10);
this.changeUserRoom(u.userId, users[index].room);
} }
}); });
} }
@ -386,6 +405,8 @@ class BreakoutRoom extends PureComponent {
users, users,
freeJoin, freeJoin,
record, record,
captureNotes,
captureSlides,
numberOfRoomsIsValid, numberOfRoomsIsValid,
numberOfRooms, numberOfRooms,
durationTime, durationTime,
@ -430,7 +451,7 @@ class BreakoutRoom extends PureComponent {
sequence: seq, sequence: seq,
})); }));
createBreakoutRoom(rooms, durationTime, record); createBreakoutRoom(rooms, durationTime, record, captureNotes, captureSlides);
Session.set('isUserListOpen', true); Session.set('isUserListOpen', true);
} }
@ -567,6 +588,14 @@ class BreakoutRoom extends PureComponent {
this.setState({ record: e.target.checked }); this.setState({ record: e.target.checked });
} }
setCaptureNotes(e) {
this.setState({ captureNotes: e.target.checked });
}
setCaptureSlides(e) {
this.setState({ captureSlides: e.target.checked });
}
getUserByRoom(room) { getUserByRoom(room) {
const { users } = this.state; const { users } = this.state;
return users.filter((user) => user.room === room); return users.filter((user) => user.room === room);
@ -602,17 +631,24 @@ class BreakoutRoom extends PureComponent {
} }
changeUserRoom(userId, room) { changeUserRoom(userId, room) {
const { intl } = this.props;
const { users, freeJoin } = this.state; const { users, freeJoin } = this.state;
const idxUser = users.findIndex((user) => user.userId === userId.replace('roomUserItem-', '')); const idxUser = users.findIndex((user) => user.userId === userId.replace('roomUserItem-', ''));
const usersCopy = [...users]; const usersCopy = [...users];
let userName = null;
if (idxUser >= 0) usersCopy[idxUser].room = room; if (idxUser >= 0) {
usersCopy[idxUser].room = room;
userName = usersCopy[idxUser].userName;
};
this.setState({ this.setState({
users: usersCopy, users: usersCopy,
leastOneUserIsValid: (this.getUserByRoom(0).length !== users.length || freeJoin), leastOneUserIsValid: (this.getUserByRoom(0).length !== users.length || freeJoin),
}, () => {
addNewAlert(intl.formatMessage(intlMessages.movedUserLabel, { 0: userName, 1: room }))
}); });
} }
@ -783,7 +819,7 @@ class BreakoutRoom extends PureComponent {
}; };
return ( return (
<Styled.BoxContainer key="rooms-grid-" ref={(r) => { this.listOfUsers = r; }}> <Styled.BoxContainer key="rooms-grid-" ref={(r) => { this.listOfUsers = r; }} data-test="roomGrid">
<Styled.Alert valid={leastOneUserIsValid} role="alert"> <Styled.Alert valid={leastOneUserIsValid} role="alert">
<Styled.FreeJoinLabel> <Styled.FreeJoinLabel>
<Styled.BreakoutNameInput <Styled.BreakoutNameInput
@ -797,7 +833,7 @@ class BreakoutRoom extends PureComponent {
<Styled.BreakoutBox id="breakoutBox-0" onDrop={drop(0)} onDragOver={allowDrop} tabIndex={0}> <Styled.BreakoutBox id="breakoutBox-0" onDrop={drop(0)} onDragOver={allowDrop} tabIndex={0}>
{this.renderUserItemByRoom(0)} {this.renderUserItemByRoom(0)}
</Styled.BreakoutBox> </Styled.BreakoutBox>
<Styled.SpanWarn valid={leastOneUserIsValid}> <Styled.SpanWarn data-test="warningNoUserAssigned" valid={leastOneUserIsValid}>
{intl.formatMessage(intlMessages.leastOneWarnBreakout)} {intl.formatMessage(intlMessages.leastOneWarnBreakout)}
</Styled.SpanWarn> </Styled.SpanWarn>
</Styled.Alert> </Styled.Alert>
@ -814,6 +850,7 @@ class BreakoutRoom extends PureComponent {
onBlur={changeRoomName(value)} onBlur={changeRoomName(value)}
aria-label={`${this.getRoomName(value)}`} aria-label={`${this.getRoomName(value)}`}
aria-describedby={this.getRoomName(value).length === 0 ? `room-error-${value}` : `room-input-${value}`} aria-describedby={this.getRoomName(value).length === 0 ? `room-error-${value}` : `room-input-${value}`}
data-test={this.getRoomName(value).length === 0 ? `room-error-${value}` : `roomName-${value}`}
readOnly={isUpdate} readOnly={isUpdate}
/> />
<div aria-hidden id={`room-input-${value}`} className="sr-only"> <div aria-hidden id={`room-input-${value}`} className="sr-only">
@ -885,6 +922,7 @@ class BreakoutRoom extends PureComponent {
onChange={this.changeDurationTime} onChange={this.changeDurationTime}
onBlur={this.blurDurationTime} onBlur={this.blurDurationTime}
aria-label={intl.formatMessage(intlMessages.duration)} aria-label={intl.formatMessage(intlMessages.duration)}
data-test="durationTime"
/> />
<Styled.HoldButtonWrapper <Styled.HoldButtonWrapper
key="decrease-breakout-time" key="decrease-breakout-time"
@ -902,6 +940,7 @@ class BreakoutRoom extends PureComponent {
hideLabel hideLabel
circle circle
size="sm" size="sm"
data-test="decreaseBreakoutTime"
/> />
</Styled.HoldButtonWrapper> </Styled.HoldButtonWrapper>
<Styled.HoldButtonWrapper <Styled.HoldButtonWrapper
@ -918,6 +957,7 @@ class BreakoutRoom extends PureComponent {
hideLabel hideLabel
circle circle
size="sm" size="sm"
data-test="increaseBreakoutTime"
/> />
</Styled.HoldButtonWrapper> </Styled.HoldButtonWrapper>
</Styled.DurationArea> </Styled.DurationArea>
@ -979,11 +1019,15 @@ class BreakoutRoom extends PureComponent {
} }
renderCheckboxes() { renderCheckboxes() {
const { intl, isUpdate, isBreakoutRecordable } = this.props; const {
intl, isUpdate, isBreakoutRecordable,
} = this.props;
if (isUpdate) return null; if (isUpdate) return null;
const { const {
freeJoin, freeJoin,
record, record,
captureNotes,
captureSlides,
} = this.state; } = this.state;
return ( return (
<Styled.CheckBoxesContainer key="breakout-checkboxes"> <Styled.CheckBoxesContainer key="breakout-checkboxes">
@ -1013,6 +1057,38 @@ class BreakoutRoom extends PureComponent {
</Styled.FreeJoinLabel> </Styled.FreeJoinLabel>
) : null ) : null
} }
{
isImportPresentationWithAnnotationsFromBreakoutRoomsEnabled() ? (
<Styled.FreeJoinLabel htmlFor="captureSlidesBreakoutCheckbox" key="capture-slides-breakouts">
<Styled.FreeJoinCheckbox
id="captureSlidesBreakoutCheckbox"
type="checkbox"
onChange={this.setCaptureSlides}
checked={captureSlides}
aria-label={intl.formatMessage(intlMessages.captureSlidesLabel)}
/>
<span aria-hidden>
{intl.formatMessage(intlMessages.captureSlidesLabel)}
</span>
</Styled.FreeJoinLabel>
) : null
}
{
isImportSharedNotesFromBreakoutRoomsEnabled() ? (
<Styled.FreeJoinLabel htmlFor="captureNotesBreakoutCheckbox" key="capture-notes-breakouts">
<Styled.FreeJoinCheckbox
id="captureNotesBreakoutCheckbox"
type="checkbox"
onChange={this.setCaptureNotes}
checked={captureNotes}
aria-label={intl.formatMessage(intlMessages.captureNotesLabel)}
/>
<span aria-hidden>
{intl.formatMessage(intlMessages.captureNotesLabel)}
</span>
</Styled.FreeJoinLabel>
) : null
}
</Styled.CheckBoxesContainer> </Styled.CheckBoxesContainer>
); );
} }

View File

@ -1,4 +1,4 @@
import React, { Component } from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages } from 'react-intl'; import { defineMessages } from 'react-intl';
import _ from 'lodash'; import _ from 'lodash';
@ -8,6 +8,7 @@ import { PANELS, ACTIONS } from '../../layout/enums';
const POLL_SETTINGS = Meteor.settings.public.poll; const POLL_SETTINGS = Meteor.settings.public.poll;
const MAX_CUSTOM_FIELDS = POLL_SETTINGS.maxCustom; const MAX_CUSTOM_FIELDS = POLL_SETTINGS.maxCustom;
const CANCELED_POLL_DELAY = 250;
const intlMessages = defineMessages({ const intlMessages = defineMessages({
quickPollLabel: { quickPollLabel: {
@ -34,6 +35,10 @@ const intlMessages = defineMessages({
id: 'app.poll.abstention', id: 'app.poll.abstention',
description: 'Poll Abstention option value', description: 'Poll Abstention option value',
}, },
typedRespLabel: {
id: 'app.poll.userResponse.label',
description: 'quick poll typed response label',
},
}); });
const propTypes = { const propTypes = {
@ -44,172 +49,218 @@ const propTypes = {
amIPresenter: PropTypes.bool.isRequired, amIPresenter: PropTypes.bool.isRequired,
}; };
const handleClickQuickPoll = (layoutContextDispatch) => { const QuickPollDropdown = (props) => {
layoutContextDispatch({ const {
type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN, amIPresenter,
value: true, intl,
}); parseCurrentSlideContent,
layoutContextDispatch({ startPoll,
type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL, stopPoll,
value: PANELS.POLL, currentSlide,
}); activePoll,
Session.set('forcePollOpen', true); className,
Session.set('pollInitiated', true); layoutContextDispatch,
}; pollTypes,
} = props;
const getAvailableQuickPolls = ( const parsedSlide = parseCurrentSlideContent(
slideId, parsedSlides, startPoll, pollTypes, layoutContextDispatch, intl.formatMessage(intlMessages.yesOptionLabel),
) => { intl.formatMessage(intlMessages.noOptionLabel),
const pollItemElements = parsedSlides.map((poll) => { intl.formatMessage(intlMessages.abstentionOptionLabel),
const { poll: label } = poll; intl.formatMessage(intlMessages.trueOptionLabel),
const { type } = poll; intl.formatMessage(intlMessages.falseOptionLabel),
let itemLabel = label; );
const letterAnswers = [];
if (type !== pollTypes.YesNo const {
&& type !== pollTypes.YesNoAbstention slideId, quickPollOptions, optionsWithLabels, pollQuestion,
&& type !== pollTypes.TrueFalse) { } = parsedSlide;
const { options } = itemLabel;
itemLabel = options.join('/').replace(/[\n.)]/g, ''); const handleClickQuickPoll = (lCDispatch) => {
if (type === pollTypes.Custom) { lCDispatch({
for (let i = 0; i < options.length; i += 1) { type: ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN,
const letterOption = options[i]?.replace(/[\r.)]/g, '').toUpperCase(); value: true,
if (letterAnswers.length < MAX_CUSTOM_FIELDS) { });
letterAnswers.push(letterOption); lCDispatch({
} else { type: ACTIONS.SET_SIDEBAR_CONTENT_PANEL,
break; value: PANELS.POLL,
});
Session.set('forcePollOpen', true);
Session.set('pollInitiated', true);
};
const getAvailableQuickPolls = (
slideId, parsedSlides, funcStartPoll, _pollTypes, _layoutContextDispatch,
) => {
const pollItemElements = parsedSlides.map((poll) => {
const { poll: label } = poll;
const { type, poll: pollData } = poll;
let itemLabel = label;
const letterAnswers = [];
if (type === 'R-') {
return (
<Dropdown.DropdownListItem
label={intl.formatMessage(intlMessages.typedRespLabel)}
key={_.uniqueId('quick-poll-item')}
onClick={() => {
if (activePoll) {
stopPoll();
}
setTimeout(() => {
handleClickQuickPoll(_layoutContextDispatch);
funcStartPoll(type, slideId, letterAnswers, pollData?.question);
}, CANCELED_POLL_DELAY);
}}
question={pollData?.question}
/>
);
}
if (type !== _pollTypes.YesNo
&& type !== _pollTypes.YesNoAbstention
&& type !== _pollTypes.TrueFalse) {
const { options } = itemLabel;
itemLabel = options.join('/').replace(/[\n.)]/g, '');
if (type === _pollTypes.Custom) {
for (let i = 0; i < options.length; i += 1) {
const letterOption = options[i]?.replace(/[\r.)]/g, '').toUpperCase();
if (letterAnswers.length < MAX_CUSTOM_FIELDS) {
letterAnswers.push(letterOption);
} else {
break;
}
} }
} }
} }
}
// removes any whitespace from the label // removes any whitespace from the label
itemLabel = itemLabel?.replace(/\s+/g, '').toUpperCase(); itemLabel = itemLabel?.replace(/\s+/g, '').toUpperCase();
const numChars = { const numChars = {
1: 'A', 2: 'B', 3: 'C', 4: 'D', 5: 'E', 1: 'A', 2: 'B', 3: 'C', 4: 'D', 5: 'E',
}; };
itemLabel = itemLabel.split('').map((c) => { itemLabel = itemLabel.split('').map((c) => {
if (numChars[c]) return numChars[c]; if (numChars[c]) return numChars[c];
return c; return c;
}).join(''); }).join('');
return ( return (
<Dropdown.DropdownListItem <Dropdown.DropdownListItem
label={itemLabel} label={itemLabel}
key={_.uniqueId('quick-poll-item')} key={_.uniqueId('quick-poll-item')}
onClick={() => { onClick={() => {
if (activePoll) {
stopPoll();
}
setTimeout(() => {
handleClickQuickPoll(_layoutContextDispatch);
funcStartPoll(type, slideId, letterAnswers, pollQuestion, pollData?.multiResp);
}, CANCELED_POLL_DELAY);
}}
answers={letterAnswers}
multiResp={pollData?.multiResp}
/>
);
});
const sizes = [];
return pollItemElements.filter((el) => {
const { label } = el.props;
if (label.length === sizes[sizes.length - 1]) return false;
sizes.push(label.length);
return el;
});
};
const quickPolls = getAvailableQuickPolls(
slideId, quickPollOptions, startPoll, pollTypes, layoutContextDispatch,
);
if (quickPollOptions.length === 0) return null;
let answers = null;
let question = '';
let quickPollLabel = '';
let multiResponse = false;
if (quickPolls.length > 0) {
const { props: pollProps } = quickPolls[0];
quickPollLabel = pollProps?.label;
answers = pollProps?.answers;
question = pollProps?.question;
multiResponse = pollProps?.multiResp;
}
let singlePollType = null;
if (quickPolls.length === 1 && quickPollOptions.length) {
const { type } = quickPollOptions[0];
singlePollType = type;
}
let btn = (
<Styled.QuickPollButton
aria-label={intl.formatMessage(intlMessages.quickPollLabel)}
label={quickPollLabel}
tooltipLabel={intl.formatMessage(intlMessages.quickPollLabel)}
onClick={() => {
if (activePoll) {
stopPoll();
}
setTimeout(() => {
handleClickQuickPoll(layoutContextDispatch); handleClickQuickPoll(layoutContextDispatch);
startPoll(type, slideId, letterAnswers); if (singlePollType === 'R-' || singlePollType === 'TF') {
}} startPoll(singlePollType, currentSlide.id, answers, pollQuestion, multiResponse);
answers={letterAnswers} } else {
/> startPoll(
); pollTypes.Custom,
}); currentSlide.id,
optionsWithLabels,
pollQuestion,
multiResponse,
);
}
}, CANCELED_POLL_DELAY);
}}
size="lg"
data-test="quickPollBtn"
/>
);
const sizes = []; const usePollDropdown = quickPollOptions && quickPollOptions.length && quickPolls.length > 1;
return pollItemElements.filter((el) => { let dropdown = null;
const { label } = el.props;
if (label.length === sizes[sizes.length - 1]) return false;
sizes.push(label.length);
return el;
});
};
class QuickPollDropdown extends Component { if (usePollDropdown) {
render() { btn = (
const {
amIPresenter,
intl,
parseCurrentSlideContent,
startPoll,
currentSlide,
activePoll,
className,
layoutContextDispatch,
pollTypes,
} = this.props;
const parsedSlide = parseCurrentSlideContent(
intl.formatMessage(intlMessages.yesOptionLabel),
intl.formatMessage(intlMessages.noOptionLabel),
intl.formatMessage(intlMessages.abstentionOptionLabel),
intl.formatMessage(intlMessages.trueOptionLabel),
intl.formatMessage(intlMessages.falseOptionLabel),
);
const { slideId, quickPollOptions } = parsedSlide;
const quickPolls = getAvailableQuickPolls(
slideId, quickPollOptions, startPoll, pollTypes, layoutContextDispatch,
);
if (quickPollOptions.length === 0) return null;
let answers = null;
let quickPollLabel = '';
if (quickPolls.length > 0) {
const { props: pollProps } = quickPolls[0];
quickPollLabel = pollProps.label;
answers = pollProps.answers;
}
let singlePollType = null;
if (quickPolls.length === 1 && quickPollOptions.length) {
const { type } = quickPollOptions[0];
singlePollType = type;
}
let btn = (
<Styled.QuickPollButton <Styled.QuickPollButton
aria-label={intl.formatMessage(intlMessages.quickPollLabel)} aria-label={intl.formatMessage(intlMessages.quickPollLabel)}
label={quickPollLabel} label={quickPollLabel}
tooltipLabel={intl.formatMessage(intlMessages.quickPollLabel)} tooltipLabel={intl.formatMessage(intlMessages.quickPollLabel)}
onClick={() => { onClick={() => null}
handleClickQuickPoll(layoutContextDispatch);
startPoll(singlePollType, currentSlide.id, answers);
}}
size="lg" size="lg"
disabled={!!activePoll}
data-test="quickPollBtn"
/> />
); );
const usePollDropdown = quickPollOptions && quickPollOptions.length && quickPolls.length > 1; dropdown = (
let dropdown = null; <Dropdown className={className}>
<Dropdown.DropdownTrigger tabIndex={0}>
if (usePollDropdown) { {btn}
btn = ( </Dropdown.DropdownTrigger>
<Styled.QuickPollButton <Dropdown.DropdownContent>
aria-label={intl.formatMessage(intlMessages.quickPollLabel)} <Dropdown.DropdownList>
label={quickPollLabel} {quickPolls}
tooltipLabel={intl.formatMessage(intlMessages.quickPollLabel)} </Dropdown.DropdownList>
onClick={() => null} </Dropdown.DropdownContent>
size="lg" </Dropdown>
disabled={!!activePoll}
/>
);
dropdown = (
<Dropdown className={className}>
<Dropdown.DropdownTrigger tabIndex={0}>
{btn}
</Dropdown.DropdownTrigger>
<Dropdown.DropdownContent>
<Dropdown.DropdownList>
{quickPolls}
</Dropdown.DropdownList>
</Dropdown.DropdownContent>
</Dropdown>
);
}
return amIPresenter && usePollDropdown ? (
dropdown
) : (
btn
); );
} }
}
return amIPresenter && usePollDropdown ? (
dropdown
) : (
btn
);
};
QuickPollDropdown.propTypes = propTypes; QuickPollDropdown.propTypes = propTypes;

View File

@ -1,17 +1,18 @@
import React from 'react'; import React from 'react';
import { withTracker } from 'meteor/react-meteor-data'; import { withTracker } from 'meteor/react-meteor-data';
import { injectIntl } from 'react-intl'; import { injectIntl } from 'react-intl';
import { makeCall } from '/imports/ui/services/api';
import PollService from '/imports/ui/components/poll/service';
import QuickPollDropdown from './component'; import QuickPollDropdown from './component';
import { layoutDispatch } from '../../layout/context'; import { layoutDispatch } from '../../layout/context';
import PollService from '/imports/ui/components/poll/service';
const QuickPollDropdownContainer = (props) => { const QuickPollDropdownContainer = (props) => {
const layoutContextDispatch = layoutDispatch(); const layoutContextDispatch = layoutDispatch();
return <QuickPollDropdown {...{ layoutContextDispatch, ...props }} />; return <QuickPollDropdown {...{ layoutContextDispatch, ...props }} />;
}; };
export default withTracker(() => ({ export default withTracker(() => ({
activePoll: Session.get('pollInitiated') || false, activePoll: Session.get('pollInitiated') || false,
pollTypes: PollService.pollTypes, pollTypes: PollService.pollTypes,
stopPoll: () => makeCall('stopPoll'),
}))(injectIntl(QuickPollDropdownContainer)); }))(injectIntl(QuickPollDropdownContainer));

View File

@ -16,7 +16,7 @@ import { SCREENSHARING_ERRORS } from '/imports/api/screenshare/client/bridge/err
import Button from '/imports/ui/components/common/button/component'; import Button from '/imports/ui/components/common/button/component';
const { isMobile } = deviceInfo; const { isMobile } = deviceInfo;
const { isSafari, isMobileApp } = browserInfo; const { isSafari, isTabletApp } = browserInfo;
const propTypes = { const propTypes = {
intl: PropTypes.objectOf(Object).isRequired, intl: PropTypes.objectOf(Object).isRequired,
@ -163,7 +163,7 @@ const ScreenshareButton = ({
? intlMessages.stopDesktopShareDesc : intlMessages.desktopShareDesc; ? intlMessages.stopDesktopShareDesc : intlMessages.desktopShareDesc;
const shouldAllowScreensharing = enabled const shouldAllowScreensharing = enabled
&& ( !isMobile || isMobileApp) && ( !isMobile || isTabletApp)
&& amIPresenter; && amIPresenter;
const dataTest = isVideoBroadcasting ? 'stopScreenShare' : 'startScreenShare'; const dataTest = isVideoBroadcasting ? 'stopScreenShare' : 'startScreenShare';

View File

@ -63,7 +63,7 @@ export default {
isBreakoutRecordable: () => Meetings.findOne({ meetingId: Auth.meetingID }, isBreakoutRecordable: () => Meetings.findOne({ meetingId: Auth.meetingID },
{ fields: { 'breakoutProps.record': 1 } }).breakoutProps.record, { fields: { 'breakoutProps.record': 1 } }).breakoutProps.record,
toggleRecording: () => makeCall('toggleRecording'), toggleRecording: () => makeCall('toggleRecording'),
createBreakoutRoom: (rooms, durationInMinutes, record = false) => makeCall('createBreakoutRoom', rooms, durationInMinutes, record), createBreakoutRoom: (rooms, durationInMinutes, record = false, captureNotes = false, captureSlides = false) => makeCall('createBreakoutRoom', rooms, durationInMinutes, record, captureNotes, captureSlides),
sendInvitation: (breakoutId, userId) => makeCall('requestJoinURL', { breakoutId, userId }), sendInvitation: (breakoutId, userId) => makeCall('requestJoinURL', { breakoutId, userId }),
breakoutJoinedUsers: () => Breakouts.find({ breakoutJoinedUsers: () => Breakouts.find({
joinedUsers: { $exists: true }, joinedUsers: { $exists: true },

View File

@ -81,6 +81,8 @@ class AudioControls extends PureComponent {
handleLeaveAudio, handleToggleMuteMicrophone, muted, disable, talking, handleLeaveAudio, handleToggleMuteMicrophone, muted, disable, talking,
} = this.props; } = this.props;
const { isMobile } = deviceInfo;
return ( return (
<InputStreamLiveSelectorContainer {...{ <InputStreamLiveSelectorContainer {...{
handleLeaveAudio, handleLeaveAudio,
@ -88,6 +90,7 @@ class AudioControls extends PureComponent {
muted, muted,
disable, disable,
talking, talking,
isMobile,
_enableDynamicDeviceSelection, _enableDynamicDeviceSelection,
}} }}
/> />

View File

@ -366,8 +366,8 @@ class InputStreamLiveSelector extends Component {
currentInputDeviceId, currentInputDeviceId,
currentOutputDeviceId, currentOutputDeviceId,
isListenOnly, isListenOnly,
isRTL,
shortcuts, shortcuts,
isMobile,
} = this.props; } = this.props;
const inputDeviceList = !isListenOnly const inputDeviceList = !isListenOnly
@ -399,6 +399,7 @@ class InputStreamLiveSelector extends Component {
}; };
const dropdownListComplete = inputDeviceList.concat(outputDeviceList).concat(leaveAudioOption); const dropdownListComplete = inputDeviceList.concat(outputDeviceList).concat(leaveAudioOption);
const customStyles = { top: '-1rem' };
return ( return (
<> <>
@ -411,6 +412,7 @@ class InputStreamLiveSelector extends Component {
/> />
) : null} ) : null}
<BBBMenu <BBBMenu
customStyles={!isMobile ? customStyles : null}
trigger={( trigger={(
<> <>
{isListenOnly {isListenOnly
@ -428,14 +430,14 @@ class InputStreamLiveSelector extends Component {
)} )}
actions={dropdownListComplete} actions={dropdownListComplete}
opts={{ opts={{
id: 'default-dropdown-menu', id: 'audio-selector-dropdown-menu',
keepMounted: true, keepMounted: true,
transitionDuration: 0, transitionDuration: 0,
elevation: 3, elevation: 3,
getContentAnchorEl: null, getContentAnchorEl: null,
fullwidth: 'true', fullwidth: 'true',
anchorOrigin: { vertical: 'top', horizontal: isRTL ? 'left' : 'right' }, anchorOrigin: { vertical: 'top', horizontal: 'center' },
transformOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' }, transformOrigin: { vertical: 'bottom', horizontal: 'center'},
}} }}
/> />
</> </>

View File

@ -94,7 +94,7 @@ const ConnectingAnimation = styled.span`
`; `;
const AudioModal = styled(Modal)` const AudioModal = styled(Modal)`
padding: 1.5rem; padding: 1rem;
min-height: 20rem; min-height: 20rem;
`; `;

View File

@ -98,6 +98,7 @@ class AudioSettings extends React.Component {
componentDidMount() { componentDidMount() {
const { inputDeviceId, outputDeviceId } = this.state; const { inputDeviceId, outputDeviceId } = this.state;
Session.set('inEchoTest', true);
this._isMounted = true; this._isMounted = true;
// Guarantee initial in/out devices are initialized on all ends // Guarantee initial in/out devices are initialized on all ends
this.setInputDevice(inputDeviceId); this.setInputDevice(inputDeviceId);
@ -107,6 +108,7 @@ class AudioSettings extends React.Component {
componentWillUnmount() { componentWillUnmount() {
const { stream } = this.state; const { stream } = this.state;
Session.set('inEchoTest', false);
this._mounted = false; this._mounted = false;
if (stream) { if (stream) {

View File

@ -1,7 +1,10 @@
import React from 'react'; import React, { useRef, useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import Service from '/imports/ui/components/audio/captions/service'; import Service from '/imports/ui/components/audio/captions/service';
import SpeechService from '/imports/ui/components/audio/captions/speech/service';
import ButtonEmoji from '/imports/ui/components/common/button/button-emoji/ButtonEmoji';
import BBBMenu from '/imports/ui/components/common/menu/component';
import Styled from './styles'; import Styled from './styles';
const intlMessages = defineMessages({ const intlMessages = defineMessages({
@ -13,18 +16,155 @@ const intlMessages = defineMessages({
id: 'app.audio.captions.button.stop', id: 'app.audio.captions.button.stop',
description: 'Stop audio captions', description: 'Stop audio captions',
}, },
transcriptionSettings: {
id: 'app.audio.captions.button.transcriptionSettings',
description: 'Audio captions settings modal',
},
transcription: {
id: 'app.audio.captions.button.transcription',
description: 'Audio speech transcription label',
},
transcriptionOn: {
id: 'app.switch.onLabel',
},
transcriptionOff: {
id: 'app.switch.offLabel',
},
language: {
id: 'app.audio.captions.button.language',
description: 'Audio speech recognition language label',
},
'de-DE': {
id: 'app.audio.captions.select.de-DE',
description: 'Audio speech recognition german language',
},
'en-US': {
id: 'app.audio.captions.select.en-US',
description: 'Audio speech recognition english language',
},
'es-ES': {
id: 'app.audio.captions.select.es-ES',
description: 'Audio speech recognition spanish language',
},
'fr-FR': {
id: 'app.audio.captions.select.fr-FR',
description: 'Audio speech recognition french language',
},
'hi-ID': {
id: 'app.audio.captions.select.hi-ID',
description: 'Audio speech recognition indian language',
},
'it-IT': {
id: 'app.audio.captions.select.it-IT',
description: 'Audio speech recognition italian language',
},
'ja-JP': {
id: 'app.audio.captions.select.ja-JP',
description: 'Audio speech recognition japanese language',
},
'pt-BR': {
id: 'app.audio.captions.select.pt-BR',
description: 'Audio speech recognition portuguese language',
},
'ru-RU': {
id: 'app.audio.captions.select.ru-RU',
description: 'Audio speech recognition russian language',
},
'zh-CN': {
id: 'app.audio.captions.select.zh-CN',
description: 'Audio speech recognition chinese language',
},
}); });
const DEFAULT_LOCALE = 'en-US';
const DISABLED = '';
const CaptionsButton = ({ const CaptionsButton = ({
intl, intl,
active, active,
isRTL,
enabled, enabled,
currentSpeechLocale,
availableVoices,
isSupported,
isVoiceUser,
}) => { }) => {
const onClick = () => Service.setAudioCaptions(!active);
if (!enabled) return null; if (!enabled) return null;
return ( const isTranscriptionDisabled = () => (
currentSpeechLocale === DISABLED
);
const fallbackLocale = availableVoices.includes(navigator.language)
? navigator.language : DEFAULT_LOCALE;
const getSelectedLocaleValue = (isTranscriptionDisabled() ? fallbackLocale : currentSpeechLocale);
const selectedLocale = useRef(getSelectedLocaleValue);
useEffect(() => {
if (!isTranscriptionDisabled()) selectedLocale.current = getSelectedLocaleValue;
}, [currentSpeechLocale]);
const shouldRenderChevron = isSupported && isVoiceUser;
const getAvailableLocales = () => (
availableVoices.map((availableVoice) => (
{
icon: '',
label: intl.formatMessage(intlMessages[availableVoice]),
key: availableVoice,
iconRight: selectedLocale.current === availableVoice ? 'check' : null,
customStyles: (selectedLocale.current === availableVoice) && Styled.SelectedLabel,
disabled: isTranscriptionDisabled(),
dividerTop: availableVoice === availableVoices[0],
onClick: () => {
selectedLocale.current = availableVoice;
SpeechService.setSpeechLocale(selectedLocale.current);
},
}
))
);
const toggleTranscription = () => {
SpeechService.setSpeechLocale(isTranscriptionDisabled() ? selectedLocale.current : DISABLED);
};
const getAvailableLocalesList = () => (
[{
key: 'availableLocalesList',
label: intl.formatMessage(intlMessages.language),
customStyles: Styled.TitleLabel,
disabled: true,
dividerTop: false,
},
...getAvailableLocales(),
{
key: 'divider',
label: intl.formatMessage(intlMessages.transcription),
customStyles: Styled.TitleLabel,
disabled: true,
}, {
key: 'transcriptionStatus',
label: intl.formatMessage(
isTranscriptionDisabled()
? intlMessages.transcriptionOn
: intlMessages.transcriptionOff,
),
customStyles: isTranscriptionDisabled()
? Styled.EnableTrascription : Styled.DisableTrascription,
disabled: false,
dividerTop: true,
onClick: toggleTranscription,
}]
);
const onToggleClick = (e) => {
e.stopPropagation();
Service.setAudioCaptions(!active);
};
const startStopCaptionsButton = (
<Styled.ClosedCaptionToggleButton <Styled.ClosedCaptionToggleButton
icon={active ? 'closed_caption' : 'closed_caption_stop'} icon={active ? 'closed_caption' : 'closed_caption_stop'}
label={intl.formatMessage(active ? intlMessages.stop : intlMessages.start)} label={intl.formatMessage(active ? intlMessages.stop : intlMessages.start)}
@ -33,9 +173,42 @@ const CaptionsButton = ({
hideLabel hideLabel
circle circle
size="lg" size="lg"
onClick={onClick} onClick={onToggleClick}
/> />
); );
return (
shouldRenderChevron
? (
<Styled.SpanButtonWrapper>
<BBBMenu
trigger={(
<>
{ startStopCaptionsButton }
<ButtonEmoji
emoji="device_list_selector"
hideLabel
label={intl.formatMessage(intlMessages.transcriptionSettings)}
tabIndex={0}
rotate
/>
</>
)}
actions={getAvailableLocalesList()}
opts={{
id: 'default-dropdown-menu',
keepMounted: true,
transitionDuration: 0,
elevation: 3,
getContentAnchorEl: null,
fullwidth: 'true',
anchorOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' },
transformOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' },
}}
/>
</Styled.SpanButtonWrapper>
) : startStopCaptionsButton
);
}; };
CaptionsButton.propTypes = { CaptionsButton.propTypes = {
@ -43,7 +216,12 @@ CaptionsButton.propTypes = {
formatMessage: PropTypes.func.isRequired, formatMessage: PropTypes.func.isRequired,
}).isRequired, }).isRequired,
active: PropTypes.bool.isRequired, active: PropTypes.bool.isRequired,
isRTL: PropTypes.bool.isRequired,
enabled: PropTypes.bool.isRequired, enabled: PropTypes.bool.isRequired,
currentSpeechLocale: PropTypes.string.isRequired,
availableVoices: PropTypes.arrayOf(PropTypes.string).isRequired,
isSupported: PropTypes.bool.isRequired,
isVoiceUser: PropTypes.bool.isRequired,
}; };
export default injectIntl(CaptionsButton); export default injectIntl(CaptionsButton);

View File

@ -2,10 +2,24 @@ import React from 'react';
import { withTracker } from 'meteor/react-meteor-data'; import { withTracker } from 'meteor/react-meteor-data';
import Service from '/imports/ui/components/audio/captions/service'; import Service from '/imports/ui/components/audio/captions/service';
import Button from './component'; import Button from './component';
import SpeechService from '/imports/ui/components/audio/captions/speech/service';
import AudioService from '/imports/ui/components/audio/service';
const Container = (props) => <Button {...props} />; const Container = (props) => <Button {...props} />;
export default withTracker(() => ({ export default withTracker(() => {
enabled: Service.hasAudioCaptions(), const isRTL = document.documentElement.getAttribute('dir') === 'rtl';
active: Service.getAudioCaptions(), const availableVoices = SpeechService.getSpeechVoices();
}))(Container); const currentSpeechLocale = SpeechService.getSpeechLocale();
const isSupported = availableVoices.length > 0;
const isVoiceUser = AudioService.isVoiceUser();
return {
isRTL,
enabled: Service.hasAudioCaptions(),
active: Service.getAudioCaptions(),
currentSpeechLocale,
availableVoices,
isSupported,
isVoiceUser,
};
})(Container);

View File

@ -1,6 +1,13 @@
import styled from 'styled-components'; import styled from 'styled-components';
import Button from '/imports/ui/components/common/button/component'; import Button from '/imports/ui/components/common/button/component';
import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette'; import Toggle from '/imports/ui/components/common/switch/component';
import {
colorWhite,
colorPrimary,
colorOffWhite,
colorDangerDark,
colorSuccess,
} from '/imports/ui/stylesheets/styled-components/palette';
const ClosedCaptionToggleButton = styled(Button)` const ClosedCaptionToggleButton = styled(Button)`
${({ ghost }) => ghost && ` ${({ ghost }) => ghost && `
@ -15,6 +22,40 @@ const ClosedCaptionToggleButton = styled(Button)`
`} `}
`; `;
const SpanButtonWrapper = styled.span`
position: relative;
`;
const TranscriptionToggle = styled(Toggle)`
display: flex;
justify-content: flex-start;
padding-left: 1em;
`;
const TitleLabel = {
fontWeight: 'bold',
opacity: 1,
};
const EnableTrascription = {
color: colorSuccess,
};
const DisableTrascription = {
color: colorDangerDark,
};
const SelectedLabel = {
color: colorPrimary,
backgroundColor: colorOffWhite,
};
export default { export default {
ClosedCaptionToggleButton, ClosedCaptionToggleButton,
SpanButtonWrapper,
TranscriptionToggle,
TitleLabel,
EnableTrascription,
DisableTrascription,
SelectedLabel,
}; };

View File

@ -17,7 +17,6 @@ const intlMessages = defineMessages({
manageUsers: { manageUsers: {
id: 'app.breakout.dropdown.manageUsers', id: 'app.breakout.dropdown.manageUsers',
description: 'Manage users label', description: 'Manage users label',
defaultMessage: 'Manage Users',
}, },
destroy: { destroy: {
id: 'app.breakout.dropdown.destroyAll', id: 'app.breakout.dropdown.destroyAll',
@ -102,7 +101,7 @@ class BreakoutDropdown extends PureComponent {
/> />
} }
opts={{ opts={{
id: "default-dropdown-menu", id: "breakoutroom-dropdown-menu",
keepMounted: true, keepMounted: true,
transitionDuration: 0, transitionDuration: 0,
elevation: 3, elevation: 3,

View File

@ -2,7 +2,7 @@ import React from 'react';
const BreakoutRemainingTime = props => ( const BreakoutRemainingTime = props => (
<span data-test="breakoutRemainingTime"> <span data-test="timeRemaining">
{props.children} {props.children}
</span> </span>
); );

View File

@ -68,7 +68,7 @@ class breakoutRemainingTimeContainer extends React.Component {
<BreakoutRemainingTimeComponent> <BreakoutRemainingTimeComponent>
<Text>{text}</Text> <Text>{text}</Text>
<br /> <br />
<Time>{time}</Time> <Time data-test="breakoutRemainingTime">{time}</Time>
</BreakoutRemainingTimeComponent> </BreakoutRemainingTimeComponent>
); );
} }

View File

@ -429,11 +429,10 @@ class BreakoutRoom extends PureComponent {
} = this.state; } = this.state;
const { animations } = Settings.application; const { animations } = Settings.application;
const roomItems = breakoutRooms.map((breakout) => ( const roomItems = breakoutRooms.map((breakout) => (
<Styled.BreakoutItems key={`breakoutRoomItems-${breakout.breakoutId}`} > <Styled.BreakoutItems key={`breakoutRoomItems-${breakout.breakoutId}`} >
<Styled.Content key={`breakoutRoomList-${breakout.breakoutId}`}> <Styled.Content key={`breakoutRoomList-${breakout.breakoutId}`}>
<Styled.BreakoutRoomListNameLabel aria-hidden> <Styled.BreakoutRoomListNameLabel data-test={breakout.shortName} aria-hidden>
{breakout.isDefaultName {breakout.isDefaultName
? intl.formatMessage(intlMessages.breakoutRoom, { 0: breakout.sequence }) ? intl.formatMessage(intlMessages.breakoutRoom, { 0: breakout.sequence })
: breakout.shortName} : breakout.shortName}
@ -454,7 +453,9 @@ class BreakoutRoom extends PureComponent {
breakout.shortName, breakout.shortName,
)} )}
</Styled.Content> </Styled.Content>
<Styled.JoinedUserNames> <Styled.JoinedUserNames
data-test={`userNameBreakoutRoom-${breakout.shortName}`}
>
{breakout.joinedUsers {breakout.joinedUsers
.sort(BreakoutRoom.sortById) .sort(BreakoutRoom.sortById)
.filter((value, idx, arr) => !(value.userId === (arr[idx + 1] || {}).userId)) .filter((value, idx, arr) => !(value.userId === (arr[idx + 1] || {}).userId))
@ -467,7 +468,7 @@ class BreakoutRoom extends PureComponent {
return ( return (
<Styled.BreakoutColumn> <Styled.BreakoutColumn>
<Styled.BreakoutScrollableList> <Styled.BreakoutScrollableList data-test="breakoutRoomList">
{roomItems} {roomItems}
</Styled.BreakoutScrollableList> </Styled.BreakoutScrollableList>
</Styled.BreakoutColumn> </Styled.BreakoutColumn>
@ -518,6 +519,7 @@ class BreakoutRoom extends PureComponent {
&nbsp; &nbsp;
&nbsp; &nbsp;
<Styled.EndButton <Styled.EndButton
data-test="sendButtonDurationTime"
color="primary" color="primary"
disabled={!isMeteorConnected} disabled={!isMeteorConnected}
size="sm" size="sm"

View File

@ -226,6 +226,9 @@ class MessageForm extends PureComponent {
onChange={this.handleMessageChange} onChange={this.handleMessageChange}
onKeyDown={this.handleMessageKeyDown} onKeyDown={this.handleMessageKeyDown}
async async
onPaste={(e) => { e.stopPropagation(); }}
onCut={(e) => { e.stopPropagation(); }}
onCopy={(e) => { e.stopPropagation(); }}
/> />
<Styled.SendButton <Styled.SendButton
hideLabel hideLabel

View File

@ -67,6 +67,11 @@ const Content = styled.div`
justify-content: space-between; justify-content: space-between;
font-size: ${fontSizeSmall}; font-size: ${fontSizeSmall};
font-weight: bold; font-weight: bold;
padding: ${borderSize} ${borderSize} ${borderSize} 0;
[dir="rtl"] & {
padding: ${borderSize} 0 ${borderSize} ${borderSize};
}
`; `;
const BreakoutRoomListNameLabel = styled.span` const BreakoutRoomListNameLabel = styled.span`

View File

@ -14,7 +14,7 @@ import Button from '/imports/ui/components/common/button/component';
import Modal from '/imports/ui/components/common/modal/simple/component'; import Modal from '/imports/ui/components/common/modal/simple/component';
const WriterMenuModal = styled(Modal)` const WriterMenuModal = styled(Modal)`
padding: 1.5rem; padding: 1rem;
min-height: 20rem; min-height: 20rem;
`; `;
@ -51,7 +51,6 @@ const Content = styled.div`
const StartBtn = styled(Button)` const StartBtn = styled(Button)`
align-self: center; align-self: center;
margin: 0; margin: 0;
width: 40%;
display: block; display: block;
position: absolute; position: absolute;
bottom: 20px; bottom: 20px;

View File

@ -14,6 +14,7 @@ import { usePreviousValue } from '/imports/ui/components/utils/hooks';
const CHAT_CONFIG = Meteor.settings.public.chat; const CHAT_CONFIG = Meteor.settings.public.chat;
const PUBLIC_CHAT_CLEAR = CHAT_CONFIG.chat_clear; const PUBLIC_CHAT_CLEAR = CHAT_CONFIG.chat_clear;
const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id; const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id;
const POLL_RESULT_KEY = CHAT_CONFIG.system_messages_keys.chat_poll_result;
const propTypes = { const propTypes = {
pushAlertEnabled: PropTypes.bool.isRequired, pushAlertEnabled: PropTypes.bool.isRequired,
@ -56,6 +57,14 @@ const intlMessages = defineMessages({
id: 'app.toast.chat.private', id: 'app.toast.chat.private',
description: 'private chat toast message title', description: 'private chat toast message title',
}, },
pollResults: {
id: 'app.toast.chat.poll',
description: 'chat toast message for polls',
},
pollResultsClick: {
id: 'app.toast.chat.pollClick',
description: 'chat toast click message for polls',
},
}); });
const ALERT_INTERVAL = 5000; // 5 seconds const ALERT_INTERVAL = 5000; // 5 seconds
@ -168,6 +177,13 @@ const ChatAlert = (props) => {
</Styled.PushMessageContent> </Styled.PushMessageContent>
); );
const createPollMessage = () => (
<Styled.PushMessageContent>
<Styled.UserNameMessage>{intl.formatMessage(intlMessages.pollResults)}</Styled.UserNameMessage>
<Styled.ContentMessagePoll>{intl.formatMessage(intlMessages.pollResultsClick)}</Styled.ContentMessagePoll>
</Styled.PushMessageContent>
);
if (_.isEqual(prevUnreadMessages, unreadMessages)) { if (_.isEqual(prevUnreadMessages, unreadMessages)) {
return null; return null;
} }
@ -175,9 +191,15 @@ const ChatAlert = (props) => {
return pushAlertEnabled return pushAlertEnabled
? unreadMessages.map((timeWindow) => { ? unreadMessages.map((timeWindow) => {
const mappedMessage = Service.mapGroupMessage(timeWindow); const mappedMessage = Service.mapGroupMessage(timeWindow);
const content = mappedMessage
? createMessage(mappedMessage.sender.name, mappedMessage.content.slice(-5)) let content = null;
: null; if (mappedMessage) {
if (mappedMessage.id.includes(POLL_RESULT_KEY)) {
content = createPollMessage();
} else {
content = createMessage(mappedMessage.sender.name, mappedMessage.content.slice(-5));
}
}
const messageChatId = mappedMessage.chatId === 'MAIN-PUBLIC-GROUP-CHAT' ? PUBLIC_CHAT_ID : mappedMessage.chatId; const messageChatId = mappedMessage.chatId === 'MAIN-PUBLIC-GROUP-CHAT' ? PUBLIC_CHAT_ID : mappedMessage.chatId;

View File

@ -38,8 +38,13 @@ const ContentMessage = styled.div`
max-height: calc(${fontSizeSmall} * 10); max-height: calc(${fontSizeSmall} * 10);
`; `;
const ContentMessagePoll = styled(ContentMessage)`
margin-top: ${fontSizeSmall};
`;
export default { export default {
PushMessageContent, PushMessageContent,
UserNameMessage, UserNameMessage,
ContentMessage, ContentMessage,
ContentMessagePoll,
}; };

Some files were not shown because too many files have changed in this diff Show More