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: RegisterUserReqMsg => handleRegisterUserReqMsg(m)
case m: EjectDuplicateUserReqMsg => handleEjectDuplicateUserReqMsg(m)
case m: GetAllMeetingsReqMsg => handleGetAllMeetingsReqMsg(m)
case m: GetRunningMeetingsReqMsg => handleGetRunningMeetingsReqMsg(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 = {
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
/**
* 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
case class DeskShareStartedRequest(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,
freeJoin: Boolean,
voiceConf: String,
assignedUsers: Vector[String]
assignedUsers: Vector[String],
captureNotes: Boolean,
captureSlides: Boolean,
): 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
import org.bigbluebutton.core.util.jhotdraw.BezierWrapper
import scala.collection.immutable.List
import scala.collection.immutable.HashMap
import scala.collection.JavaConverters._
import org.bigbluebutton.common2.msgs.AnnotationVO
import org.bigbluebutton.core.apps.whiteboard.Whiteboard
import org.bigbluebutton.SystemConfiguration
@ -24,86 +21,83 @@ class WhiteboardModel extends SystemConfiguration {
}
private def createWhiteboard(wbId: String): Whiteboard = {
new Whiteboard(
Whiteboard(
wbId,
Array.empty[String],
Array.empty[String],
System.currentTimeMillis(),
new HashMap[String, Map[String, AnnotationVO]]()
new HashMap[String, AnnotationVO]
)
}
private def getAnnotationsByUserId(wb: Whiteboard, id: String): Map[String, AnnotationVO] = {
wb.annotationsMap.get(id).getOrElse(Map[String, AnnotationVO]())
}
private def deepMerge(test: Map[String, _], that: Map[String, _]): Map[String, _] =
(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 usersAnnotations = getAnnotationsByUserId(wb, userId)
var newUserAnnotations = usersAnnotations
var newAnnotationsMap = wb.annotationsMap
for (annotation <- annotations) {
newUserAnnotations = newUserAnnotations + (annotation.id -> annotation)
println("Adding annotation to page [" + wb.id + "]. After numAnnotations=[" + newUserAnnotations.size + "].")
val oldAnnotation = wb.annotationsMap.get(annotation.id)
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)
saveWhiteboard(newWb)
annotations
annotationsAdded
}
def getHistory(wbId: String): Array[AnnotationVO] = {
//wb.annotationsMap.values.flatten.toArray.sortBy(_.position);
val wb = getWhiteboard(wbId)
var annotations = Array[AnnotationVO]()
// TODO: revisit this, probably there is a one-liner simple solution
wb.annotationsMap.values.foreach(
user => user.values.foreach(
annotation => annotations = annotations :+ annotation
)
)
annotations
wb.annotationsMap.values.toArray
}
def clearWhiteboard(wbId: String, userId: String): Option[Boolean] = {
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] = {
def deleteAnnotations(wbId: String, userId: String, annotationsIds: Array[String], isPresenter: Boolean, isModerator: Boolean): Array[String] = {
var annotationsIdsRemoved = Array[String]()
val wb = getWhiteboard(wbId)
var newAnnotationsMap = wb.annotationsMap
val usersAnnotations = getAnnotationsByUserId(wb, userId)
var newUserAnnotations = usersAnnotations
for (annotationId <- annotationsIds) {
val annotation = usersAnnotations.get(annotationId)
val annotation = wb.annotationsMap.get(annotationId)
//not empty and annotation exists
if (!usersAnnotations.isEmpty && !annotation.isEmpty) {
newUserAnnotations = newUserAnnotations - annotationId
println("Removing annotation on page [" + wb.id + "]. After numAnnotations=[" + newUserAnnotations.size + "].")
annotationsIdsRemoved = annotationsIdsRemoved :+ annotationId
if (!annotation.isEmpty) {
val hasPermission = isPresenter || isModerator || annotation.get.userId == userId
if (hasPermission) {
newAnnotationsMap -= 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)
saveWhiteboard(newWb)
annotationsIdsRemoved

View File

@ -52,7 +52,7 @@ trait BreakoutRoomCreatedMsgHdlr {
(redirectToHtml5JoinURL, redirectJoinURL) <- BreakoutHdlrHelpers.getRedirectUrls(liveMeeting, user, r.externalId, r.sequence.toString())
} yield (user -> redirectToHtml5JoinURL)
new BreakoutRoomInfo(r.name, r.externalId, r.id, r.sequence, r.shortName, r.isDefaultName, r.freeJoin, html5JoinUrls.toMap)
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)
@ -79,7 +79,7 @@ trait BreakoutRoomCreatedMsgHdlr {
BbbCommonEnvCoreMsg(envelope, event)
}
val breakoutInfo = BreakoutRoomInfo(room.name, room.externalId, room.id, room.sequence, room.shortName, room.isDefaultName, room.freeJoin, 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)
outGW.send(event)

View File

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

View File

@ -52,7 +52,7 @@ trait CreateBreakoutRoomsCmdMsgHdlr extends RightsManagementTrait {
val (internalId, externalId) = BreakoutRoomsUtil.createMeetingIds(liveMeeting.props.meetingProp.intId, 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)
}
@ -70,7 +70,9 @@ trait CreateBreakoutRoomsCmdMsgHdlr extends RightsManagementTrait {
liveMeeting.props.password.moderatorPass,
liveMeeting.props.password.viewerPass,
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)

View File

@ -14,8 +14,8 @@ trait EndAllBreakoutRoomsMsgHdlr extends RightsManagementTrait {
val outGW: OutMsgRouter
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)) {
val meetingId = liveMeeting.props.meetingProp.intId
val reason = "No permission to end breakout rooms for meeting."
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, outGW, liveMeeting)
state
@ -24,11 +24,11 @@ trait EndAllBreakoutRoomsMsgHdlr extends RightsManagementTrait {
model <- state.breakout
} yield {
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(
liveMeeting.props.meetingProp.intId,
meetingId,
"info",
"rooms",
"app.toast.breakoutRoomEnded",

View File

@ -1,7 +1,7 @@
package org.bigbluebutton.core.apps.breakout
import org.bigbluebutton.core.api.EndBreakoutRoomInternalMsg
import org.bigbluebutton.core.bus.{ InternalEventBus }
import org.bigbluebutton.core.api.{ CaptureSharedNotesReqInternalMsg, CapturePresentationReqInternalMsg, EndBreakoutRoomInternalMsg }
import org.bigbluebutton.core.bus.{ BigBlueButtonEvent, InternalEventBus }
import org.bigbluebutton.core.running.{ BaseMeetingActor, HandlerHelpers, LiveMeeting, OutMsgRouter }
trait EndBreakoutRoomInternalMsgHdlr extends HandlerHelpers {
@ -12,6 +12,18 @@ trait EndBreakoutRoomInternalMsgHdlr extends HandlerHelpers {
val eventBus: InternalEventBus
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)
sendEndMeetingDueToExpiry(msg.reason, eventBus, outGW, liveMeeting, "system")
}

View File

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

View File

@ -45,27 +45,31 @@ trait RespondToPollReqMsgHdlr {
bus.outGW.send(msgEvent)
}
for {
(pollId: String, updatedPoll: SimplePollResultOutVO) <- Polls.handleRespondToPollReqMsg(msg.header.userId, msg.body.pollId,
msg.body.questionId, msg.body.answerIds, liveMeeting)
} yield {
broadcastPollUpdatedEvent(msg, pollId, updatedPoll)
if (Polls.checkUserResponded(msg.body.pollId, msg.header.userId, liveMeeting.polls) == false) {
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 {
broadcastPollUpdatedEvent(msg, pollId, updatedPoll)
for {
answerId <- msg.body.answerIds
poll <- Polls.getPoll(pollId, liveMeeting.polls)
} yield {
val answerText = poll.questions(0).answers.get(answerId).key
broadcastUserRespondedToPollRecordMsg(msg, pollId, answerId, answerText, poll.isSecret)
for {
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)
}
}
for {
presenter <- Users2x.findPresenter(liveMeeting.users2x)
} yield {
broadcastUserRespondedToPollRespMsg(msg, pollId, msg.body.answerIds, 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

@ -34,17 +34,23 @@ trait RespondToTypedPollReqMsgHdlr {
bus.outGW.send(msgEvent)
}
for {
(pollId: String, updatedPoll: SimplePollResultOutVO) <- Polls.handleRespondToTypedPollReqMsg(msg.header.userId, msg.body.pollId,
msg.body.questionId, msg.body.answer, liveMeeting)
} yield {
broadcastPollUpdatedEvent(msg, pollId, updatedPoll)
if (Polls.isResponsePollType(msg.body.pollId, liveMeeting.polls) &&
Polls.checkUserResponded(msg.body.pollId, msg.header.userId, liveMeeting.polls) == false &&
Polls.checkUserAddedQuestion(msg.body.pollId, msg.header.userId, liveMeeting.polls) == false) {
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 {
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
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.api.{ CapturePresentationReqInternalMsg, CaptureSharedNotesReqInternalMsg }
import org.bigbluebutton.core.apps.{ PermissionCheck, RightsManagementTrait }
import org.bigbluebutton.core.bus.MessageBus
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 userId = m.header.userId
val userId = m.userId
val presentationPods: Vector[PresentationPod] = state.presentationPodManager.getAllPresentationPodsInMeeting()
val currentPres: Option[PresentationInPod] = presentationPods.flatMap(_.getCurrentPresentation()).headOption
if (liveMeeting.props.meetingProp.disabledFeatures.contains("importPresentationWithAnnotationsFromBreakoutRooms")) {
val reason = "Importing slides from breakout rooms disabled for this meeting."
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)
log.error(s"Capturing breakout rooms slides disabled in meeting ${meetingId}.")
} else if (currentPres.isEmpty) {
log.error(s"No presentation set in meeting ${meetingId}")
} else {
val jobId: String = RandomStringGenerator.randomAlphanumericString(16);
val jobType = "PresentationWithAnnotationExportJob"
val allPages: Boolean = m.body.allPages
val allPages: Boolean = m.allPages
val pageCount = currentPres.get.pages.size
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 parentMeetingId: String = m.body.parentMeetingId
val parentMeetingId: String = m.parentMeetingId
val currentPage: PresentationPage = PresentationInPod.getCurrentPage(currentPres.get).get
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)
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)
LockSettingsUtil.enforceCamLockSettingsForAllUsers(liveMeeting, outGW)
// Dial-in
def buildLockMessage(meetingId: String, userId: String, lockedBy: String, locked: Boolean): BbbCommonEnvCoreMsg = {
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.core.models._
import org.bigbluebutton.core.running.{ LiveMeeting, OutMsgRouter }
import org.bigbluebutton.core2.message.senders.MsgBuilder
import org.bigbluebutton.core2.message.senders.{ MsgBuilder, Sender }
trait RegisterUserReqMsgHdlr {
this: UsersApp =>
@ -22,12 +22,44 @@ trait RegisterUserReqMsgHdlr {
val event = UserRegisteredRespMsg(header, body)
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 regUser = RegisteredUsers.create(msg.body.intUserId, msg.body.extUserId,
msg.body.name, msg.body.role, msg.body.authToken,
msg.body.avatarURL, msg.body.guest, msg.body.authed, guestStatus, msg.body.excludeFromDashboard, false)
checkUserConcurrentAccesses(regUser)
RegisteredUsers.add(liveMeeting.registeredUsers, regUser)
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.core.apps.breakout.BreakoutHdlrHelpers
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 }
trait UserJoinMeetingReqMsgHdlr extends HandlerHelpers {
@ -26,16 +26,31 @@ trait UserJoinMeetingReqMsgHdlr extends HandlerHelpers {
state
case None =>
val newState = userJoinMeeting(outGW, msg.body.authToken, msg.body.clientType, liveMeeting, state)
if (liveMeeting.props.meetingProp.isBreakout) {
BreakoutHdlrHelpers.updateParentMeetingWithUsers(liveMeeting, eventBus)
// Check if maxParticipants has been reached
// User are able to reenter if he already joined previously with the same extId
val userHasJoinedAlready = RegisteredUsers.findWithUserId(msg.body.userId, liveMeeting.registeredUsers) match {
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
VoiceUsers.recoverVoiceUser(liveMeeting.voiceUsers, msg.body.userId)
if (!hasReachedMaxParticipants) {
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 AssignPresenterReqMsgHdlr
with ChangeUserPinStateReqMsgHdlr
with EjectDuplicateUserReqMsgHdlr
with EjectUserFromMeetingCmdMsgHdlr
with EjectUserFromMeetingSysMsgHdlr
with MuteUserCmdMsgHdlr {

View File

@ -5,7 +5,7 @@ import org.bigbluebutton.core.bus.InternalEventBus
import org.bigbluebutton.core.domain.MeetingState2x
import org.bigbluebutton.core.models._
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 {
this: UsersApp =>
@ -24,10 +24,16 @@ trait ValidateAuthTokenReqMsgHdlr extends HandlerHelpers {
liveMeeting.registeredUsers)
regUser match {
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.
// Fail validation if ejected user is rejoining.
// 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)
} else {
if (u.banned) {
@ -36,6 +42,9 @@ trait ValidateAuthTokenReqMsgHdlr extends HandlerHelpers {
} else if (u.loggedOut) {
failReason = "User had 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(
outGW,

View File

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

View File

@ -21,14 +21,24 @@ trait DeleteWhiteboardAnnotationsPubMsgHdlr extends RightsManagementTrait {
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)) {
val meetingId = liveMeeting.props.meetingProp.intId
val reason = "No permission to delete an annotation."
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, bus.outGW, liveMeeting)
}
} 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) {
broadcastEvent(msg, deletedAnnotations)
}

View File

@ -46,13 +46,18 @@ trait SendWhiteboardAnnotationsPubMsgHdlr extends RightsManagementTrait {
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) {
println("============= Printing Sanitized annotations ============")
for (annotation <- msg.body.annotations) {
printAnnotationInfo(annotation)
}
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)
} else {
//val meetingId = liveMeeting.props.meetingProp.intId

View File

@ -11,7 +11,7 @@ case class Whiteboard(
multiUser: Array[String],
oldMultiUser: Array[String],
changedModeOn: Long,
annotationsMap: Map[String, Map[String, AnnotationVO]]
annotationsMap: Map[String, AnnotationVO]
)
class WhiteboardApp2x(implicit val context: ActorContext)
@ -24,9 +24,16 @@ class WhiteboardApp2x(implicit val context: ActorContext)
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 + "]")
liveMeeting.wbModel.addAnnotations(whiteboardId, requesterId, annotations)
liveMeeting.wbModel.addAnnotations(whiteboardId, requesterId, annotations, isPresenter, isModerator)
}
def getWhiteboardAnnotations(whiteboardId: String, liveMeeting: LiveMeeting): Array[AnnotationVO] = {
@ -34,12 +41,15 @@ class WhiteboardApp2x(implicit val context: ActorContext)
liveMeeting.wbModel.getHistory(whiteboardId)
}
def clearWhiteboard(whiteboardId: String, requesterId: String, liveMeeting: LiveMeeting): Option[Boolean] = {
liveMeeting.wbModel.clearWhiteboard(whiteboardId, requesterId)
}
def deleteWhiteboardAnnotations(whiteboardId: String, requesterId: String, annotationsIds: Array[String], liveMeeting: LiveMeeting): Array[String] = {
liveMeeting.wbModel.deleteAnnotations(whiteboardId, requesterId, annotationsIds)
def deleteWhiteboardAnnotations(
whiteboardId: String,
requesterId: String,
annotationsIds: Array[String],
liveMeeting: LiveMeeting,
isPresenter: Boolean,
isModerator: Boolean
): Array[String] = {
liveMeeting.wbModel.deleteAnnotations(whiteboardId, requesterId, annotationsIds, isPresenter, isModerator)
}
def getWhiteboardAccess(whiteboardId: String, liveMeeting: LiveMeeting): Array[String] = {

View File

@ -14,10 +14,12 @@ case class BreakoutRoom2x(
users: Vector[BreakoutUser],
voiceUsers: Vector[BreakoutVoiceUser],
startedOn: Option[Long],
started: Boolean
started: Boolean,
captureNotes: Boolean,
captureSlides: Boolean,
) {
}
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 newBreakoutRoom(parentRoomId: String, id: String, externalMeetingId: String, name: String, sequence: Integer, freeJoin: Boolean,
voiceConfId: String, assignedUsers: Vector[String], breakoutRooms: BreakoutRooms): Option[BreakoutRoomVO] = {
val brvo = new BreakoutRoomVO(id, externalMeetingId, name, parentRoomId, sequence, freeJoin, voiceConfId, assignedUsers, Vector())
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(), captureNotes, captureSlides)
breakoutRooms.add(brvo)
Some(brvo)
}

View File

@ -112,7 +112,7 @@ object Polls {
shape = pollResultToWhiteboardShape(result)
annot <- send(result, shape)
} 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)
(result, annot)
}
@ -238,7 +238,7 @@ object Polls {
private def handleRespondToTypedPoll(poll: SimplePollResultOutVO, requesterId: String, pollId: String, questionId: Int,
answer: String, lm: LiveMeeting): Option[SimplePollResultOutVO] = {
addQuestionResponse(poll.id, questionId, answer, lm.polls)
addQuestionResponse(poll.id, questionId, answer, requesterId, lm.polls)
for {
updatedPoll <- getSimplePollResult(poll.id, lm.polls)
} yield {
@ -355,6 +355,45 @@ object Polls {
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) {
polls.get(pollId) foreach {
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 {
case Some(p) => {
p.addQuestionResponse(questionID, answer)
if (!p.getTypedPollResponders().contains(requesterId)) {
p.addTypedPollResponder(requesterId)
p.addQuestionResponse(questionID, answer)
}
}
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 _numResponders: Int = 0
private var _responders = new ArrayBuffer[Responder]()
private var _respondersTypedPoll = new ArrayBuffer[String]()
def showingResult() { _showResult = true }
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 getResponders(): ArrayBuffer[Responder] = { return _responders }
def addTypedPollResponder(responderId: String) { _respondersTypedPoll += (responderId) }
def getTypedPollResponders(): ArrayBuffer[String] = { return _respondersTypedPoll }
def hasResponses(): Boolean = {
questions.foreach(q => {

View File

@ -64,6 +64,14 @@ object RegisteredUsers {
} 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] = {
findWithExternUserId(user.externId, users) match {

View File

@ -407,4 +407,5 @@ object EjectReasonCode {
val USER_INACTIVITY = "user_inactivity_eject_reason"
val BANNED_USER_REJOINING = "banned_user_rejoining_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
// because the meeting actor might not have been created yet.
route[RegisterUserReqMsg](meetingManagerChannel, envelope, jsonNode)
case EjectDuplicateUserReqMsg.NAME =>
route[EjectDuplicateUserReqMsg](meetingManagerChannel, envelope, jsonNode)
case UserJoinMeetingReqMsg.NAME =>
routeGenericMsg[UserJoinMeetingReqMsg](envelope, jsonNode)
case UserJoinMeetingAfterReconnectReqMsg.NAME =>
@ -175,6 +173,8 @@ class ReceivedJsonMsgHandlerActor(
routePadMsg[PadPatchSysMsg](envelope, jsonNode)
case PadUpdatePubMsg.NAME =>
routeGenericMsg[PadUpdatePubMsg](envelope, jsonNode)
case PadCapturePubMsg.NAME =>
routePadMsg[PadCapturePubMsg](envelope, jsonNode)
// Voice
case RecordingStartedVoiceConfEvtMsg.NAME =>
@ -310,8 +310,6 @@ class ReceivedJsonMsgHandlerActor(
routeGenericMsg[AssignPresenterReqMsg](envelope, jsonNode)
case MakePresentationWithAnnotationDownloadReqMsg.NAME =>
routeGenericMsg[MakePresentationWithAnnotationDownloadReqMsg](envelope, jsonNode)
case ExportPresentationWithAnnotationReqMsg.NAME =>
routeGenericMsg[ExportPresentationWithAnnotationReqMsg](envelope, jsonNode)
case NewPresAnnFileAvailableMsg.NAME =>
routeGenericMsg[NewPresAnnFileAvailableMsg](envelope, jsonNode)

View File

@ -226,7 +226,7 @@ trait HandlerHelpers extends SystemConfiguration {
model <- state.breakout
} yield {
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
// its type is not BbbCommonEnvCoreMsg
case m: RegisterUserReqMsg => usersApp.handleRegisterUserReqMsg(m)
case m: EjectDuplicateUserReqMsg => usersApp.handleEjectDuplicateUserReqMsg(m)
case m: GetAllMeetingsReqMsg => handleGetAllMeetingsReqMsg(m)
case m: GetRunningMeetingStateReqMsg => handleGetRunningMeetingStateReqMsg(m)
case m: ValidateConnAuthTokenSysMsg => handleValidateConnAuthTokenSysMsg(m)
@ -283,7 +282,8 @@ class MeetingActor(
case msg: SendMessageToBreakoutRoomInternalMsg => state = handleSendMessageToBreakoutRoomInternalMsg(msg, state, liveMeeting, msgBus)
case msg: SendBreakoutTimeRemainingInternalMsg =>
handleSendBreakoutTimeRemainingInternalMsg(msg)
case msg: CapturePresentationReqInternalMsg => presentationPodsApp.handle(msg, state, liveMeeting, msgBus)
case msg: CaptureSharedNotesReqInternalMsg => presentationPodsApp.handle(msg, liveMeeting, msgBus)
case msg: SendRecordingTimerInternalMsg =>
state = usersApp.handleSendRecordingTimerInternalMsg(msg, state)
@ -505,8 +505,8 @@ class MeetingActor(
case m: PreuploadedPresentationsSysPubMsg => presentationApp2x.handle(m, liveMeeting, msgBus)
case m: AssignPresenterReqMsg => state = handlePresenterChange(m, state)
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: PadCapturePubMsg => presentationPodsApp.handle(m, liveMeeting, msgBus)
// Presentation Pods
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: StoreExportJobInRedisSysMsg => logMessage(msg)
case m: MakePresentationWithAnnotationDownloadReqMsg => logMessage(msg)
case m: ExportPresentationWithAnnotationReqMsg => logMessage(msg)
case m: NewPresAnnFileAvailableMsg => logMessage(msg)
case m: PresentationPageConversionStartedSysMsg => 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: PadUpdatePubMsg => logMessage(msg)
case m: PadUpdateCmdMsg => logMessage(msg)
case m: PadCapturePubMsg => logMessage(msg)
case _ => // ignore message
}

View File

@ -47,7 +47,7 @@ trait AppsTestFixtures {
val meetingLayout = ""
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,
meetingCameraCap = meetingCameraCap,

View File

@ -27,7 +27,9 @@ case class BreakoutProps(
freeJoin: Boolean,
breakoutRooms: Vector[String],
record: Boolean,
privateChatEnabled: Boolean
privateChatEnabled: Boolean,
captureNotes: Boolean,
captureSlides: Boolean,
)
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 UsersProp(
maxUsers: Int,
webcamsOnlyForModerator: Boolean,
userCameraCap: Int,
guestPolicy: String,
meetingLayout: String,
allowModsToUnmuteUsers: Boolean,
allowModsToEjectCameras: Boolean,
authenticatedGuest: Boolean
maxUsers: Int,
maxUserConcurrentAccesses:Int,
webcamsOnlyForModerator: Boolean,
userCameraCap: Int,
guestPolicy: String,
meetingLayout: String,
allowModsToUnmuteUsers: Boolean,
allowModsToEjectCameras: Boolean,
authenticatedGuest: Boolean
)
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" }
case class BreakoutRoomsListEvtMsg(header: BbbClientMsgHeader, body: BreakoutRoomsListEvtMsgBody) extends BbbCoreMsg
case class BreakoutRoomsListEvtMsgBody(meetingId: String, rooms: Vector[BreakoutRoomInfo], roomsReady: Boolean)
case class BreakoutRoomInfo(name: String, externalId: String, breakoutId: String, sequence: Int, shortName: String, isDefaultName: Boolean, freeJoin: Boolean, 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" }
case class BreakoutRoomsListMsg(header: BbbClientMsgHeader, body: BreakoutRoomsListMsgBody) extends StandardMsg
@ -58,7 +58,9 @@ case class BreakoutRoomDetail(
sourcePresentationId: String,
sourcePresentationSlide: Int,
record: Boolean,
privateChatEnabled: Boolean
privateChatEnabled: Boolean,
captureNotes: Boolean,
captureSlides: Boolean,
)
/**
@ -66,7 +68,7 @@ case class BreakoutRoomDetail(
*/
object CreateBreakoutRoomsCmdMsg { val NAME = "CreateBreakoutRoomsCmdMsg" }
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])
// 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,
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" }
case class PadUpdateCmdMsg(header: BbbCoreHeaderWithMeetingId, body: PadUpdateCmdMsgBody) extends BbbCoreMsg
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 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" }
case class NewPresAnnFileAvailableMsg(header: BbbClientMsgHeader, body: NewPresAnnFileAvailableMsgBody) extends StandardMsg
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 ------------
// ------------ akka-apps to client ------------
@ -40,4 +32,13 @@ case class PresenterUnassignedEvtMsgBody(intId: String, name: String, assignedBy
object NewPresentationEvtMsg { val NAME = "NewPresentationEvtMsg" }
case class NewPresentationEvtMsg(header: BbbClientMsgHeader, body: NewPresentationEvtMsgBody) extends BbbCoreMsg
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 ------------

View File

@ -1,13 +1,5 @@
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" }
case class RegisterUserReqMsg(
header: BbbCoreHeaderWithMeetingId,

View File

@ -49,7 +49,7 @@ trait TestFixtures {
meetingCameraCap = meetingCameraCap,
maxPinnedCameras = maxPinnedCameras,
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,
meetingExpireIfNoUserJoinedInMinutes = meetingExpireIfNoUserJoinedInMinutes, meetingExpireWhenLastUserLeftInMinutes = meetingExpireWhenLastUserLeftInMinutes,

View File

@ -31,7 +31,7 @@ object Dependencies {
val lang = "3.12.0"
val io = "2.11.0"
val pool = "2.11.1"
val text = "1.9"
val text = "1.10.0"
// BigBlueButton
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_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_RECORD = "breakoutRoomsRecord";
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("sequence", m.getSequence().toString());
breakoutMetadata.put("freeJoin", m.isFreeJoin().toString());
breakoutMetadata.put("captureSlides", m.isCaptureSlides().toString());
breakoutMetadata.put("captureNotes", m.isCaptureNotes().toString());
breakoutMetadata.put("parentMeetingId", m.getParentMeetingId());
storeService.recordBreakoutInfo(m.getInternalId(), breakoutMetadata);
}
@ -388,6 +390,8 @@ public class MeetingService implements MessageListener {
if (m.isBreakout()) {
logData.put("sequence", m.getSequence());
logData.put("freeJoin", m.isFreeJoin());
logData.put("captureSlides", m.isCaptureSlides());
logData.put("captureNotes", m.isCaptureNotes());
logData.put("parentMeetingId", m.getParentMeetingId());
}
logData.put("name", m.getName());
@ -415,7 +419,7 @@ public class MeetingService implements MessageListener {
m.getLearningDashboardAccessToken(), m.getCreateTime(),
formatPrettyDate(m.getCreateTime()), m.isBreakout(), m.getSequence(), m.isFreeJoin(), m.getMetadata(),
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.getUserInactivityInspectTimerInMinutes(), m.getUserInactivityThresholdInMinutes(),
m.getUserActivitySignResponseDelayInMinutes(), m.getEndWhenNoModerator(), m.getEndWhenNoModeratorDelayInMinutes(),
@ -434,33 +438,6 @@ public class MeetingService implements MessageListener {
}
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,
message.internalUserId, message.fullname, message.role,
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.SEQUENCE, message.sequence.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.MODERATOR_PW, message.moderatorPassword);
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.clientType);
if(m.getMaxUsers() > 0 && m.getUsers().size() >= m.getMaxUsers()) {
if(m.getMaxUsers() > 0 && m.countUniqueExtIds() >= m.getMaxUsers()) {
m.removeEnteredUser(user.getInternalUserId());
gw.ejectDuplicateUser(message.meetingId, user.getInternalUserId(), user.getFullname(), user.getExternalUserId());
return;
}

View File

@ -66,6 +66,8 @@ public class ParamsProcessorUtil {
private String apiVersion;
private boolean serviceEnabled = false;
private String securitySalt;
private String supportedChecksumAlgorithms;
private String checksumHash;
private int defaultMaxUsers = 20;
private String defaultWelcomeMessage;
private String defaultWelcomeMessageFooter;
@ -106,6 +108,8 @@ public class ParamsProcessorUtil {
private boolean defaultBreakoutRoomsEnabled = true;
private boolean defaultBreakoutRoomsRecord;
private boolean defaultBreakoutRoomsCaptureSlides = false;
private boolean defaultBreakoutRoomsCaptureNotes = false;
private boolean defaultbreakoutRoomsPrivateChatEnabled;
private boolean defaultLockSettingsDisableCam;
@ -128,6 +132,8 @@ public class ParamsProcessorUtil {
private Integer userInactivityThresholdInMinutes = 30;
private Integer userActivitySignResponseDelayInMinutes = 5;
private Boolean defaultAllowDuplicateExtUserid = true;
private Integer maxUserConcurrentAccesses = 0;
private Boolean defaultEndWhenNoModerator = false;
private Integer defaultEndWhenNoModeratorDelayInMinutes = 1;
private Integer defaultHtml5InstanceId = 1;
@ -275,7 +281,19 @@ public class ParamsProcessorUtil {
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) {
@ -680,6 +698,11 @@ public class ParamsProcessorUtil {
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.
Meeting meeting = new Meeting.Builder(externalMeetingId,
internalMeetingId, createTime).withName(meetingName)
@ -706,7 +729,7 @@ public class ParamsProcessorUtil {
.withMeetingLayout(meetingLayout)
.withBreakoutRoomsParams(breakoutParams)
.withLockSettingsParams(lockSettingsParams)
.withAllowDuplicateExtUserid(defaultAllowDuplicateExtUserid)
.withMaxUserConcurrentAccesses(maxUserConcurrentAccesses)
.withHTML5InstanceId(html5InstanceId)
.withLearningDashboardCleanupDelayInMinutes(learningDashboardCleanupMins)
.withLearningDashboardAccessToken(learningDashboardAccessToken)
@ -742,6 +765,8 @@ public class ParamsProcessorUtil {
if (isBreakout) {
meeting.setSequence(Integer.parseInt(params.get(ApiParams.SEQUENCE)));
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);
}
@ -978,11 +1003,39 @@ public class ParamsProcessorUtil {
log.info("CHECKSUM={} length={}", checksum, checksum.length());
String data = apiCall + queryString + securitySalt;
String cs = DigestUtils.sha1Hex(data);
if (checksum.length() == 64) {
cs = DigestUtils.sha256Hex(data);
log.info("SHA256 {}", cs);
}
int checksumLength = checksum.length();
String cs = null;
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)) {
log.info("query string after checksum removed: [{}]", queryString);
log.info("checksumError: query string checksum failed. our: [{}], client: [{}]", cs, checksum);
@ -1068,6 +1121,10 @@ public class ParamsProcessorUtil {
this.securitySalt = securitySalt;
}
public void setSupportedChecksumAlgorithms(String supportedChecksumAlgorithms) { this.supportedChecksumAlgorithms = supportedChecksumAlgorithms; }
public void setChecksumHash(String checksumHash) { this.checksumHash = checksumHash; }
public void setDefaultMaxUsers(int defaultMaxUsers) {
this.defaultMaxUsers = defaultMaxUsers;
}
@ -1367,6 +1424,10 @@ public class ParamsProcessorUtil {
this.defaultAllowDuplicateExtUserid = allow;
}
public void setMaxUserConcurrentAccesses(Integer maxUserConcurrentAccesses) {
this.maxUserConcurrentAccesses = maxUserConcurrentAccesses;
}
public void setEndWhenNoModerator(Boolean val) {
this.defaultEndWhenNoModerator = val;
}

View File

@ -3,9 +3,13 @@ package org.bigbluebutton.api.domain;
public class BreakoutRoomsParams {
public final Boolean record;
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.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 Integer sequence = 0;
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 startTime = 0;
private long endTime = 0;
@ -109,7 +111,7 @@ public class Meeting {
public final BreakoutRoomsParams breakoutRoomsParams;
public final LockSettingsParams lockSettingsParams;
public final Boolean allowDuplicateExtUserid;
public final Integer maxUserConcurrentAccesses;
private String meetingEndedCallbackURL = "";
@ -163,7 +165,7 @@ public class Meeting {
allowRequestsWithoutSession = builder.allowRequestsWithoutSession;
breakoutRoomsParams = builder.breakoutRoomsParams;
lockSettingsParams = builder.lockSettingsParams;
allowDuplicateExtUserid = builder.allowDuplicateExtUserid;
maxUserConcurrentAccesses = builder.maxUserConcurrentAccesses;
endWhenNoModerator = builder.endWhenNoModerator;
endWhenNoModeratorDelayInMinutes = builder.endWhenNoModeratorDelayInMinutes;
html5InstanceId = builder.html5InstanceId;
@ -197,6 +199,28 @@ public class Meeting {
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) {
RegisteredUser ruser = registeredUsers.get(userId);
if (ruser != null) {
@ -288,6 +312,22 @@ public class Meeting {
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() {
return duration;
}
@ -504,6 +544,10 @@ public class Meeting {
return maxUsers;
}
public Integer getMaxUserConcurrentAccesses() {
return maxUserConcurrentAccesses;
}
public int getLogoutTimer() {
return logoutTimer;
}
@ -633,17 +677,6 @@ public class Meeting {
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(){
return this.users.size();
}
@ -843,7 +876,8 @@ public class Meeting {
private String meetingLayout;
private BreakoutRoomsParams breakoutRoomsParams;
private LockSettingsParams lockSettingsParams;
private Boolean allowDuplicateExtUserid;
private Integer maxUserConcurrentAccesses;
private Boolean endWhenNoModerator;
private Integer endWhenNoModeratorDelayInMinutes;
private int html5InstanceId;
@ -1035,8 +1069,8 @@ public class Meeting {
return this;
}
public Builder withAllowDuplicateExtUserid(Boolean allowDuplicateExtUserid) {
this.allowDuplicateExtUserid = allowDuplicateExtUserid;
public Builder withMaxUserConcurrentAccesses(Integer maxUserConcurrentAccesses) {
this.maxUserConcurrentAccesses = maxUserConcurrentAccesses;
return this;
}

View File

@ -19,6 +19,8 @@ public class CreateBreakoutRoom implements IMessage {
public final Integer sourcePresentationSlide;
public final Boolean record;
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,
String parentMeetingId,
@ -35,7 +37,9 @@ public class CreateBreakoutRoom implements IMessage {
String sourcePresentationId,
Integer sourcePresentationSlide,
Boolean record,
Boolean privateChatEnabled) {
Boolean privateChatEnabled,
Boolean captureNotes,
Boolean captureSlides) {
this.meetingId = meetingId;
this.parentMeetingId = parentMeetingId;
this.name = name;
@ -52,5 +56,7 @@ public class CreateBreakoutRoom implements IMessage {
this.sourcePresentationSlide = sourcePresentationSlide;
this.record = record;
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;
import org.bigbluebutton.api.model.constraint.MaxParticipantsConstraint;
import org.bigbluebutton.api.model.constraint.MeetingEndedConstraint;
import org.bigbluebutton.api.model.constraint.MeetingExistsConstraint;
import org.bigbluebutton.api.model.constraint.UserSessionConstraint;

View File

@ -20,6 +20,7 @@ public class GetChecksumValidator implements ConstraintValidator<GetChecksumCons
@Override
public boolean isValid(GetChecksum checksum, ConstraintValidatorContext context) {
String securitySalt = ServiceUtils.getValidationService().getSecuritySalt();
String supportedChecksumAlgorithms = ServiceUtils.getValidationService().getSupportedChecksumAlgorithms();
if (securitySalt.isEmpty()) {
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 createdCheckSum = DigestUtils.sha1Hex(data);
if (providedChecksum.length() == 64) {
log.debug("providedChecksum.length() == 64");
createdCheckSum = DigestUtils.sha256Hex(data);
log.info("SHA256 {}", createdCheckSum);
int checksumLength = providedChecksum.length();
String createdCheckSum = null;
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)) {

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

View File

@ -26,7 +26,7 @@ public interface IBbbWebApiGWApp {
String moderatorPass, String viewerPass, String learningDashboardAccessToken, Long createTime,
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 dialNumber, Integer maxUsers,
String dialNumber, Integer maxUsers, Integer maxUserConcurrentAccesses,
Integer meetingExpireIfNoUserJoinedInMinutes,
Integer meetingExpireWhenLastUserLeftInMinutes,
Integer userInactivityInspectTimerInMinutes,
@ -50,8 +50,6 @@ public interface IBbbWebApiGWApp {
void registerUser(String meetingID, String internalUserId, String fullname, String role,
String externUserID, String authToken, String avatarURL,
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 destroyMeeting(DestroyMeetingMessage msg);

View File

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

View File

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

View File

@ -131,7 +131,9 @@ class BbbWebApiGWApp(
freeJoin: java.lang.Boolean,
metadata: java.util.Map[String, String], guestPolicy: String, authenticatedGuest: java.lang.Boolean, meetingLayout: 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,
meetingExpireWhenLastUserLeftInMinutes: java.lang.Integer,
userInactivityInspectTimerInMinutes: java.lang.Integer,
@ -189,17 +191,23 @@ class BbbWebApiGWApp(
freeJoin = freeJoin.booleanValue(),
breakoutRooms = Vector(),
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,
modOnlyMessage = modOnlyMessage)
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(),
guestPolicy = guestPolicy, meetingLayout = meetingLayout, allowModsToUnmuteUsers = allowModsToUnmuteUsers.booleanValue(),
allowModsToEjectCameras = allowModsToEjectCameras.booleanValue(),
authenticatedGuest = authenticatedGuest.booleanValue())
authenticatedGuest = authenticatedGuest.booleanValue()
)
val metadataProp = MetadataProp(mapAsScalaMap(metadata).toMap)
val lockSettingsProps = LockSettingsProps(
@ -261,11 +269,6 @@ class BbbWebApiGWApp(
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 = {
val event = MsgBuilder.buildGuestWaitingLeftMsg(meetingId, intUserId)
msgToAkkaAppsEventBus.publish(MsgToAkkaApps(toAkkaAppsChannel, event))

View File

@ -34,16 +34,6 @@ object MsgBuilder {
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 = {
val routing = collection.immutable.HashMap("sender" -> "bbb-web")
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,
voiceConfId: String, viewerPassword: String, moderatorPassword: String, duration: 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 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.sourcePresentationSlide,
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 = {

View File

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

View File

@ -1,24 +1,32 @@
const Logger = require('../lib/utils/logger');
const axios = require('axios').default;
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 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 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) => {
const worker = new Worker('./workers/process.js', {workerData: jobId});
const worker = new Worker(`./workers/${workerType}.js`, {workerData: data});
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
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 exportJob = JSON.parse(job);
// Collect the annotations from Redis
(async () => {
async function collectAnnotationsFromRedis() {
const client = redis.createClient({
host: config.redis.host,
port: config.redis.port,
@ -42,7 +49,7 @@ const exportJob = JSON.parse(job);
await client.connect();
const presAnn = await client.hGetAll(exportJob.jobId);
const presAnn = await client.hGetAll(jobId);
// Remove annotations from Redis
await client.del(jobId);
@ -95,8 +102,66 @@ const exportJob = JSON.parse(job);
} else if (fs.existsSync(`${presFile}.jpeg`)) {
fs.copyFileSync(`${presFile}.jpeg`, path.join(dropbox, 'slide1.jpeg'));
} 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 [jobType, jobId, filename_with_extension] = workerData;
const [jobType, jobId, filename] = workerData;
const logger = new Logger('presAnn Notifier Worker');
@ -30,7 +30,7 @@ async function notifyMeetingActor() {
const link = path.join(`${path.sep}bigbluebutton`, 'presentation',
exportJob.parentMeetingId, exportJob.parentMeetingId,
exportJob.presId, 'pdf', jobId, filename_with_extension);
exportJob.presId, 'pdf', jobId, filename);
const notification = {
envelope: {
@ -59,27 +59,35 @@ async function notifyMeetingActor() {
client.disconnect();
}
/** Upload PDF to a BBB room */
async function upload() {
/** Upload PDF to a BBB room
* @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 formData = new FormData();
const file = `${exportJob.presLocation}/pdfs/${jobId}/${filename_with_extension}`;
formData.append('conference', exportJob.parentMeetingId);
formData.append('pod_id', config.notifier.pod_id);
formData.append('is_downloadable', config.notifier.is_downloadable);
formData.append('temporaryPresentationId', jobId);
formData.append('fileUpload', fs.createReadStream(file));
formData.append('fileUpload', fs.createReadStream(filePath));
const res = await axios.post(callbackUrl, formData,
{headers: formData.getHeaders()});
logger.info(`Upload of job ${exportJob.jobId} returned ${res.data}`);
try {
const res = await axios.post(callbackUrl, formData,
{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') {
notifyMeetingActor();
} 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 {
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 path = require('path');
const sanitize = require('sanitize-filename');
const {getStroke, getStrokePoints} = require('perfect-freehand');
const {getStrokePoints, getStrokeOutlinePoints} = require('perfect-freehand');
const probe = require('probe-image-size');
const jobId = workerData;
@ -193,17 +193,17 @@ function get_gap(dash, size) {
function get_stroke_width(dash, size) {
switch (size) {
case 'small': if (dash === 'draw') {
return 1;
return 2;
} else {
return 4;
}
case 'medium': if (dash === 'draw') {
return 1.75;
return 3.5;
} else {
return 6.25;
}
case 'large': if (dash === 'draw') {
return 2.5;
return 5;
} else {
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) {
// Gets inner path of a stroke outline
// For solid, dashed, and dotted types
const stroke = getStrokePoints(annotationPoints)
.map((strokePoint) => strokePoint.point);
let [max_x, max_y] = [0, 0];
const inner_path = stroke.reduce(
/**
* Turns an array of points into a path of quadradic curves.
* @param {Array} annotationPoints
* @param {Boolean} closed - whether the path end and start should be connected (default)
* @return {Array} - an SVG quadratic curve path
*/
function getSvgPath(annotationPoints, closed = true) {
const svgPath = annotationPoints.reduce(
(acc, [x0, y0], i, arr) => {
if (!arr[i + 1]) return acc;
const [x1, y1] = arr[i + 1];
if (x1 >= max_x) {
max_x = x1;
}
if (y1 >= max_y) {
max_y = y1;
}
acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2);
acc.push(x0.toFixed(2), y0.toFixed(2), ((x0 + x1) / 2).toFixed(2), ((y0 + y1) / 2).toFixed(2));
return acc;
},
['M', ...stroke[0], 'Q'],
['M', ...annotationPoints[0], 'Q'],
);
return [inner_path, max_x, max_y];
}
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];
if (closed) svgPath.push('Z');
return svgPath;
}
function circleFromThreePoints(A, B, C) {
@ -471,49 +436,94 @@ function overlay_arrow(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 [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 gap = get_gap(dash, annotation.style.size);
const [x, y] = annotation.point;
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 shapeTransform = `translate(${x} ${y}), rotate(${rotation} ${max_x / 2} ${max_y / 2})`;
const fill = isDashDraw ? shapeColor : 'none';
// Fill assuming solid, small pencil used
// when path start- and end points overlap
const shapeIsFilled =
const rotation = rad_to_degree(annotation.rotation);
const [x, y] = annotation.point;
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.points.length > 3 &&
shapePointsLength > 3 &&
Math.round(distance(
annotation.points[0][0],
annotation.points[0][1],
annotation.points[annotation.points.length - 1][0],
annotation.points[annotation.points.length - 1][1],
)) <= 2 * get_stroke_width('solid', 'small');
shapePoints[0][0],
shapePoints[0][1],
shapePoints[shapePointsLength - 1][0],
shapePoints[shapePointsLength - 1][1],
)) <= 2 * thickness;
if (shapeIsFilled) {
if (isShapeFilled) {
const shapeArea = strokePoints.map((strokePoint) => strokePoint.point);
svg.ele('path', {
style: `fill:${shapeFillColor};`,
d: getPath(annotation.points)[0] + 'Z',
d: getSvgPath(shapeArea),
transform: shapeTransform,
}).up();
}
svg.ele('path', {
style: `stroke:${shapeColor};stroke-width:${thickness};fill:${fill};${stroke_dasharray}`,
d: calculated_path,
transform: shapeTransform,
});
if (isDashDraw) {
const strokeOutlinePoints = getStrokeOutlinePoints(strokePoints, options);
const svgPath = getSvgPath(strokeOutlinePoints);
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) {
@ -603,19 +613,23 @@ function overlay_shape_label(svg, annotation) {
render_textbox(fontColor, font, fontSize, textAlign, text, id);
const dimensions = probe.sync(fs.readFileSync(path.join(dropbox, `text${id}.png`)));
const labelWidth = dimensions.width / config.process.textScaleFactor;
const labelHeight = dimensions.height / config.process.textScaleFactor;
const shape_label = path.join(dropbox, `text${id}.png`);
svg.ele('g', {
transform: `rotate(${rotation} ${label_center_x} ${label_center_y})`,
}).ele('image', {
'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();
if (fs.existsSync(shape_label)) {
const dimensions = probe.sync(fs.readFileSync(shape_label));
const labelWidth = dimensions.width / config.process.textScaleFactor;
const labelHeight = dimensions.height / config.process.textScaleFactor;
svg.ele('g', {
transform: `rotate(${rotation} ${label_center_x} ${label_center_y})`,
}).ele('image', {
'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) {
@ -712,32 +726,30 @@ function overlay_text(svg, annotation) {
}
function overlay_annotation(svg, currentAnnotation) {
if (currentAnnotation.childIndex >= 1) {
switch (currentAnnotation.type) {
case 'arrow':
overlay_arrow(svg, currentAnnotation);
break;
case 'draw':
overlay_draw(svg, currentAnnotation);
break;
case 'ellipse':
overlay_ellipse(svg, currentAnnotation);
break;
case 'rectangle':
overlay_rectangle(svg, currentAnnotation);
break;
case 'sticky':
overlay_sticky(svg, currentAnnotation);
break;
case 'triangle':
overlay_triangle(svg, currentAnnotation);
break;
case 'text':
overlay_text(svg, currentAnnotation);
break;
default:
logger.info(`Unknown annotation type ${currentAnnotation.type}.`);
}
switch (currentAnnotation.type) {
case 'arrow':
overlay_arrow(svg, currentAnnotation);
break;
case 'draw':
overlay_draw(svg, currentAnnotation);
break;
case 'ellipse':
overlay_ellipse(svg, currentAnnotation);
break;
case 'rectangle':
overlay_rectangle(svg, currentAnnotation);
break;
case 'sticky':
overlay_sticky(svg, currentAnnotation);
break;
case 'triangle':
overlay_triangle(svg, currentAnnotation);
break;
case 'text':
overlay_text(svg, currentAnnotation);
break;
default:
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 (
<table className="w-full">
<thead>
@ -104,7 +142,16 @@ class PollsTable extends React.Component {
.sort((a, b) => ((a.createdOn > b.createdOn) ? 1 : -1))
.map((poll) => (
<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
? (
<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
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')"
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
if stunclient --mode full --localport 30000 $STUN_SERVER | grep -q "fail\|Unable\ to\ resolve"; then
# stun is from the stun-client package, which is available on both bionic and focal
# 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 "# Warning: Failed to verify STUN server at $STUN_SERVER with command"
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 "#"
fi
fi

View File

@ -1,20 +1,27 @@
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(whiteboardId, String);
check(annotation, Object);
const {
id, annotationInfo, wbId,
id, wbId,
} = annotation;
let { annotationInfo } = annotation;
const selector = {
meetingId,
id,
userId,
};
const oldAnnotation = Annotations.findOne(selector);
if (oldAnnotation) {
annotationInfo = _.merge(oldAnnotation.annotationInfo, annotationInfo)
}
const modifier = {
$set: {
whiteboardId,

View File

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

View File

@ -1,6 +1,6 @@
import Settings from '/imports/ui/services/settings';
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 DEFAULT_INPUT_DEVICE_ID = '';
@ -38,10 +38,10 @@ const getCurrentAudioSinkId = () => {
return audioElement?.sinkId || DEFAULT_OUTPUT_DEVICE_ID;
};
const getStoredAudioInputDeviceId = () => Storage.getItem(INPUT_DEVICE_ID_KEY);
const getStoredAudioOutputDeviceId = () => Storage.getItem(OUTPUT_DEVICE_ID_KEY);
const storeAudioInputDeviceId = (deviceId) => Storage.setItem(INPUT_DEVICE_ID_KEY, deviceId);
const storeAudioOutputDeviceId = (deviceId) => Storage.setItem(OUTPUT_DEVICE_ID_KEY, deviceId);
const getStoredAudioInputDeviceId = () => BBBStorage.getItem(INPUT_DEVICE_ID_KEY);
const getStoredAudioOutputDeviceId = () => BBBStorage.getItem(OUTPUT_DEVICE_ID_KEY);
const storeAudioInputDeviceId = (deviceId) => BBBStorage.setItem(INPUT_DEVICE_ID_KEY, deviceId);
const storeAudioOutputDeviceId = (deviceId) => BBBStorage.setItem(OUTPUT_DEVICE_ID_KEY, deviceId);
/**
* 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 { 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 CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const BREAKOUT_LIM = Meteor.settings.public.app.breakouts.breakoutRoomLimit;
@ -24,6 +24,8 @@ export default function createBreakoutRoom(rooms, durationInMinutes, record = fa
}
const payload = {
record,
captureNotes,
captureSlides,
durationInMinutes,
rooms,
meetingId,

View File

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

View File

@ -6,6 +6,7 @@ import padUpdated from './handlers/padUpdated';
import padContent from './handlers/padContent';
import padTail from './handlers/padTail';
import sessionDeleted from './handlers/sessionDeleted';
import captureSharedNotes from './handlers/captureSharedNotes';
RedisPubSub.on('PadGroupCreatedRespMsg', groupCreated);
RedisPubSub.on('PadCreatedRespMsg', padCreated);
@ -14,3 +15,4 @@ RedisPubSub.on('PadUpdatedEvtMsg', padUpdated);
RedisPubSub.on('PadContentEvtMsg', padContent);
RedisPubSub.on('PadTailEvtMsg', padTail);
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,
meetingIsBreakout,
subscriptionsReady,
User,
} = this.props;
if ((loading || !subscriptionsReady) && !meetingHasEnded && meetingExist) {
return (<LoadingScreen>{loading}</LoadingScreen>);
}
if (meetingIsBreakout && (ejected || userRemoved)) {
Base.setExitReason('removedFromBreakout').finally(() => {
Meteor.disconnect();
window.close();
});
return null;
}
if (ejected) {
return (
<MeetingEnded
@ -281,21 +272,19 @@ class Base extends Component {
);
}
if ((meetingHasEnded || User?.loggedOut) && meetingIsBreakout) {
const reason = meetingHasEnded ? 'breakoutEnded' : 'logout';
Base.setExitReason(reason).finally(() => {
if (meetingHasEnded && meetingIsBreakout) {
Base.setExitReason('breakoutEnded').finally(() => {
Meteor.disconnect();
window.close();
});
return null;
}
if ((meetingHasEnded && !meetingIsBreakout) || (codeError && User?.loggedOut)) {
if (meetingHasEnded && !meetingIsBreakout) {
return (
<MeetingEnded
code={codeError}
endedReason={meetingEndedReason}
ejectedReason={ejectedReason}
callback={() => Base.setExitReason('meetingEnded')}
/>
);

View File

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

View File

@ -318,7 +318,7 @@ class ActionsDropdown extends PureComponent {
}
actions={children}
opts={{
id: "default-dropdown-menu",
id: "actions-dropdown-menu",
keepMounted: true,
transitionDuration: 0,
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 Styled from './styles';
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;
@ -82,6 +84,14 @@ const intlMessages = defineMessages({
id: 'app.createBreakoutRoom.freeJoin',
description: 'free join label',
},
captureNotesLabel: {
id: 'app.createBreakoutRoom.captureNotes',
description: 'capture shared notes label',
},
captureSlidesLabel: {
id: 'app.createBreakoutRoom.captureSlides',
description: 'capture slides label',
},
roomLabel: {
id: 'app.createBreakoutRoom.room',
description: 'Room label',
@ -150,6 +160,10 @@ const intlMessages = defineMessages({
id: 'app.createBreakoutRoom.roomNameInputDesc',
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;
@ -200,6 +214,8 @@ class BreakoutRoom extends PureComponent {
this.handleDismiss = this.handleDismiss.bind(this);
this.setInvitationConfig = this.setInvitationConfig.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.removeRoomUsers = this.removeRoomUsers.bind(this);
this.renderErrorMessages = this.renderErrorMessages.bind(this);
@ -220,6 +236,8 @@ class BreakoutRoom extends PureComponent {
roomNameDuplicatedIsValid: true,
roomNameEmptyIsValid: true,
record: false,
captureNotes: false,
captureSlides: false,
durationIsValid: true,
breakoutJoinedUsers: null,
};
@ -310,6 +328,7 @@ class BreakoutRoom extends PureComponent {
users.forEach((u, index) => {
if (`roomUserItem-${u.userId}` === document.activeElement.id) {
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,
freeJoin,
record,
captureNotes,
captureSlides,
numberOfRoomsIsValid,
numberOfRooms,
durationTime,
@ -430,7 +451,7 @@ class BreakoutRoom extends PureComponent {
sequence: seq,
}));
createBreakoutRoom(rooms, durationTime, record);
createBreakoutRoom(rooms, durationTime, record, captureNotes, captureSlides);
Session.set('isUserListOpen', true);
}
@ -567,6 +588,14 @@ class BreakoutRoom extends PureComponent {
this.setState({ record: e.target.checked });
}
setCaptureNotes(e) {
this.setState({ captureNotes: e.target.checked });
}
setCaptureSlides(e) {
this.setState({ captureSlides: e.target.checked });
}
getUserByRoom(room) {
const { users } = this.state;
return users.filter((user) => user.room === room);
@ -602,17 +631,24 @@ class BreakoutRoom extends PureComponent {
}
changeUserRoom(userId, room) {
const { intl } = this.props;
const { users, freeJoin } = this.state;
const idxUser = users.findIndex((user) => user.userId === userId.replace('roomUserItem-', ''));
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({
users: usersCopy,
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 (
<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.FreeJoinLabel>
<Styled.BreakoutNameInput
@ -797,7 +833,7 @@ class BreakoutRoom extends PureComponent {
<Styled.BreakoutBox id="breakoutBox-0" onDrop={drop(0)} onDragOver={allowDrop} tabIndex={0}>
{this.renderUserItemByRoom(0)}
</Styled.BreakoutBox>
<Styled.SpanWarn valid={leastOneUserIsValid}>
<Styled.SpanWarn data-test="warningNoUserAssigned" valid={leastOneUserIsValid}>
{intl.formatMessage(intlMessages.leastOneWarnBreakout)}
</Styled.SpanWarn>
</Styled.Alert>
@ -814,6 +850,7 @@ class BreakoutRoom extends PureComponent {
onBlur={changeRoomName(value)}
aria-label={`${this.getRoomName(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}
/>
<div aria-hidden id={`room-input-${value}`} className="sr-only">
@ -885,6 +922,7 @@ class BreakoutRoom extends PureComponent {
onChange={this.changeDurationTime}
onBlur={this.blurDurationTime}
aria-label={intl.formatMessage(intlMessages.duration)}
data-test="durationTime"
/>
<Styled.HoldButtonWrapper
key="decrease-breakout-time"
@ -902,6 +940,7 @@ class BreakoutRoom extends PureComponent {
hideLabel
circle
size="sm"
data-test="decreaseBreakoutTime"
/>
</Styled.HoldButtonWrapper>
<Styled.HoldButtonWrapper
@ -918,6 +957,7 @@ class BreakoutRoom extends PureComponent {
hideLabel
circle
size="sm"
data-test="increaseBreakoutTime"
/>
</Styled.HoldButtonWrapper>
</Styled.DurationArea>
@ -979,11 +1019,15 @@ class BreakoutRoom extends PureComponent {
}
renderCheckboxes() {
const { intl, isUpdate, isBreakoutRecordable } = this.props;
const {
intl, isUpdate, isBreakoutRecordable,
} = this.props;
if (isUpdate) return null;
const {
freeJoin,
record,
captureNotes,
captureSlides,
} = this.state;
return (
<Styled.CheckBoxesContainer key="breakout-checkboxes">
@ -1013,6 +1057,38 @@ class BreakoutRoom extends PureComponent {
</Styled.FreeJoinLabel>
) : 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>
);
}

View File

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

View File

@ -1,17 +1,18 @@
import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
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 { layoutDispatch } from '../../layout/context';
import PollService from '/imports/ui/components/poll/service';
const QuickPollDropdownContainer = (props) => {
const layoutContextDispatch = layoutDispatch();
return <QuickPollDropdown {...{ layoutContextDispatch, ...props }} />;
};
export default withTracker(() => ({
activePoll: Session.get('pollInitiated') || false,
pollTypes: PollService.pollTypes,
stopPoll: () => makeCall('stopPoll'),
}))(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';
const { isMobile } = deviceInfo;
const { isSafari, isMobileApp } = browserInfo;
const { isSafari, isTabletApp } = browserInfo;
const propTypes = {
intl: PropTypes.objectOf(Object).isRequired,
@ -163,7 +163,7 @@ const ScreenshareButton = ({
? intlMessages.stopDesktopShareDesc : intlMessages.desktopShareDesc;
const shouldAllowScreensharing = enabled
&& ( !isMobile || isMobileApp)
&& ( !isMobile || isTabletApp)
&& amIPresenter;
const dataTest = isVideoBroadcasting ? 'stopScreenShare' : 'startScreenShare';

View File

@ -63,7 +63,7 @@ export default {
isBreakoutRecordable: () => Meetings.findOne({ meetingId: Auth.meetingID },
{ fields: { 'breakoutProps.record': 1 } }).breakoutProps.record,
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 }),
breakoutJoinedUsers: () => Breakouts.find({
joinedUsers: { $exists: true },

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,10 @@
import React from 'react';
import React, { useRef, useEffect } from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
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';
const intlMessages = defineMessages({
@ -13,18 +16,155 @@ const intlMessages = defineMessages({
id: 'app.audio.captions.button.stop',
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 = ({
intl,
active,
isRTL,
enabled,
currentSpeechLocale,
availableVoices,
isSupported,
isVoiceUser,
}) => {
const onClick = () => Service.setAudioCaptions(!active);
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
icon={active ? 'closed_caption' : 'closed_caption_stop'}
label={intl.formatMessage(active ? intlMessages.stop : intlMessages.start)}
@ -33,9 +173,42 @@ const CaptionsButton = ({
hideLabel
circle
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 = {
@ -43,7 +216,12 @@ CaptionsButton.propTypes = {
formatMessage: PropTypes.func.isRequired,
}).isRequired,
active: PropTypes.bool.isRequired,
isRTL: 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);

View File

@ -2,10 +2,24 @@ import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import Service from '/imports/ui/components/audio/captions/service';
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} />;
export default withTracker(() => ({
enabled: Service.hasAudioCaptions(),
active: Service.getAudioCaptions(),
}))(Container);
export default withTracker(() => {
const isRTL = document.documentElement.getAttribute('dir') === 'rtl';
const availableVoices = SpeechService.getSpeechVoices();
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 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)`
${({ 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 {
ClosedCaptionToggleButton,
SpanButtonWrapper,
TranscriptionToggle,
TitleLabel,
EnableTrascription,
DisableTrascription,
SelectedLabel,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -67,6 +67,11 @@ const Content = styled.div`
justify-content: space-between;
font-size: ${fontSizeSmall};
font-weight: bold;
padding: ${borderSize} ${borderSize} ${borderSize} 0;
[dir="rtl"] & {
padding: ${borderSize} 0 ${borderSize} ${borderSize};
}
`;
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';
const WriterMenuModal = styled(Modal)`
padding: 1.5rem;
padding: 1rem;
min-height: 20rem;
`;
@ -51,7 +51,6 @@ const Content = styled.div`
const StartBtn = styled(Button)`
align-self: center;
margin: 0;
width: 40%;
display: block;
position: absolute;
bottom: 20px;

View File

@ -14,6 +14,7 @@ import { usePreviousValue } from '/imports/ui/components/utils/hooks';
const CHAT_CONFIG = Meteor.settings.public.chat;
const PUBLIC_CHAT_CLEAR = CHAT_CONFIG.chat_clear;
const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id;
const POLL_RESULT_KEY = CHAT_CONFIG.system_messages_keys.chat_poll_result;
const propTypes = {
pushAlertEnabled: PropTypes.bool.isRequired,
@ -56,6 +57,14 @@ const intlMessages = defineMessages({
id: 'app.toast.chat.private',
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
@ -168,6 +177,13 @@ const ChatAlert = (props) => {
</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)) {
return null;
}
@ -175,9 +191,15 @@ const ChatAlert = (props) => {
return pushAlertEnabled
? unreadMessages.map((timeWindow) => {
const mappedMessage = Service.mapGroupMessage(timeWindow);
const content = mappedMessage
? createMessage(mappedMessage.sender.name, mappedMessage.content.slice(-5))
: null;
let content = 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;

View File

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

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