Merge branch 'v3.0.x-release' into remove-unused-talking-indicator

This commit is contained in:
Ramón Souza 2023-11-22 15:42:05 -03:00 committed by GitHub
commit 889d72dd40
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
154 changed files with 3675 additions and 2516 deletions

View File

@ -113,6 +113,12 @@ case class EjectUserFromBreakoutInternalMsg(parentId: String, breakoutId: String
*/
case class CapturePresentationReqInternalMsg(userId: String, parentMeetingId: String, filename: String, allPages: Boolean = true) extends InMessage
/**
* Sent to the same meeting to force a new presenter to the Pod
* @param presenterId
*/
case class SetPresenterInDefaultPodInternalMsg(presenterId: String) extends InMessage
/**
* Sent by breakout room to parent meeting to obtain padId
* @param breakoutId

View File

@ -11,7 +11,7 @@ class PresentationPodHdlrs(implicit val context: ActorContext)
with PresentationConversionCompletedSysPubMsgHdlr
with PdfConversionInvalidErrorSysPubMsgHdlr
with SetCurrentPagePubMsgHdlr
with SetPresenterInPodReqMsgHdlr
with SetPresenterInDefaultPodInternalMsgHdlr
with RemovePresentationPubMsgHdlr
with SetPresentationDownloadablePubMsgHdlr
with PresentationConversionUpdatePubMsgHdlr

View File

@ -0,0 +1,68 @@
package org.bigbluebutton.core.apps.presentationpod
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.api.SetPresenterInDefaultPodInternalMsg
import org.bigbluebutton.core.apps.{ RightsManagementTrait }
import org.bigbluebutton.core.bus.MessageBus
import org.bigbluebutton.core.domain.MeetingState2x
import org.bigbluebutton.core.running.{ LiveMeeting, OutMsgRouter }
import org.bigbluebutton.core.models.{ PresentationPod, Users2x }
trait SetPresenterInDefaultPodInternalMsgHdlr {
this: PresentationPodHdlrs =>
def handleSetPresenterInDefaultPodInternalMsg(
msg: SetPresenterInDefaultPodInternalMsg, state: MeetingState2x,
liveMeeting: LiveMeeting, bus: MessageBus
): MeetingState2x = {
// Swith presenter as default presenter pod has changed.
log.info("Presenter pod change will trigger a presenter change")
SetPresenterInPodActionHandler.handleAction(state, liveMeeting, bus.outGW, "", PresentationPod.DEFAULT_PRESENTATION_POD, msg.presenterId)
}
}
object SetPresenterInPodActionHandler extends RightsManagementTrait {
def handleAction(
state: MeetingState2x,
liveMeeting: LiveMeeting,
outGW: OutMsgRouter,
assignedBy: String,
podId: String,
newPresenterId: String
): MeetingState2x = {
def broadcastSetPresenterInPodRespMsg(podId: String, nextPresenterId: String, requesterId: String): Unit = {
val routing = Routing.addMsgToClientRouting(
MessageTypes.BROADCAST_TO_MEETING,
liveMeeting.props.meetingProp.intId, requesterId
)
val envelope = BbbCoreEnvelope(SetPresenterInPodRespMsg.NAME, routing)
val header = BbbClientMsgHeader(SetPresenterInPodRespMsg.NAME, liveMeeting.props.meetingProp.intId, requesterId)
val body = SetPresenterInPodRespMsgBody(podId, nextPresenterId)
val event = SetPresenterInPodRespMsg(header, body)
val msgEvent = BbbCommonEnvCoreMsg(envelope, event)
outGW.send(msgEvent)
}
val newState = for {
user <- Users2x.findWithIntId(liveMeeting.users2x, newPresenterId)
pod <- PresentationPodsApp.getPresentationPod(state, podId)
} yield {
if (pod.currentPresenter != "") {
Users2x.removeUserFromPresenterGroup(liveMeeting.users2x, pod.currentPresenter)
liveMeeting.users2x.addOldPresenter(pod.currentPresenter)
}
Users2x.addUserToPresenterGroup(liveMeeting.users2x, newPresenterId)
val updatedPod = pod.setCurrentPresenter(newPresenterId)
broadcastSetPresenterInPodRespMsg(pod.id, newPresenterId, assignedBy)
val pods = state.presentationPodManager.addPod(updatedPod)
state.update(pods)
}
newState match {
case Some(ns) => ns
case None => state
}
}
}

View File

@ -1,78 +0,0 @@
package org.bigbluebutton.core.apps.presentationpod
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.apps.users.AssignPresenterActionHandler
import org.bigbluebutton.core.apps.{ PermissionCheck, RightsManagementTrait }
import org.bigbluebutton.core.bus.MessageBus
import org.bigbluebutton.core.domain.MeetingState2x
import org.bigbluebutton.core.running.{ LiveMeeting, OutMsgRouter }
import org.bigbluebutton.core.models.{ PresentationPod, Users2x }
trait SetPresenterInPodReqMsgHdlr {
this: PresentationPodHdlrs =>
def handle(
msg: SetPresenterInPodReqMsg, state: MeetingState2x,
liveMeeting: LiveMeeting, bus: MessageBus
): MeetingState2x = {
if (msg.body.podId == PresentationPod.DEFAULT_PRESENTATION_POD) {
// Swith presenter as default presenter pod has changed.
log.info("Presenter pod change will trigger a presenter change")
AssignPresenterActionHandler.handleAction(liveMeeting, bus.outGW, msg.header.userId, msg.body.nextPresenterId)
}
SetPresenterInPodActionHandler.handleAction(state, liveMeeting, bus.outGW, msg.header.userId, msg.body.podId, msg.body.nextPresenterId)
}
}
object SetPresenterInPodActionHandler extends RightsManagementTrait {
def handleAction(
state: MeetingState2x,
liveMeeting: LiveMeeting,
outGW: OutMsgRouter,
assignedBy: String,
podId: String,
newPresenterId: String
): MeetingState2x = {
if (permissionFailed(PermissionCheck.MOD_LEVEL, PermissionCheck.VIEWER_LEVEL, liveMeeting.users2x, assignedBy)) {
val meetingId = liveMeeting.props.meetingProp.intId
val reason = "No permission to set presenter in presentation pod."
PermissionCheck.ejectUserForFailedPermission(meetingId, assignedBy, reason, outGW, liveMeeting)
state
} else {
def broadcastSetPresenterInPodRespMsg(podId: String, nextPresenterId: String, requesterId: String): Unit = {
val routing = Routing.addMsgToClientRouting(
MessageTypes.BROADCAST_TO_MEETING,
liveMeeting.props.meetingProp.intId, requesterId
)
val envelope = BbbCoreEnvelope(SetPresenterInPodRespMsg.NAME, routing)
val header = BbbClientMsgHeader(SetPresenterInPodRespMsg.NAME, liveMeeting.props.meetingProp.intId, requesterId)
val body = SetPresenterInPodRespMsgBody(podId, nextPresenterId)
val event = SetPresenterInPodRespMsg(header, body)
val msgEvent = BbbCommonEnvCoreMsg(envelope, event)
outGW.send(msgEvent)
}
val newState = for {
user <- Users2x.findWithIntId(liveMeeting.users2x, newPresenterId)
pod <- PresentationPodsApp.getPresentationPod(state, podId)
} yield {
if (pod.currentPresenter != "") {
Users2x.removeUserFromPresenterGroup(liveMeeting.users2x, pod.currentPresenter)
liveMeeting.users2x.addOldPresenter(pod.currentPresenter)
}
Users2x.addUserToPresenterGroup(liveMeeting.users2x, newPresenterId)
val updatedPod = pod.setCurrentPresenter(newPresenterId)
broadcastSetPresenterInPodRespMsg(pod.id, newPresenterId, assignedBy)
val pods = state.presentationPodManager.addPod(updatedPod)
state.update(pods)
}
newState match {
case Some(ns) => ns
case None => state
}
}
}
}

View File

@ -2,8 +2,11 @@ package org.bigbluebutton.core.apps.users
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.apps.RightsManagementTrait
import org.bigbluebutton.core.models.{ UserState, Users2x }
import org.bigbluebutton.core.apps.groupchats.GroupChatApp
import org.bigbluebutton.core.db.ChatMessageDAO
import org.bigbluebutton.core.models.{ GroupChatFactory, GroupChatMessage, Roles, UserState, Users2x }
import org.bigbluebutton.core.running.{ LiveMeeting, OutMsgRouter }
import org.bigbluebutton.core2.MeetingStatus2x
import org.bigbluebutton.core2.message.senders.MsgBuilder
trait ChangeUserAwayReqMsgHdlr extends RightsManagementTrait {
@ -30,6 +33,8 @@ trait ChangeUserAwayReqMsgHdlr extends RightsManagementTrait {
outGW.send(msgEventChange)
}
val permissions = MeetingStatus2x.getPermissions(liveMeeting.status)
for {
user <- Users2x.findWithIntId(liveMeeting.users2x, msg.body.userId)
newUserState <- Users2x.setUserAway(liveMeeting.users2x, user.intId, msg.body.away)
@ -44,6 +49,14 @@ trait ChangeUserAwayReqMsgHdlr extends RightsManagementTrait {
outGW.send(MsgBuilder.buildUserEmojiChangedEvtMsg(liveMeeting.props.meetingProp.intId, msg.body.userId, "none"))
}
val msgMeta = Map(
"away" -> msg.body.away
)
if (!(user.role == Roles.VIEWER_ROLE && user.locked && permissions.disablePubChat) && ((user.away && !msg.body.away) || (!user.away && msg.body.away))) {
ChatMessageDAO.insertSystemMsg(liveMeeting.props.meetingProp.intId, GroupChatApp.MAIN_PUBLIC_CHAT, "", GroupChatMessageType.USER_AWAY_STATUS_MSG, msgMeta, user.name)
}
broadcast(newUserState, msg.body.away)
}
}

View File

@ -60,7 +60,8 @@ trait RegisterUserReqMsgHdlr {
val regUser = RegisteredUsers.create(msg.body.intUserId, msg.body.extUserId,
msg.body.name, msg.body.role, msg.body.authToken, msg.body.sessionToken,
msg.body.avatarURL, ColorPicker.nextColor(liveMeeting.props.meetingProp.intId), msg.body.guest, msg.body.authed, guestStatus, msg.body.excludeFromDashboard, msg.body.customParameters, false)
msg.body.avatarURL, ColorPicker.nextColor(liveMeeting.props.meetingProp.intId), msg.body.guest, msg.body.authed,
guestStatus, msg.body.excludeFromDashboard, msg.body.enforceLayout, msg.body.customParameters, false)
checkUserConcurrentAccesses(regUser)
RegisteredUsers.add(liveMeeting.registeredUsers, regUser, liveMeeting.props.meetingProp.intId)

View File

@ -2,15 +2,14 @@ package org.bigbluebutton.core.apps.users
import org.bigbluebutton.common2.msgs.UserJoinMeetingReqMsg
import org.bigbluebutton.core.apps.breakout.BreakoutHdlrHelpers
import org.bigbluebutton.core.db.{ UserStateDAO }
import org.bigbluebutton.core.db.{ UserDAO, UserStateDAO }
import org.bigbluebutton.core.domain.MeetingState2x
import org.bigbluebutton.core.models.{ RegisteredUser, RegisteredUsers, Users2x, VoiceUsers }
import org.bigbluebutton.core.running.{ HandlerHelpers, LiveMeeting, MeetingActor, OutMsgRouter }
import org.bigbluebutton.core2.message.senders.MsgBuilder
import org.bigbluebutton.core.models._
import org.bigbluebutton.core.running._
import org.bigbluebutton.core2.message.senders._
trait UserJoinMeetingReqMsgHdlr extends HandlerHelpers {
this: MeetingActor =>
val liveMeeting: LiveMeeting
val outGW: OutMsgRouter
@ -18,64 +17,136 @@ trait UserJoinMeetingReqMsgHdlr extends HandlerHelpers {
log.info("Received user joined meeting. user {} meetingId={}", msg.body.userId, msg.header.meetingId)
Users2x.findWithIntId(liveMeeting.users2x, msg.body.userId) match {
case Some(reconnectingUser) =>
if (reconnectingUser.userLeftFlag.left) {
log.info("Resetting flag that user left meeting. user {}", msg.body.userId)
// User has reconnected. Just reset it's flag. ralam Oct 23, 2018
sendUserLeftFlagUpdatedEvtMsg(outGW, liveMeeting, msg.body.userId, false)
Users2x.resetUserLeftFlag(liveMeeting.users2x, msg.body.userId)
}
state
case None =>
// 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
if (!hasReachedMaxParticipants) {
val newState = userJoinMeeting(outGW, msg.body.authToken, msg.body.clientType, liveMeeting, state)
if (liveMeeting.props.meetingProp.isBreakout) {
BreakoutHdlrHelpers.updateParentMeetingWithUsers(liveMeeting, eventBus)
}
// Warn previous users that someone connected with same Id
for {
regUser <- RegisteredUsers.getRegisteredUserWithToken(msg.body.authToken, msg.body.userId,
liveMeeting.registeredUsers)
} yield {
RegisteredUsers.findAllWithExternUserId(regUser.externId, liveMeeting.registeredUsers)
.filter(u => u.id != regUser.id)
.foreach { previousUser =>
val notifyUserEvent = MsgBuilder.buildNotifyUserInMeetingEvtMsg(
previousUser.id,
liveMeeting.props.meetingProp.intId,
"info",
"promote",
"app.mobileAppModal.userConnectedWithSameId",
"Notification to warn that user connect again from other browser/device",
Vector(regUser.name)
)
outGW.send(notifyUserEvent)
}
}
// fresh user joined (not due to reconnection). Clear (pop) the cached voice user
VoiceUsers.recoverVoiceUser(liveMeeting.voiceUsers, msg.body.userId)
UserStateDAO.updateExpired(msg.body.userId, false)
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
}
case Some(user) => handleUserReconnecting(user, msg, state)
case None => handleUserJoining(msg, state)
}
}
}
private def handleUserJoining(msg: UserJoinMeetingReqMsg, state: MeetingState2x): MeetingState2x = {
val regUser = RegisteredUsers.getRegisteredUserWithToken(msg.body.authToken, msg.body.userId, liveMeeting.registeredUsers)
log.info(s"Number of registered users [${RegisteredUsers.numRegisteredUsers(liveMeeting.registeredUsers)}]")
regUser.fold {
handleFailedUserJoin(msg, "Invalid auth token.", EjectReasonCode.VALIDATE_TOKEN)
state
} { user =>
val validationResult = for {
_ <- checkIfUserGuestStatusIsAllowed(user)
_ <- checkIfUserIsBanned(user)
_ <- checkIfUserLoggedOut(user)
_ <- validateMaxParticipants(user)
} yield user
validationResult.fold(
reason => handleFailedUserJoin(msg, reason._1, reason._2),
validUser => handleSuccessfulUserJoin(msg, validUser)
)
}
}
private def handleSuccessfulUserJoin(msg: UserJoinMeetingReqMsg, regUser: RegisteredUser) = {
val newState = userJoinMeeting(outGW, msg.body.authToken, msg.body.clientType, liveMeeting, state)
updateParentMeetingWithNewListOfUsers()
notifyPreviousUsersWithSameExtId(regUser)
clearCachedVoiceUser(regUser)
clearExpiredUserState(regUser)
invalidateUserGraphqlConnection(regUser)
newState
}
private def handleFailedUserJoin(msg: UserJoinMeetingReqMsg, failReason: String, failReasonCode: String) = {
log.info("Ignoring user {} attempt to join in meeting {}. Reason Code: {}, Reason Message: {}", msg.body.userId, msg.header.meetingId, failReasonCode, failReason)
UserDAO.updateJoinError(msg.body.userId, failReasonCode, failReason)
state
}
private def handleUserReconnecting(user: UserState, msg: UserJoinMeetingReqMsg, state: MeetingState2x): MeetingState2x = {
if (user.userLeftFlag.left) {
resetUserLeftFlag(msg)
}
state
}
private def resetUserLeftFlag(msg: UserJoinMeetingReqMsg) = {
log.info("Resetting flag that user left meeting. user {}", msg.body.userId)
sendUserLeftFlagUpdatedEvtMsg(outGW, liveMeeting, msg.body.userId, false)
Users2x.resetUserLeftFlag(liveMeeting.users2x, msg.body.userId)
}
private def validateMaxParticipants(regUser: RegisteredUser): Either[(String, String), Unit] = {
val userHasJoinedAlready = RegisteredUsers.checkUserExtIdHasJoined(regUser.externId, liveMeeting.registeredUsers)
val maxParticipants = liveMeeting.props.usersProp.maxUsers - 1
if (maxParticipants > 0 && //0 = no limit
RegisteredUsers.numUniqueJoinedUsers(liveMeeting.registeredUsers) >= maxParticipants &&
!userHasJoinedAlready) {
Left(("The maximum number of participants allowed for this meeting has been reached.", EjectReasonCode.MAX_PARTICIPANTS))
} else {
Right(())
}
}
private def checkIfUserGuestStatusIsAllowed(user: RegisteredUser): Either[(String, String), Unit] = {
if (user.guestStatus != GuestStatus.ALLOW) {
Left(("User is not allowed to join", EjectReasonCode.PERMISSION_FAILED))
} else {
Right(())
}
}
private def checkIfUserIsBanned(user: RegisteredUser): Either[(String, String), Unit] = {
if (user.banned) {
Left(("Banned user rejoining", EjectReasonCode.BANNED_USER_REJOINING))
} else {
Right(())
}
}
private def checkIfUserLoggedOut(user: RegisteredUser): Either[(String, String), Unit] = {
if (user.loggedOut) {
Left(("User had logged out", EjectReasonCode.USER_LOGGED_OUT))
} else {
Right(())
}
}
private def updateParentMeetingWithNewListOfUsers() = {
if (liveMeeting.props.meetingProp.isBreakout) {
BreakoutHdlrHelpers.updateParentMeetingWithUsers(liveMeeting, eventBus)
}
}
private def notifyPreviousUsersWithSameExtId(regUser: RegisteredUser) = {
RegisteredUsers.findAllWithExternUserId(regUser.externId, liveMeeting.registeredUsers)
.filter(_.id != regUser.id)
.foreach { previousUser =>
sendUserConnectedNotification(previousUser, regUser, liveMeeting)
}
}
private def sendUserConnectedNotification(previousUser: RegisteredUser, newUser: RegisteredUser, liveMeeting: LiveMeeting) = {
val notifyUserEvent = MsgBuilder.buildNotifyUserInMeetingEvtMsg(
previousUser.id,
liveMeeting.props.meetingProp.intId,
"info",
"promote",
"app.mobileAppModal.userConnectedWithSameId",
"Notification to warn that user connect again from other browser/device",
Vector(newUser.name)
)
outGW.send(notifyUserEvent)
}
private def clearCachedVoiceUser(regUser: RegisteredUser) =
// fresh user joined (not due to reconnection). Clear (pop) the cached voice user
VoiceUsers.recoverVoiceUser(liveMeeting.voiceUsers, regUser.id)
private def clearExpiredUserState(regUser: RegisteredUser) =
UserStateDAO.updateExpired(regUser.id, false)
private def invalidateUserGraphqlConnection(regUser: RegisteredUser) =
Sender.sendInvalidateUserGraphqlConnectionSysMsg(liveMeeting.props.meetingProp.intId, regUser.id, regUser.sessionToken, "user_joined", outGW)
}

View File

@ -2,12 +2,14 @@ package org.bigbluebutton.core.apps.users
import org.apache.pekko.actor.ActorContext
import org.apache.pekko.event.Logging
import org.bigbluebutton.Boot.eventBus
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.api.{SetPresenterInDefaultPodInternalMsg}
import org.bigbluebutton.core.apps.ExternalVideoModel
import org.bigbluebutton.core.bus.InternalEventBus
import org.bigbluebutton.core.bus.{BigBlueButtonEvent, InternalEventBus}
import org.bigbluebutton.core.models._
import org.bigbluebutton.core.running.{LiveMeeting, OutMsgRouter}
import org.bigbluebutton.core2.message.senders.{MsgBuilder, Sender}
import org.bigbluebutton.core2.message.senders.{MsgBuilder}
import org.bigbluebutton.core.apps.screenshare.ScreenshareApp2x
import org.bigbluebutton.core.db.UserStateDAO
@ -67,8 +69,8 @@ object UsersApp {
moderator <- Users2x.findModerator(liveMeeting.users2x)
newPresenter <- Users2x.makePresenter(liveMeeting.users2x, moderator.intId)
} yield {
// println(s"automaticallyAssignPresenter: moderator=${moderator} newPresenter=${newPresenter.intId}");
sendPresenterAssigned(outGW, meetingId, newPresenter.intId, newPresenter.name, newPresenter.intId)
sendPresenterInPodReq(meetingId, newPresenter.intId)
}
}
@ -77,6 +79,10 @@ object UsersApp {
outGW.send(event)
}
def sendPresenterInPodReq(meetingId: String, newPresenterIntId: String): Unit = {
eventBus.publish(BigBlueButtonEvent(meetingId, SetPresenterInDefaultPodInternalMsg(newPresenterIntId)))
}
def sendUserLeftMeetingToAllClients(outGW: OutMsgRouter, meetingId: String,
userId: String, eject: Boolean = false, ejectedBy: String = "", reason: String = "", reasonCode: String = ""): Unit = {
// send a user left event for the clients to update

View File

@ -2,6 +2,7 @@ package org.bigbluebutton.core.apps.users
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.bus.InternalEventBus
import org.bigbluebutton.core.db.UserDAO
import org.bigbluebutton.core.domain.MeetingState2x
import org.bigbluebutton.core.models._
import org.bigbluebutton.core.running.{ HandlerHelpers, LiveMeeting, OutMsgRouter }
@ -15,103 +16,79 @@ trait ValidateAuthTokenReqMsgHdlr extends HandlerHelpers {
val eventBus: InternalEventBus
def handleValidateAuthTokenReqMsg(msg: ValidateAuthTokenReqMsg, state: MeetingState2x): MeetingState2x = {
log.debug("RECEIVED ValidateAuthTokenReqMsg msg {}", msg)
log.debug(s"Received ValidateAuthTokenReqMsg msg $msg")
var failReason = "Invalid auth token."
var failReasonCode = EjectReasonCode.VALIDATE_TOKEN
val regUser = RegisteredUsers.getRegisteredUserWithToken(msg.body.authToken, msg.body.userId, liveMeeting.registeredUsers)
log.info(s"Number of registered users [${RegisteredUsers.numRegisteredUsers(liveMeeting.registeredUsers)}]")
log.info("Number of registered users [{}]", RegisteredUsers.numRegisteredUsers(liveMeeting.registeredUsers))
val regUser = RegisteredUsers.getRegisteredUserWithToken(msg.body.authToken, msg.body.userId,
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
regUser.fold {
sendFailedValidateAuthTokenRespMsg(msg, "Invalid auth token.", EjectReasonCode.VALIDATE_TOKEN)
} { user =>
val validationResult = for {
_ <- checkIfUserGuestStatusIsAllowed(user)
_ <- checkIfUserIsBanned(user)
_ <- checkIfUserLoggedOut(user)
_ <- validateMaxParticipants(user)
} yield user
// 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 && !hasReachedMaxParticipants) {
userValidated(u, state)
} else {
if (u.banned) {
failReason = "Banned user rejoining"
failReasonCode = EjectReasonCode.BANNED_USER_REJOINING
} 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,
meetingId = liveMeeting.props.meetingProp.intId,
userId = msg.header.userId,
authToken = msg.body.authToken,
valid = false,
waitForApproval = false,
failReason,
failReasonCode,
state
)
}
validationResult.fold(
reason => sendFailedValidateAuthTokenRespMsg(msg, reason._1, reason._2),
validUser => sendSuccessfulValidateAuthTokenRespMsg(validUser)
)
}
case None =>
validateTokenFailed(
outGW,
meetingId = liveMeeting.props.meetingProp.intId,
userId = msg.header.userId,
authToken = msg.body.authToken,
valid = false,
waitForApproval = false,
failReason,
failReasonCode,
state
)
state
}
private def validateMaxParticipants(user: RegisteredUser): Either[(String, String), Unit] = {
if (liveMeeting.props.usersProp.maxUsers > 0 &&
RegisteredUsers.numUniqueJoinedUsers(liveMeeting.registeredUsers) >= liveMeeting.props.usersProp.maxUsers &&
RegisteredUsers.checkUserExtIdHasJoined(user.externId, liveMeeting.registeredUsers) == false) {
Left(("The maximum number of participants allowed for this meeting has been reached.", EjectReasonCode.MAX_PARTICIPANTS))
} else {
Right(())
}
}
def validateTokenFailed(
outGW: OutMsgRouter,
meetingId: String,
userId: String,
authToken: String,
valid: Boolean,
waitForApproval: Boolean,
reason: String,
reasonCode: String,
state: MeetingState2x
): MeetingState2x = {
val event = MsgBuilder.buildValidateAuthTokenRespMsg(meetingId, userId, authToken, valid, waitForApproval, 0,
0, reasonCode, reason)
outGW.send(event)
// send a system message to force disconnection
// Comment out as meteor will disconnect the client. Requested by Tiago (ralam apr 28, 2020)
//Sender.sendDisconnectClientSysMsg(meetingId, userId, SystemUser.ID, reasonCode, outGW)
state
private def checkIfUserGuestStatusIsAllowed(user: RegisteredUser): Either[(String, String), Unit] = {
if (user.guestStatus != GuestStatus.ALLOW) {
Left(("User is not allowed to join", EjectReasonCode.PERMISSION_FAILED))
} else {
Right(())
}
}
def sendValidateAuthTokenRespMsg(meetingId: String, userId: String, authToken: String,
valid: Boolean, waitForApproval: Boolean, registeredOn: Long, authTokenValidatedOn: Long,
reasonCode: String = EjectReasonCode.NOT_EJECT, reason: String = "User not ejected"): Unit = {
val event = MsgBuilder.buildValidateAuthTokenRespMsg(meetingId, userId, authToken, valid, waitForApproval, registeredOn,
authTokenValidatedOn, reasonCode, reason)
private def checkIfUserIsBanned(user: RegisteredUser): Either[(String, String), Unit] = {
if (user.banned) {
Left(("Banned user rejoining", EjectReasonCode.BANNED_USER_REJOINING))
} else {
Right(())
}
}
private def checkIfUserLoggedOut(user: RegisteredUser): Either[(String, String), Unit] = {
if (user.loggedOut) {
Left(("User had logged out", EjectReasonCode.USER_LOGGED_OUT))
} else {
Right(())
}
}
private def sendFailedValidateAuthTokenRespMsg(msg: ValidateAuthTokenReqMsg, failReason: String, failReasonCode: String) = {
UserDAO.updateJoinError(msg.body.userId, failReasonCode, failReason)
val event = MsgBuilder.buildValidateAuthTokenRespMsg(liveMeeting.props.meetingProp.intId, msg.header.userId, msg.body.authToken, false, false, 0,
0, failReasonCode, failReason)
outGW.send(event)
}
def userValidated(user: RegisteredUser, state: MeetingState2x): MeetingState2x = {
def sendSuccessfulValidateAuthTokenRespMsg(user: RegisteredUser) = {
val meetingId = liveMeeting.props.meetingProp.intId
val updatedUser = RegisteredUsers.updateUserLastAuthTokenValidated(liveMeeting.registeredUsers, user)
sendValidateAuthTokenRespMsg(meetingId, updatedUser.id, updatedUser.authToken, valid = true, waitForApproval = false, updatedUser.registeredOn, updatedUser.lastAuthTokenValidatedOn)
state
val event = MsgBuilder.buildValidateAuthTokenRespMsg(meetingId, updatedUser.id, updatedUser.authToken, true, false, updatedUser.registeredOn,
updatedUser.lastAuthTokenValidatedOn, EjectReasonCode.NOT_EJECT, "User not ejected")
outGW.send(event)
}
def sendAllUsersInMeeting(requesterId: String): Unit = {

View File

@ -33,8 +33,8 @@ trait UserJoinedVoiceConfEvtMsgHdlr extends SystemConfiguration {
def registerUserInRegisteredUsers() = {
val regUser = RegisteredUsers.create(msg.body.intId, msg.body.voiceUserId,
msg.body.callerIdName, Roles.VIEWER_ROLE, "", "", "", userColor,
true, true, GuestStatus.WAIT, true, Map(), false)
msg.body.callerIdName, Roles.VIEWER_ROLE, msg.body.intId, "", "", userColor,
true, true, GuestStatus.WAIT, true, "", Map(), false)
RegisteredUsers.add(liveMeeting.registeredUsers, regUser, liveMeeting.props.meetingProp.intId)
}
@ -57,7 +57,7 @@ trait UserJoinedVoiceConfEvtMsgHdlr extends SystemConfiguration {
locked = MeetingStatus2x.getPermissions(liveMeeting.status).lockOnJoin,
avatar = "",
color = userColor,
clientType = "",
clientType = if (isDialInUser) "dial-in-user" else "",
pickExempted = false,
userLeftFlag = UserLeftFlag(false, 0)
)

View File

@ -4,6 +4,7 @@ import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.models._
import org.bigbluebutton.core.apps.users.UsersApp
import org.bigbluebutton.core.apps.breakout.BreakoutHdlrHelpers
import org.bigbluebutton.core.db.UserDAO
import org.bigbluebutton.core.running.{ LiveMeeting, MeetingActor, OutMsgRouter }
trait UserLeftVoiceConfEvtMsgHdlr {
@ -39,6 +40,7 @@ trait UserLeftVoiceConfEvtMsgHdlr {
UsersApp.guestWaitingLeft(liveMeeting, user.intId, outGW)
}
Users2x.remove(liveMeeting.users2x, user.intId)
UserDAO.delete(user.intId)
VoiceApp.removeUserFromVoiceConf(liveMeeting, outGW, msg.body.voiceUserId)
}

View File

@ -1,6 +1,6 @@
package org.bigbluebutton.core.apps.voice
import org.apache.pekko.actor.{ ActorContext, ActorSystem, Cancellable }
import org.apache.pekko.actor.{ActorContext, ActorSystem, Cancellable}
import org.bigbluebutton.SystemConfiguration
import org.bigbluebutton.LockSettingsUtil
import org.bigbluebutton.core.apps.breakout.BreakoutHdlrHelpers
@ -11,8 +11,10 @@ import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.running.{LiveMeeting, MeetingActor, OutMsgRouter}
import org.bigbluebutton.core.models._
import org.bigbluebutton.core.apps.users.UsersApp
import org.bigbluebutton.core.db.{UserDAO, UserVoiceDAO}
import org.bigbluebutton.core.util.ColorPicker
import org.bigbluebutton.core.util.TimeUtil
import scala.collection.immutable.Map
import scala.concurrent.duration._
@ -323,6 +325,8 @@ object VoiceApp extends SystemConfiguration {
uuid
)
VoiceUsers.add(liveMeeting.voiceUsers, voiceUserState)
UserVoiceDAO.update(voiceUserState)
UserDAO.updateVoiceUserJoined(voiceUserState)
broadcastEvent(voiceUserState)

View File

@ -1,7 +1,7 @@
package org.bigbluebutton.core.apps.voice
import org.bigbluebutton.common2.msgs.{ BbbClientMsgHeader, BbbCommonEnvCoreMsg, BbbCoreEnvelope, MessageTypes, Routing, VoiceCallStateEvtMsg, VoiceCallStateEvtMsgBody, VoiceConfCallStateEvtMsg }
import org.bigbluebutton.core.models.{ VoiceUserState, VoiceUsers }
import org.bigbluebutton.core.db.{ UserVoiceConfStateDAO, UserVoiceDAO }
import org.bigbluebutton.core.running.{ LiveMeeting, MeetingActor, OutMsgRouter }
trait VoiceConfCallStateEvtMsgHdlr {
@ -38,5 +38,9 @@ trait VoiceConfCallStateEvtMsgHdlr {
val event = VoiceCallStateEvtMsg(header, body)
val msgEvent = BbbCommonEnvCoreMsg(envelope, event)
outGW.send(msgEvent)
if (msg.body.userId.nonEmpty) {
UserVoiceConfStateDAO.insertOrUpdate(msg.body.userId, msg.body.voiceConf, msg.body.callSession, msg.body.clientSession, msg.body.callState)
}
}
}

View File

@ -77,12 +77,12 @@ object MeetingDAO {
).onComplete {
case Success(rowsAffected) => {
DatabaseConnection.logger.debug(s"$rowsAffected row(s) inserted in Meeting table!")
ChatDAO.insert(meetingProps.meetingProp.intId, GroupChatApp.createDefaultPublicGroupChat())
MeetingUsersPoliciesDAO.insert(meetingProps.meetingProp.intId, meetingProps.usersProp)
MeetingLockSettingsDAO.insert(meetingProps.meetingProp.intId, meetingProps.lockSettingsProps)
MeetingMetadataDAO.insert(meetingProps.meetingProp.intId, meetingProps.metadataProp)
MeetingRecordingPoliciesDAO.insert(meetingProps.meetingProp.intId, meetingProps.recordProp)
MeetingVoiceDAO.insert(meetingProps.meetingProp.intId, meetingProps.voiceProp)
ChatDAO.insert(meetingProps.meetingProp.intId, GroupChatApp.createDefaultPublicGroupChat())
MeetingWelcomeDAO.insert(meetingProps.meetingProp.intId, meetingProps.welcomeProp)
MeetingGroupDAO.insert(meetingProps.meetingProp.intId, meetingProps.groups)
MeetingBreakoutDAO.insert(meetingProps.meetingProp.intId, meetingProps.breakoutProps)

View File

@ -19,7 +19,7 @@ class UserConnectionStatusDbTableDef(tag: Tag) extends Table[UserConnectionStatu
val connectionAliveAt = column[Option[java.sql.Timestamp]]("connectionAliveAt")
}
object UserConnectionStatusdDAO {
object UserConnectionStatusDAO {
def insert(meetingId: String, userId: String) = {
DatabaseConnection.db.run(

View File

@ -1,34 +1,37 @@
package org.bigbluebutton.core.db
import org.bigbluebutton.core.models.{RegisteredUser}
import org.bigbluebutton.core.models.{RegisteredUser, VoiceUserState}
import slick.jdbc.PostgresProfile.api._
import scala.concurrent.ExecutionContext.Implicits.global
import scala.util.{Failure, Success}
case class UserDbModel(
userId: String,
extId: String,
meetingId: String,
name: String,
role: String,
avatar: String = "",
color: String = "",
sessionToken: String = "",
authed: Boolean = false,
joined: Boolean = false,
banned: Boolean = false,
loggedOut: Boolean = false,
guest: Boolean,
guestStatus: String,
registeredOn: Long,
excludeFromDashboard: Boolean,
userId: String,
extId: String,
meetingId: String,
name: String,
role: String,
avatar: String = "",
color: String = "",
sessionToken: String = "",
authed: Boolean = false,
joined: Boolean = false,
joinErrorMessage: Option[String],
joinErrorCode: Option[String],
banned: Boolean = false,
loggedOut: Boolean = false,
guest: Boolean,
guestStatus: String,
registeredOn: Long,
excludeFromDashboard: Boolean,
enforceLayout: Option[String],
)
class UserDbTableDef(tag: Tag) extends Table[UserDbModel](tag, None, "user") {
override def * = (
userId,extId,meetingId,name,role,avatar,color, sessionToken, authed,joined,banned,loggedOut,guest,guestStatus,registeredOn,excludeFromDashboard) <> (UserDbModel.tupled, UserDbModel.unapply)
userId,extId,meetingId,name,role,avatar,color, sessionToken, authed,joined,joinErrorCode, joinErrorMessage, banned,loggedOut,guest,guestStatus,registeredOn,excludeFromDashboard, enforceLayout) <> (UserDbModel.tupled, UserDbModel.unapply)
val userId = column[String]("userId", O.PrimaryKey)
val extId = column[String]("extId")
val meetingId = column[String]("meetingId")
@ -39,12 +42,15 @@ class UserDbTableDef(tag: Tag) extends Table[UserDbModel](tag, None, "user") {
val sessionToken = column[String]("sessionToken")
val authed = column[Boolean]("authed")
val joined = column[Boolean]("joined")
val joinErrorCode = column[Option[String]]("joinErrorCode")
val joinErrorMessage = column[Option[String]]("joinErrorMessage")
val banned = column[Boolean]("banned")
val loggedOut = column[Boolean]("loggedOut")
val guest = column[Boolean]("guest")
val guestStatus = column[String]("guestStatus")
val registeredOn = column[Long]("registeredOn")
val excludeFromDashboard = column[Boolean]("excludeFromDashboard")
val enforceLayout = column[Option[String]]("enforceLayout")
}
object UserDAO {
@ -62,21 +68,27 @@ object UserDAO {
sessionToken = regUser.sessionToken,
authed = regUser.authed,
joined = regUser.joined,
joinErrorCode = None,
joinErrorMessage = None,
banned = regUser.banned,
loggedOut = regUser.loggedOut,
guest = regUser.guest,
guestStatus = regUser.guestStatus,
registeredOn = regUser.registeredOn,
excludeFromDashboard = regUser.excludeFromDashboard
excludeFromDashboard = regUser.excludeFromDashboard,
enforceLayout = regUser.enforceLayout match {
case "" => None
case enforceLayout: String => Some(enforceLayout)
}
)
)
).onComplete {
case Success(rowsAffected) => {
DatabaseConnection.logger.debug(s"$rowsAffected row(s) inserted in User table!")
ChatUserDAO.insertUserPublicChat(meetingId, regUser.id)
UserConnectionStatusdDAO.insert(meetingId, regUser.id)
UserConnectionStatusDAO.insert(meetingId, regUser.id)
UserCustomParameterDAO.insert(regUser.id, regUser.customParameters)
UserClientSettingsDAO.insert(regUser.id, meetingId)
ChatUserDAO.insertUserPublicChat(meetingId, regUser.id)
}
case Failure(e) => DatabaseConnection.logger.debug(s"Error inserting user: $e")
}
@ -94,18 +106,33 @@ object UserDAO {
}
}
def updateVoiceUserJoined(voiceUserState: VoiceUserState) = {
DatabaseConnection.db.run(
TableQuery[UserDbTableDef]
.filter(_.userId === voiceUserState.intId)
.map(u => (u.guest, u.guestStatus, u.authed, u.joined))
.update((false, "ALLOW", true, true))
).onComplete {
case Success(rowsAffected) => DatabaseConnection.logger.debug(s"$rowsAffected row(s) updated on user voice table!")
case Failure(e) => DatabaseConnection.logger.debug(s"Error updating user voice: $e")
}
}
def updateJoinError(userId: String, joinErrorCode: String, joinErrorMessage: String) = {
DatabaseConnection.db.run(
TableQuery[UserDbTableDef]
.filter(_.userId === userId)
.map(u => (u.joined, u.joinErrorCode, u.joinErrorMessage))
.update((false, Some(joinErrorCode), Some(joinErrorMessage)))
).onComplete {
case Success(rowsAffected) => DatabaseConnection.logger.debug(s"$rowsAffected row(s) updated on user (Joined) table!")
case Failure(e) => DatabaseConnection.logger.debug(s"Error updating user (Joined): $e")
}
}
def delete(intId: String) = {
// DatabaseConnection.db.run(
// TableQuery[UserDbTableDef]
// .filter(_.userId === intId)
// .delete
// ).onComplete {
// case Success(rowsAffected) => DatabaseConnection.logger.debug(s"User ${intId} deleted")
// case Failure(e) => DatabaseConnection.logger.error(s"Error deleting user ${intId}: $e")
// }
DatabaseConnection.db.run(
TableQuery[UserDbTableDef]
.filter(_.userId === intId)

View File

@ -0,0 +1,49 @@
package org.bigbluebutton.core.db
import org.bigbluebutton.core.models.{ VoiceUserState }
import slick.jdbc.PostgresProfile.api._
import scala.concurrent.ExecutionContext.Implicits.global
import scala.util.{Failure, Success }
case class UserVoiceConfStateDbModel(
userId: String,
voiceConf: String,
voiceConfCallSession: String,
voiceConfClientSession: String,
voiceConfCallState: String,
)
class UserVoiceConfStateDbTableDef(tag: Tag) extends Table[UserVoiceConfStateDbModel](tag, None, "user_voice") {
override def * = (
userId, voiceConf, voiceConfCallSession, voiceConfClientSession, voiceConfCallState
) <> (UserVoiceConfStateDbModel.tupled, UserVoiceConfStateDbModel.unapply)
val userId = column[String]("userId", O.PrimaryKey)
val voiceConf = column[String]("voiceConf")
val voiceConfCallSession = column[String]("voiceConfCallSession")
val voiceConfClientSession = column[String]("voiceConfClientSession")
val voiceConfCallState = column[String]("voiceConfCallState")
}
object UserVoiceConfStateDAO {
def insertOrUpdate(userId: String, voiceConf: String, voiceConfCallSession: String, clientSession: String, callState: String) = {
DatabaseConnection.db.run(
TableQuery[UserVoiceConfStateDbTableDef].insertOrUpdate(
UserVoiceConfStateDbModel(
userId = userId,
voiceConf = voiceConf,
voiceConfCallSession = voiceConfCallSession,
voiceConfClientSession = clientSession,
voiceConfCallState = callState,
)
)
).onComplete {
case Success(rowsAffected) => {
DatabaseConnection.logger.debug(s"$rowsAffected row(s) inserted on user_voice table!")
}
case Failure(e) => DatabaseConnection.logger.debug(s"Error inserting voice: $e")
}
}
}

View File

@ -7,27 +7,26 @@ import scala.concurrent.ExecutionContext.Implicits.global
import scala.util.{Failure, Success }
case class UserVoiceDbModel(
userId: String,
voiceUserId: String,
callerName: String,
callerNum: String,
callingWith: String,
joined: Boolean,
listenOnly: Boolean,
muted: Boolean,
spoke: Boolean,
talking: Boolean,
floor: Boolean,
lastFloorTime: String,
voiceConf: String,
startTime: Option[Long],
endTime: Option[Long],
userId: String,
voiceUserId: String,
callerName: String,
callerNum: String,
callingWith: String,
joined: Boolean,
listenOnly: Boolean,
muted: Boolean,
spoke: Boolean,
talking: Boolean,
floor: Boolean,
lastFloorTime: String,
startTime: Option[Long],
endTime: Option[Long],
)
class UserVoiceDbTableDef(tag: Tag) extends Table[UserVoiceDbModel](tag, None, "user_voice") {
override def * = (
userId, voiceUserId, callerName, callerNum, callingWith, joined, listenOnly,
muted, spoke, talking, floor, lastFloorTime, voiceConf, startTime, endTime
muted, spoke, talking, floor, lastFloorTime, startTime, endTime
) <> (UserVoiceDbModel.tupled, UserVoiceDbModel.unapply)
val userId = column[String]("userId", O.PrimaryKey)
val voiceUserId = column[String]("voiceUserId")
@ -42,6 +41,9 @@ class UserVoiceDbTableDef(tag: Tag) extends Table[UserVoiceDbModel](tag, None, "
val floor = column[Boolean]("floor")
val lastFloorTime = column[String]("lastFloorTime")
val voiceConf = column[String]("voiceConf")
val voiceConfCallSession = column[String]("voiceConfCallSession")
val voiceConfClientSession = column[String]("voiceConfClientSession")
val voiceConfCallState = column[String]("voiceConfCallState")
val startTime = column[Option[Long]]("startTime")
val endTime = column[Option[Long]]("endTime")
}
@ -64,7 +66,6 @@ object UserVoiceDAO {
talking = voiceUserState.talking,
floor = voiceUserState.floor,
lastFloorTime = voiceUserState.lastFloorTime,
voiceConf = "",
startTime = None,
endTime = None
)
@ -85,7 +86,7 @@ object UserVoiceDAO {
.update((voiceUserState.listenOnly, voiceUserState.muted, voiceUserState.floor, voiceUserState.lastFloorTime))
).onComplete {
case Success(rowsAffected) => DatabaseConnection.logger.debug(s"$rowsAffected row(s) updated on user_voice table!")
case Failure(e) => DatabaseConnection.logger.error(s"Error updating user: $e")
case Failure(e) => DatabaseConnection.logger.error(s"Error updating user_voice: $e")
}
}
@ -113,7 +114,19 @@ object UserVoiceDAO {
}
}
def deleteUser(userId: String) = {
def delete(intId: String) = {
DatabaseConnection.db.run(
TableQuery[UserDbTableDef]
.filter(_.userId === intId)
.map(u => (u.loggedOut))
.update((true))
).onComplete {
case Success(rowsAffected) => DatabaseConnection.logger.debug(s"$rowsAffected row(s) updated loggedOut=true on user table!")
case Failure(e) => DatabaseConnection.logger.error(s"Error updating loggedOut=true user: $e")
}
}
def deleteUserVoice(userId: String) = {
//Meteor sets this props instead of removing
// muted: false
// talking: false
@ -124,10 +137,11 @@ object UserVoiceDAO {
DatabaseConnection.db.run(
TableQuery[UserVoiceDbTableDef]
.filter(_.userId === userId)
.delete
.map(u => (u.muted, u.talking, u.listenOnly, u.joined, u.spoke, u.startTime, u.endTime))
.update((false, false, false, false, false, None, None))
).onComplete {
case Success(rowsAffected) => DatabaseConnection.logger.debug(s"Voice of user ${userId} deleted")
case Failure(e) => DatabaseConnection.logger.error(s"Error deleting voice: $e")
case Success(rowsAffected) => DatabaseConnection.logger.debug(s"Voice of user ${userId} deleted (joined=false)")
case Failure(e) => DatabaseConnection.logger.error(s"Error deleting voice user: $e")
}
}

View File

@ -7,7 +7,8 @@ import org.bigbluebutton.core.domain.BreakoutRoom2x
object RegisteredUsers {
def create(userId: String, extId: String, name: String, roles: String,
authToken: String, sessionToken: String, avatar: String, color: String, guest: Boolean, authenticated: Boolean,
guestStatus: String, excludeFromDashboard: Boolean, customParameters: Map[String, String], loggedOut: Boolean): RegisteredUser = {
guestStatus: String, excludeFromDashboard: Boolean, enforceLayout: String,
customParameters: Map[String, String], loggedOut: Boolean): RegisteredUser = {
new RegisteredUser(
userId,
extId,
@ -25,6 +26,7 @@ object RegisteredUsers {
0,
false,
false,
enforceLayout,
customParameters,
loggedOut,
)
@ -214,6 +216,7 @@ case class RegisteredUser(
lastAuthTokenValidatedOn: Long,
joined: Boolean,
banned: Boolean,
enforceLayout: String,
customParameters: Map[String,String],
loggedOut: Boolean,
lastBreakoutRoom: BreakoutRoom2x = null,

View File

@ -39,7 +39,7 @@ object VoiceUsers {
}
def removeWithIntId(users: VoiceUsers, intId: String): Option[VoiceUserState] = {
UserVoiceDAO.deleteUser(intId)
UserVoiceDAO.deleteUserVoice(intId)
users.remove(intId)
}

View File

@ -346,8 +346,6 @@ class ReceivedJsonMsgHandlerActor(
routeGenericMsg[CreateNewPresentationPodPubMsg](envelope, jsonNode)
case RemovePresentationPodPubMsg.NAME =>
routeGenericMsg[RemovePresentationPodPubMsg](envelope, jsonNode)
case SetPresenterInPodReqMsg.NAME =>
routeGenericMsg[SetPresenterInPodReqMsg](envelope, jsonNode)
// Caption
case EditCaptionHistoryPubMsg.NAME =>

View File

@ -248,25 +248,26 @@ class MeetingActor(
//=============================
// 2x messages
case msg: BbbCommonEnvCoreMsg => handleBbbCommonEnvCoreMsg(msg)
case msg: BbbCommonEnvCoreMsg => handleBbbCommonEnvCoreMsg(msg)
// Handling RegisterUserReqMsg as it is forwarded from BBBActor and
// its type is not BbbCommonEnvCoreMsg
case m: RegisterUserReqMsg => usersApp.handleRegisterUserReqMsg(m)
case m: GetAllMeetingsReqMsg => handleGetAllMeetingsReqMsg(m)
case m: GetRunningMeetingStateReqMsg => handleGetRunningMeetingStateReqMsg(m)
case m: ValidateConnAuthTokenSysMsg => handleValidateConnAuthTokenSysMsg(m)
case m: RegisterUserReqMsg => usersApp.handleRegisterUserReqMsg(m)
case m: GetAllMeetingsReqMsg => handleGetAllMeetingsReqMsg(m)
case m: GetRunningMeetingStateReqMsg => handleGetRunningMeetingStateReqMsg(m)
case m: ValidateConnAuthTokenSysMsg => handleValidateConnAuthTokenSysMsg(m)
// Meeting
case m: DestroyMeetingSysCmdMsg => handleDestroyMeetingSysCmdMsg(m)
case m: DestroyMeetingSysCmdMsg => handleDestroyMeetingSysCmdMsg(m)
//======================================
//=======================================
// internal messages
case msg: MonitorNumberOfUsersInternalMsg => handleMonitorNumberOfUsers(msg)
case msg: MonitorNumberOfUsersInternalMsg => handleMonitorNumberOfUsers(msg)
case msg: SetPresenterInDefaultPodInternalMsg => state = presentationPodsApp.handleSetPresenterInDefaultPodInternalMsg(msg, state, liveMeeting, msgBus)
case msg: ExtendMeetingDuration => handleExtendMeetingDuration(msg)
case msg: ExtendMeetingDuration => handleExtendMeetingDuration(msg)
case msg: SendTimeRemainingAuditInternalMsg =>
if (!liveMeeting.props.meetingProp.isBreakout) {
// Update users of meeting remaining time.
@ -531,7 +532,6 @@ class MeetingActor(
case m: PresentationConversionCompletedSysPubMsg => state = presentationPodsApp.handle(m, state, liveMeeting, msgBus)
case m: PdfConversionInvalidErrorSysPubMsg => state = presentationPodsApp.handle(m, state, liveMeeting, msgBus)
case m: SetCurrentPagePubMsg => state = presentationPodsApp.handle(m, state, liveMeeting, msgBus)
case m: SetPresenterInPodReqMsg => state = presentationPodsApp.handle(m, state, liveMeeting, msgBus)
case m: RemovePresentationPubMsg => state = presentationPodsApp.handle(m, state, liveMeeting, msgBus)
case m: SetPresentationDownloadablePubMsg => state = presentationPodsApp.handle(m, state, liveMeeting, msgBus)
case m: PresentationConversionUpdateSysPubMsg => state = presentationPodsApp.handle(m, state, liveMeeting, msgBus)

View File

@ -1,8 +1,8 @@
package org.bigbluebutton.core2.message.senders
import org.bigbluebutton.common2.domain.DefaultProps
import org.bigbluebutton.common2.msgs.{ BbbCommonEnvCoreMsg, BbbCoreEnvelope, BbbCoreHeaderWithMeetingId, MessageTypes, Routing, ValidateConnAuthTokenSysRespMsg, ValidateConnAuthTokenSysRespMsgBody, NotifyAllInMeetingEvtMsg, NotifyAllInMeetingEvtMsgBody, NotifyRoleInMeetingEvtMsg, NotifyRoleInMeetingEvtMsgBody, NotifyUserInMeetingEvtMsg, NotifyUserInMeetingEvtMsgBody, _ }
import org.bigbluebutton.core.models.GuestWaiting
import org.bigbluebutton.common2.msgs.{ BbbCommonEnvCoreMsg, BbbCoreEnvelope, BbbCoreHeaderWithMeetingId, MessageTypes, NotifyAllInMeetingEvtMsg, NotifyAllInMeetingEvtMsgBody, NotifyRoleInMeetingEvtMsg, NotifyRoleInMeetingEvtMsgBody, NotifyUserInMeetingEvtMsg, NotifyUserInMeetingEvtMsgBody, Routing, ValidateConnAuthTokenSysRespMsg, ValidateConnAuthTokenSysRespMsgBody, _ }
import org.bigbluebutton.core.models.{ GuestWaiting, PresentationPod }
object MsgBuilder {
def buildGuestPolicyChangedEvtMsg(meetingId: String, userId: String, policy: String, setBy: String): BbbCommonEnvCoreMsg = {

View File

@ -56,7 +56,7 @@ object FakeUserGenerator {
val color = "#ff6242"
val ru = RegisteredUsers.create(userId = id, extId, name, role,
authToken, sessionToken, avatarURL, color, guest, authed, guestStatus = GuestStatus.ALLOW, false, Map(), false)
authToken, sessionToken, avatarURL, color, guest, authed, guestStatus = GuestStatus.ALLOW, false, "", Map(), false)
RegisteredUsers.add(users, ru, meetingId)
ru
}

View File

@ -397,10 +397,33 @@ class LearningDashboardActor(
user <- findUserByIntId(meeting, msg.body.userId)
} yield {
if (msg.body.reactionEmoji != "none") {
val updatedUser = user.copy(reactions = user.reactions :+ Emoji(msg.body.reactionEmoji))
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.userKey -> updatedUser))
//Ignore multiple Reactions to prevent flooding
val hasSameReactionInLast30Seconds = user.reactions.filter(r => {
System.currentTimeMillis() - r.sentOn < (30 * 1000) && r.name == msg.body.reactionEmoji
}).length > 0
meetings += (updatedMeeting.intId -> updatedMeeting)
if(!hasSameReactionInLast30Seconds) {
val updatedUser = user.copy(reactions = user.reactions :+ Emoji(msg.body.reactionEmoji))
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.userKey -> updatedUser))
meetings += (updatedMeeting.intId -> updatedMeeting)
//Convert Reactions to legacy Emoji (while LearningDashboard doesn't support Reactions)
val emoji = msg.body.reactionEmoji.codePointAt(0) match {
case 128515 => "happy"
case 128528 => "neutral"
case 128577 => "sad"
case 128077 => "thumbsUp"
case 128078 => "thumbsDown"
case 128079 => "applause"
case _ => "none"
}
if (emoji != "none") {
val updatedUserWithEmoji = updatedUser.copy(emojis = user.emojis :+ Emoji(emoji))
val updatedMeetingWithEmoji = meeting.copy(users = meeting.users + (updatedUserWithEmoji.userKey -> updatedUserWithEmoji))
meetings += (updatedMeeting.intId -> updatedMeetingWithEmoji)
}
}
}
}
}

View File

@ -13,9 +13,10 @@ object TestDataGen {
val sessionToken = RandomStringGenerator.randomAlphanumericString(16)
val avatarURL = "https://www." + RandomStringGenerator.randomAlphanumericString(32) + ".com/" +
RandomStringGenerator.randomAlphanumericString(10) + ".png"
val color = "#ff6242"
val ru = RegisteredUsers.create(userId = id, extId, name, role,
authToken, sessionToken, avatarURL, guest, authed, GuestStatus.ALLOW, false)
authToken, sessionToken, avatarURL, color, guest, authed, GuestStatus.ALLOW, false, "", Map(), false)
RegisteredUsers.add(users, ru, meetingId = "test")
ru

View File

@ -11,6 +11,7 @@ object GroupChatMessageType {
val POLL = "poll"
val BREAKOUTROOM_MOD_MSG = "breakoutRoomModeratorMsg"
val PUBLIC_CHAT_HIST_CLEARED = "publicChatHistoryCleared"
val USER_AWAY_STATUS_MSG = "userAwayStatusMsg"
}
case class GroupChatUser(id: String, name: String = "", role: String = "VIEWER")

View File

@ -23,10 +23,6 @@ object SetCurrentPagePubMsg { val NAME = "SetCurrentPagePubMsg" }
case class SetCurrentPagePubMsg(header: BbbClientMsgHeader, body: SetCurrentPagePubMsgBody) extends StandardMsg
case class SetCurrentPagePubMsgBody(podId: String, presentationId: String, pageId: String)
object SetPresenterInPodReqMsg { val NAME = "SetPresenterInPodReqMsg" }
case class SetPresenterInPodReqMsg(header: BbbClientMsgHeader, body: SetPresenterInPodReqMsgBody) extends StandardMsg
case class SetPresenterInPodReqMsgBody(podId: String, nextPresenterId: String)
object RemovePresentationPubMsg { val NAME = "RemovePresentationPubMsg" }
case class RemovePresentationPubMsg(header: BbbClientMsgHeader, body: RemovePresentationPubMsgBody) extends StandardMsg
case class RemovePresentationPubMsgBody(podId: String, presentationId: String)

View File

@ -8,7 +8,7 @@ case class RegisterUserReqMsg(
case class RegisterUserReqMsgBody(meetingId: String, intUserId: String, name: String, role: String,
extUserId: String, authToken: String, sessionToken: String, avatarURL: String,
guest: Boolean, authed: Boolean, guestStatus: String, excludeFromDashboard: Boolean,
customParameters: Map[String, String])
enforceLayout: String, customParameters: Map[String, String])
object UserRegisteredRespMsg { val NAME = "UserRegisteredRespMsg" }
case class UserRegisteredRespMsg(

View File

@ -122,13 +122,19 @@ public class MeetingService implements MessageListener {
public void registerUser(String meetingID, String internalUserId,
String fullname, String role, String externUserID,
String authToken, String sessionToken, String avatarURL, Boolean guest,
Boolean authed, String guestStatus, Boolean excludeFromDashboard, Boolean leftGuestLobby, Map<String, String> customParameters) {
handle(new RegisterUser(meetingID, internalUserId, fullname, role,
externUserID, authToken, sessionToken, avatarURL, guest, authed, guestStatus, excludeFromDashboard, leftGuestLobby, customParameters));
Boolean authed, String guestStatus, Boolean excludeFromDashboard, Boolean leftGuestLobby,
String enforceLayout, Map<String, String> customParameters) {
handle(
new RegisterUser(meetingID, internalUserId, fullname, role,
externUserID, authToken, sessionToken, avatarURL, guest, authed, guestStatus,
excludeFromDashboard, leftGuestLobby, enforceLayout, customParameters
)
);
Meeting m = getMeeting(meetingID);
if (m != null) {
RegisteredUser ruser = new RegisteredUser(authToken, internalUserId, guestStatus, excludeFromDashboard, leftGuestLobby);
RegisteredUser ruser = new RegisteredUser(authToken, internalUserId, guestStatus,
excludeFromDashboard, leftGuestLobby, enforceLayout);
m.userRegistered(ruser);
}
}
@ -423,7 +429,7 @@ public class MeetingService implements MessageListener {
gw.registerUser(message.meetingID,
message.internalUserId, message.fullname, message.role,
message.externUserID, message.authToken, message.sessionToken, message.avatarURL, message.guest,
message.authed, message.guestStatus, message.excludeFromDashboard, message.customParameters);
message.authed, message.guestStatus, message.excludeFromDashboard, message.enforceLayout, message.customParameters);
}
public Meeting getMeeting(String meetingId) {

View File

@ -1152,6 +1152,11 @@ public class ParamsProcessorUtil {
return true;
}
public boolean parentMeetingExists(String parentMeetingId) {
Meeting meeting = ServiceUtils.findMeetingFromMeetingID(parentMeetingId);
return meeting != null;
}
/*************************************************
* Setters
************************************************/

View File

@ -2,11 +2,14 @@ package org.bigbluebutton.api;
import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
public final class Util {
@ -20,6 +23,14 @@ public final class Util {
throw new IllegalStateException("Utility class");
}
public static String extractFilenameFromUrl(String preUploadedPresentation) throws MalformedURLException {
URL url = new URL(preUploadedPresentation);
String filename = FilenameUtils.getName(url.getPath());
String extension = FilenameUtils.getExtension(url.getPath());
if (extension == null || extension.isEmpty()) return null;
return filename;
}
public static boolean isMeetingIdValidFormat(String id) {
Matcher matcher = MEETING_ID_PATTERN.matcher(id);
if (matcher.matches()) {
@ -51,7 +62,11 @@ public final class Util {
}
public static String createNewFilename(String presId, String fileExt) {
return presId + "." + fileExt;
if (!fileExt.isEmpty()) {
return presId + "." + fileExt;
} else {
return presId;
}
}
public static File createPresentationDir(String meetingId, String presentationDir, String presentationId) {

View File

@ -9,13 +9,16 @@ public class RegisteredUser {
private Boolean excludeFromDashboard;
private Long guestWaitedOn;
private Boolean leftGuestLobby;
private String enforceLayout;
public RegisteredUser(String authToken, String userId, String guestStatus, Boolean excludeFromDashboard, Boolean leftGuestLobby) {
public RegisteredUser(String authToken, String userId, String guestStatus, Boolean excludeFromDashboard,
Boolean leftGuestLobby, String enforceLayout) {
this.authToken = authToken;
this.userId = userId;
this.guestStatus = guestStatus;
this.excludeFromDashboard = excludeFromDashboard;
this.leftGuestLobby = leftGuestLobby;
this.enforceLayout = enforceLayout;
Long currentTimeMillis = System.currentTimeMillis();
this.registeredOn = currentTimeMillis;
@ -30,6 +33,10 @@ public class RegisteredUser {
return guestStatus;
}
public void setLeftGuestLobby(boolean bool) {
this.leftGuestLobby = bool;
}
public Boolean getLeftGuestLobby() {
return leftGuestLobby;
}
@ -38,6 +45,14 @@ public class RegisteredUser {
this.excludeFromDashboard = excludeFromDashboard;
}
public String getEnforceLayout() {
return enforceLayout;
}
public void setEnforceLayout(String enforceLayout) {
this.enforceLayout = enforceLayout;
}
public Boolean getExcludeFromDashboard() {
return excludeFromDashboard;
}
@ -46,9 +61,6 @@ public class RegisteredUser {
this.guestWaitedOn = System.currentTimeMillis();
}
public void setLeftGuestLobby(boolean bool) {
this.leftGuestLobby = bool;
}
public Long getGuestWaitedOn() {
return this.guestWaitedOn;
}

View File

@ -41,6 +41,7 @@ public class UserSession {
public String welcome = null;
public String logoutUrl = null;
public String defaultLayout = "NOLAYOUT";
public String enforceLayout = "";
public String avatarURL;
public String guestStatus = GuestPolicy.ALLOW;
public String clientUrl = null;
@ -130,6 +131,10 @@ public class UserSession {
return defaultLayout;
}
public String getEnforceLayout() {
return enforceLayout;
}
public String getAvatarURL() {
return avatarURL;
}

View File

@ -18,12 +18,13 @@ public class RegisterUser implements IMessage {
public final String guestStatus;
public final Boolean excludeFromDashboard;
public final Boolean leftGuestLobby;
public final String enforceLayout;
public final Map<String, String> customParameters;
public RegisterUser(String meetingID, String internalUserId, String fullname, String role, String externUserID,
String authToken, String sessionToken, String avatarURL, Boolean guest,
Boolean authed, String guestStatus, Boolean excludeFromDashboard, Boolean leftGuestLobby,
Map<String, String> customParameters) {
String enforceLayout, Map<String, String> customParameters) {
this.meetingID = meetingID;
this.internalUserId = internalUserId;
this.fullname = fullname;
@ -37,6 +38,7 @@ public class RegisterUser implements IMessage {
this.guestStatus = guestStatus;
this.excludeFromDashboard = excludeFromDashboard;
this.leftGuestLobby = leftGuestLobby;
this.enforceLayout = enforceLayout;
this.customParameters = customParameters;
}
}

View File

@ -22,7 +22,8 @@ public interface IPublisherService {
void endMeeting(String meetingId);
void send(String channel, String message);
void registerUser(String meetingID, String internalUserId, String fullname, String role, String externUserID,
String authToken, String avatarURL, Boolean guest, Boolean excludeFromDashboard, Boolean authed);
String authToken, String avatarURL, Boolean guest, Boolean excludeFromDashboard,
String enforceLayout, Boolean authed);
void sendKeepAlive(String system, Long bbbWebTimestamp, Long akkaAppsTimestamp);
void sendStunTurnInfo(String meetingId, String internalUserId, Set<StunServer> stuns, Set<TurnEntry> turns);
}

View File

@ -52,7 +52,7 @@ public interface IBbbWebApiGWApp {
void registerUser(String meetingID, String internalUserId, String fullname, String role,
String externUserID, String authToken, String sessionToken, String avatarURL,
Boolean guest, Boolean authed, String guestStatus, Boolean excludeFromDashboard,
Map<String, String> customParameters);
String enforceLayout, Map<String, String> customParameters);
void guestWaitingLeft(String meetingID, String internalUserId);
void destroyMeeting(DestroyMeetingMessage msg);

View File

@ -1,5 +1,7 @@
package org.bigbluebutton.presentation;
import org.bigbluebutton.api.domain.Extension;
import java.util.*;
import static org.bigbluebutton.presentation.FileTypeConstants.*;
@ -43,6 +45,15 @@ public class MimeTypeUtils {
put(FileTypeConstants.SVG, Arrays.asList(SVG));
}
};
public String getExtensionBasedOnMimeType(String mimeType) {
return EXTENSIONS_MIME.entrySet()
.stream()
.filter(entry -> entry.getValue().contains(mimeType))
.map(Map.Entry::getKey)
.findFirst()
.orElse(null);
}
public Boolean extensionMatchMimeType(String mimeType, String finalExtension) {
finalExtension = finalExtension.toLowerCase();

View File

@ -108,14 +108,21 @@ public final class SupportedFileTypes {
return "";
}
public static String detectFileExtensionBasedOnMimeType(File pres) {
String mimeType = detectMimeType(pres);
return mimeTypeUtils.getExtensionBasedOnMimeType(mimeType);
}
public static Boolean isPresentationMimeTypeValid(File pres, String fileExtension) {
String mimeType = detectMimeType(pres);
if (mimeType.equals("")) {
log.error("Not able to detect mimeType.");
return false;
}
if (!mimeTypeUtils.getValidMimeTypes().contains(mimeType)) {
log.error("MimeType is not valid for this meeting, [{}]", mimeType);
return false;
}

View File

@ -262,7 +262,7 @@ class BbbWebApiGWApp(
role: String, extUserId: String, authToken: String, sessionToken: String,
avatarURL: String, guest: java.lang.Boolean, authed: java.lang.Boolean,
guestStatus: String, excludeFromDashboard: java.lang.Boolean,
customParameters: java.util.Map[String, String]): Unit = {
enforceLayout: String, customParameters: java.util.Map[String, String]): Unit = {
// meetingManagerActorRef ! new RegisterUser(meetingId = meetingId, intUserId = intUserId, name = name,
// role = role, extUserId = extUserId, authToken = authToken, avatarURL = avatarURL,
@ -271,7 +271,8 @@ class BbbWebApiGWApp(
val regUser = new RegisterUser(meetingId = meetingId, intUserId = intUserId, name = name,
role = role, extUserId = extUserId, authToken = authToken, sessionToken = sessionToken,
avatarURL = avatarURL, guest = guest.booleanValue(), authed = authed.booleanValue(),
guestStatus = guestStatus, excludeFromDashboard = excludeFromDashboard, customParameters = (customParameters).asScala.toMap)
guestStatus = guestStatus, excludeFromDashboard = excludeFromDashboard, enforceLayout = enforceLayout,
customParameters = (customParameters).asScala.toMap)
val event = MsgBuilder.buildRegisterUserRequestToAkkaApps(regUser)
msgToAkkaAppsEventBus.publish(MsgToAkkaApps(toAkkaAppsChannel, event))

View File

@ -12,6 +12,7 @@ import java.nio.charset.StandardCharsets
import java.util.stream.Collectors
import javax.imageio.ImageIO
import scala.io.Source
import scala.util.{ Failure, Success, Try, Using }
import scala.xml.XML
object MsgBuilder {
@ -49,7 +50,7 @@ object MsgBuilder {
val body = RegisterUserReqMsgBody(meetingId = msg.meetingId, intUserId = msg.intUserId,
name = msg.name, role = msg.role, extUserId = msg.extUserId, authToken = msg.authToken, sessionToken = msg.sessionToken,
avatarURL = msg.avatarURL, guest = msg.guest, authed = msg.authed, guestStatus = msg.guestStatus,
excludeFromDashboard = msg.excludeFromDashboard, customParameters = msg.customParameters)
excludeFromDashboard = msg.excludeFromDashboard, enforceLayout = msg.enforceLayout, customParameters = msg.customParameters)
val req = RegisterUserReqMsg(header, body)
BbbCommonEnvCoreMsg(envelope, req)
}
@ -83,7 +84,12 @@ object MsgBuilder {
val urls = Map("thumb" -> thumbUrl, "text" -> txtUrl, "svg" -> svgUrl, "png" -> pngUrl)
try {
val result = Using.Manager { use =>
val contentUrl = new URL(txtUrl)
val stream = use(new InputStreamReader(contentUrl.openStream(), StandardCharsets.UTF_8))
val reader = use(new BufferedReader(stream))
val content = reader.lines().collect(Collectors.joining("\n"))
val svgSource = Source.fromURL(new URL(svgUrl))
val svgContent = svgSource.mkString
svgSource.close()
@ -100,10 +106,6 @@ object MsgBuilder {
val width = w.toDouble
val height = h.toDouble
val contentUrl = new URL(txtUrl)
val reader = new BufferedReader(new InputStreamReader(contentUrl.openStream(), StandardCharsets.UTF_8))
val content = reader.lines().collect(Collectors.joining("\n"))
PresentationPageConvertedVO(
id = id,
num = page,
@ -113,7 +115,7 @@ object MsgBuilder {
width = width,
height = height
)
} catch {
} recover {
case e: Exception =>
e.printStackTrace()
PresentationPageConvertedVO(
@ -124,6 +126,18 @@ object MsgBuilder {
current = current
)
}
val presentationPage = result.getOrElse(
PresentationPageConvertedVO(
id = id,
num = page,
urls = urls,
content = "",
current = current
)
)
presentationPage
}
def buildPresentationPageConvertedSysMsg(msg: DocPageGeneratedProgress): BbbCommonEnvCoreMsg = {

View File

@ -18,7 +18,7 @@ case class AddUserSession(token: String, session: UserSession)
case class RegisterUser(meetingId: String, intUserId: String, name: String, role: String,
extUserId: String, authToken: String, sessionToken: String, avatarURL: String,
guest: Boolean, authed: Boolean, guestStatus: String, excludeFromDashboard: Boolean,
customParameters: Map[String, String])
enforceLayout: String, customParameters: Map[String, String])
case class CreateMeetingMsg(defaultProps: DefaultProps)

View File

@ -29,7 +29,8 @@ trait ToAkkaAppsSendersTrait extends SystemConfiguration {
val body = RegisterUserReqMsgBody(meetingId = msg.meetingId, intUserId = msg.intUserId,
name = msg.name, role = msg.role, extUserId = msg.extUserId, authToken = msg.authToken,
sessionToken = msg.sessionToken, avatarURL = msg.avatarURL, guest = msg.guest, authed = msg.authed,
guestStatus = msg.guestStatus, excludeFromDashboard = msg.excludeFromDashboard, customParameters = msg.customParameters)
guestStatus = msg.guestStatus, excludeFromDashboard = msg.excludeFromDashboard,
enforceLayout = msg.enforceLayout, customParameters = msg.customParameters)
val req = RegisterUserReqMsg(header, body)
val message = BbbCommonEnvCoreMsg(envelope, req)
sendToBus(message)

View File

@ -1,12 +0,0 @@
package org.bigbluebutton.api2.meeting
case class UserSession2(authToken: String, internalUserId: String, conferencename: String,
meetingID: String, externMeetingID: String, externUserID: String, fullname: String,
role: String, conference: String, room: String, guest: Boolean = false,
authed: Boolean = false, voicebridge: String, webvoiceconf: String,
mode: String, record: String, welcome: String, logoutUrl: String,
defaultLayout: String = "NOLAYOUT", avatarURL: String)
class UserSessions {
}

View File

@ -30,6 +30,7 @@ function App() {
const [sessionToken, setSessionToken] = useState(null);
const [userId, setUserId] = useState(null);
const [userName, setUserName] = useState(null);
const [userAuthToken, setUserAuthToken] = useState(null);
const [graphqlClient, setGraphqlClient] = useState(null);
const [enterApiResponse, setEnterApiResponse] = useState('');
@ -46,6 +47,7 @@ function App() {
if(json?.response?.internalUserID) {
setUserId(json.response.internalUserID);
setUserName(json.response.fullname);
setUserAuthToken(json.response.authToken);
}
});
}
@ -110,7 +112,7 @@ function App() {
<br />
<PluginDataChannel userId={userId} />
<br />
<MyInfo />
<MyInfo userAuthToken={userAuthToken} />
<br />
<PresPresentationUploadToken />
<br />

View File

@ -1,7 +1,7 @@
import {gql, useMutation, useQuery, useSubscription} from '@apollo/client';
import {gql, useMutation, useSubscription} from '@apollo/client';
import React from "react";
export default function MyInfo() {
export default function MyInfo({userAuthToken}) {
//where is not necessary once user can update only its own status
//Hasura accepts "now()" as value to timestamp fields
@ -20,17 +20,32 @@ export default function MyInfo() {
updateUserClientEchoTestRunningAtMeAsNow();
};
const [dispatchUserJoin] = useMutation(gql`
mutation UserJoin($authToken: String!, $clientType: String!) {
userJoin(
authToken: $authToken,
clientType: $clientType,
)
}
`);
const handleDispatchUserJoin = (authToken) => {
dispatchUserJoin({
variables: {
authToken: authToken,
clientType: 'HTML5',
},
});
};
const { loading, error, data } = useSubscription(
gql`subscription {
user_current {
userId
name
meeting {
name
}
echoTestRunningAt
isRunningEchoTest
joined
joinErrorCode
joinErrorMessage
}
}`
);
@ -45,23 +60,23 @@ export default function MyInfo() {
{/*<th>Id</th>*/}
<th>userId</th>
<th>name</th>
<th>Meeting</th>
<th>echoTestRunningAt</th>
<th>isRunningEchoTest</th>
<th>joined</th>
<th>joinErrorCode</th>
<th>joinErrorMessage</th>
</tr>
</thead>
<tbody>
{data.user_current.map((curr) => {
console.log('meeting', curr);
console.log('user_current', curr);
return (
<tr key={curr.userId}>
<td>{curr.userId}</td>
<td>{curr.name}</td>
<td>{curr.meeting.name}</td>
<td>{curr.echoTestRunningAt}
<button onClick={() => handleUpdateUserEchoTestRunningAt()}>Set running now!</button>
<td>{curr.joined ? 'Yes' : 'No'}
{curr.joined ? '' : <button onClick={() => handleDispatchUserJoin(userAuthToken)}>Join Now!</button>}
</td>
<td>{curr.isRunningEchoTest ? 'Yes' : 'No'}</td>
<td>{curr.joinErrorCode}</td>
<td>{curr.joinErrorMessage}</td>
</tr>
);
})}

View File

@ -230,12 +230,15 @@ CREATE TABLE "user" (
"sessionToken" varchar(16),
"authed" bool,
"joined" bool,
"joinErrorCode" varchar(50),
"joinErrorMessage" varchar(400),
"banned" bool,
"loggedOut" bool, -- when user clicked Leave meeting button
"guest" bool, --used for dialIn
"guestStatus" varchar(50),
"registeredOn" bigint,
"excludeFromDashboard" bool,
"enforceLayout" varchar(50),
--columns of user state bellow
"raiseHand" bool default false,
"raiseHandTime" timestamp with time zone,
@ -247,8 +250,8 @@ CREATE TABLE "user" (
"guestLobbyMessage" text,
"mobile" bool,
"clientType" varchar(50),
"disconnected" bool, -- this is the old leftFlag (that was renamed), set when the user just closed the client
"expired" bool, -- when it is been some time the user is disconnected
"disconnected" bool default false, -- this is the old leftFlag (that was renamed), set when the user just closed the client
"expired" bool default false, -- when it is been some time the user is disconnected
"ejected" bool,
"ejectReason" varchar(255),
"ejectReasonCode" varchar(50),
@ -388,10 +391,13 @@ AS SELECT "user"."userId",
-- "user"."guestStatus",
"user"."mobile",
"user"."clientType",
"user"."enforceLayout",
"user"."isDialIn",
"user"."role",
"user"."authed",
"user"."joined",
"user"."joinErrorCode",
"user"."joinErrorMessage",
"user"."disconnected",
"user"."expired",
"user"."ejected",
@ -481,7 +487,7 @@ join meeting_welcome w USING("meetingId");
CREATE TABLE "user_voice" (
"userId" varchar(50) PRIMARY KEY NOT NULL REFERENCES "user"("userId") ON DELETE CASCADE,
"userId" varchar(50) PRIMARY KEY NOT NULL REFERENCES "user"("userId") ON DELETE CASCADE,
"voiceUserId" varchar(100),
"callerName" varchar(100),
"callerNum" varchar(100),
@ -494,6 +500,9 @@ CREATE TABLE "user_voice" (
"floor" boolean,
"lastFloorTime" varchar(25),
"voiceConf" varchar(100),
"voiceConfCallSession" varchar(50),
"voiceConfClientSession" varchar(10),
"voiceConfCallState" varchar(30),
"endTime" bigint,
"startTime" bigint
);
@ -513,7 +522,8 @@ SELECT
FROM "user" u
JOIN "user_voice" ON "user_voice"."userId" = u."userId"
LEFT JOIN "user_voice" user_talking ON (user_talking."userId" = u."userId" and user_talking."talking" IS TRUE)
OR (user_talking."userId" = u."userId" and user_talking."hideTalkingIndicatorAt" > now());
OR (user_talking."userId" = u."userId" and user_talking."hideTalkingIndicatorAt" > now())
WHERE "user_voice"."joined" is true;
CREATE TABLE "user_camera" (
"streamId" varchar(100) PRIMARY KEY,

View File

@ -23,3 +23,10 @@ type Mutation {
): Boolean
}
type Mutation {
userJoin(
authToken: String!
clientType: String!
): Boolean
}

View File

@ -17,6 +17,13 @@ actions:
handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}'
permissions:
- role: bbb_client
- name: userJoin
definition:
kind: synchronous
handler: '{{HASURA_BBB_GRAPHQL_ACTIONS_ADAPTER_URL}}'
permissions:
- role: pre_join_bbb_client
- role: bbb_client
custom_types:
enums: []
input_objects: []

View File

@ -135,6 +135,7 @@ select_permissions:
- away
- banned
- clientType
- enforceLayout
- color
- disconnected
- echoTestRunningAt
@ -149,6 +150,8 @@ select_permissions:
- isDialIn
- isModerator
- isRunningEchoTest
- joinErrorCode
- joinErrorMessage
- joined
- locked
- loggedOut
@ -158,16 +161,39 @@ select_permissions:
- pinned
- presenter
- raiseHand
- registeredAt
- registeredOn
- role
- speechLocale
- userId
filter:
_and:
- meetingId:
_eq: X-Hasura-MeetingId
- userId:
_eq: X-Hasura-UserId
userId:
_eq: X-Hasura-UserId
- role: pre_join_bbb_client
permission:
columns:
- authed
- banned
- color
- disconnected
- ejectReason
- ejectReasonCode
- ejected
- expired
- extId
- guest
- joinErrorCode
- joinErrorMessage
- joined
- loggedOut
- name
- registeredAt
- registeredOn
- userId
filter:
userId:
_eq: X-Hasura-UserId
comment: ""
update_permissions:
- role: bbb_client
permission:

View File

@ -36,6 +36,7 @@ select_permissions:
- talking
- userId
- voiceConf
- voiceConfCallState
- voiceUserId
filter:
meetingId:

View File

@ -1,8 +1,8 @@
- "!include public_v_caption.yaml"
- "!include public_v_breakoutRoom.yaml"
- "!include public_v_breakoutRoom_assignedUser.yaml"
- "!include public_v_breakoutRoom_participant.yaml"
- "!include public_v_breakoutRoom_user.yaml"
- "!include public_v_caption.yaml"
- "!include public_v_chat.yaml"
- "!include public_v_chat_message_private.yaml"
- "!include public_v_chat_message_public.yaml"

View File

@ -1 +1 @@
FROM bigbluebutton/bbb-libreoffice:latest
FROM bigbluebutton/bbb-libreoffice:7.6.2-20231020-161900

View File

@ -1,21 +1,11 @@
import Users from '/imports/api/users';
import PresentationPods from '/imports/api/presentation-pods';
import changePresenter from '/imports/api/users/server/modifiers/changePresenter';
import RedisPubSub from '/imports/startup/server/redis';
import Logger from '/imports/startup/server/logger';
function setPresenterInPodReqMsg(credentials) { // TODO-- switch to meetingId, etc
const REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'SetPresenterInPodReqMsg';
const { meetingId, requesterUserId, presenterId } = credentials;
const payload = {
podId: 'DEFAULT_PRESENTATION_POD',
nextPresenterId: presenterId,
};
RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
// TODO It will be removed soon
Logger.debug(credentials);
}
export default async function handlePresenterAssigned({ body }, meetingId) {

View File

@ -0,0 +1,5 @@
import { FetchResult } from '@apollo/client';
export type GraphqlDataHookSubscriptionResponse<T> = FetchResult<T> & {
loading: boolean;
};

View File

@ -277,6 +277,7 @@ class ActionsDropdown extends PureComponent {
: intl.formatMessage(intlMessages.activateTimerStopwatchLabel),
key: this.timerId,
onClick: () => this.handleTimerClick(),
dataTest: 'timerStopWatchFeature',
});
}

View File

@ -8,7 +8,7 @@ import AudioCaptionsButtonContainer from '/imports/ui/components/audio/captions/
import CaptionsReaderMenuContainer from '/imports/ui/components/captions/reader-menu/container';
import ScreenshareButtonContainer from '/imports/ui/components/actions-bar/screenshare/container';
import ReactionsButtonContainer from './reactions-button/container';
import AudioControlsContainer from '../audio/audio-controls/container';
import AudioControlsContainer from '../audio/audio-graphql/audio-controls/component';
import JoinVideoOptionsContainer from '../video-provider/video-button/container';
import PresentationOptionsContainer from './presentation-options/component';
import RaiseHandDropdownContainer from './raise-hand/container';

View File

@ -4,11 +4,15 @@ import PropTypes from 'prop-types';
import BBBMenu from '/imports/ui/components/common/menu/component';
import UserReactionService from '/imports/ui/components/user-reaction/service';
import UserListService from '/imports/ui/components/user-list/service';
import { Emoji } from 'emoji-mart';
import { convertRemToPixels } from '/imports/utils/dom-utils';
import data from '@emoji-mart/data';
import withShortcutHelper from '/imports/ui/components/shortcut-help/service';
import { init } from 'emoji-mart';
import Styled from './styles';
const REACTIONS = Meteor.settings.public.userReaction.reactions;
const ReactionsButton = (props) => {
const {
intl,
@ -16,10 +20,14 @@ const ReactionsButton = (props) => {
userId,
raiseHand,
isMobile,
shortcuts,
currentUserReaction,
autoCloseReactionsBar,
} = props;
// initialize emoji-mart data, need for the new version
init({ data });
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const intlMessages = defineMessages({
@ -74,43 +82,20 @@ const ReactionsButton = (props) => {
};
const emojiProps = {
native: true,
size: convertRemToPixels(1.5),
padding: '4px',
};
const reactions = [
{
id: 'smiley',
native: '😃',
},
{
id: 'neutral_face',
native: '😐',
},
{
id: 'slightly_frowning_face',
native: '🙁',
},
{
id: '+1',
native: '👍',
},
{
id: '-1',
native: '👎',
},
{
id: 'clap',
native: '👏',
},
];
const handReaction = {
id: 'hand',
native: '✋',
};
let actions = [];
reactions.forEach(({ id, native }) => {
REACTIONS.forEach(({ id, native }) => {
actions.push({
label: <Styled.ButtonWrapper active={currentUserReaction === native}><Emoji key={id} emoji={{ id }} {...emojiProps} /></Styled.ButtonWrapper>,
label: <Styled.ButtonWrapper active={currentUserReaction === native}><em-emoji key={native} native={native} {...emojiProps} /></Styled.ButtonWrapper>,
key: id,
onClick: () => handleReactionSelect(native),
customStyles: actionCustomStyles,
@ -118,29 +103,29 @@ const ReactionsButton = (props) => {
});
actions.push({
label: <Styled.RaiseHandButtonWrapper isMobile={isMobile} data-test={raiseHand ? 'lowerHandBtn' : 'raiseHandBtn'} active={raiseHand}><Emoji key="hand" emoji={{ id: 'hand' }} {...emojiProps} />{RaiseHandButtonLabel()}</Styled.RaiseHandButtonWrapper>,
label: <Styled.RaiseHandButtonWrapper accessKey={shortcuts.raisehand} isMobile={isMobile} data-test={raiseHand ? 'lowerHandBtn' : 'raiseHandBtn'} active={raiseHand}><em-emoji key={handReaction.id} native={handReaction.native} emoji={{ id: handReaction.id }} {...emojiProps} />{RaiseHandButtonLabel()}</Styled.RaiseHandButtonWrapper>,
key: 'hand',
onClick: () => handleRaiseHandButtonClick(),
customStyles: {...actionCustomStyles, width: 'auto'},
});
const icon = !raiseHand && currentUserReaction === 'none' ? 'hand' : null;
const currentUserReactionEmoji = reactions.find(({ native }) => native === currentUserReaction);
const currentUserReactionEmoji = REACTIONS.find(({ native }) => native === currentUserReaction);
let customIcon = null;
if (raiseHand) {
customIcon = <Emoji key="hand" emoji={{ id: 'hand' }} {...emojiProps} />;
customIcon = <em-emoji key={handReaction.id} native={handReaction.native} emoji={handReaction} {...emojiProps} />;
} else {
if (!icon) {
customIcon = <Emoji key={currentUserReactionEmoji?.id} emoji={{ id: currentUserReactionEmoji?.id }} {...emojiProps} />;
customIcon = <em-emoji key={currentUserReactionEmoji?.id} native={currentUserReactionEmoji?.native} emoji={{ id: currentUserReactionEmoji?.id }} {...emojiProps} />;
}
}
return (
<BBBMenu
trigger={(
<Styled.ReactionsDropdown>
<Styled.ReactionsDropdown id="interactionsButton">
<Styled.RaiseHandButton
data-test="reactionsButton"
icon={icon}
@ -193,4 +178,4 @@ const propTypes = {
ReactionsButton.propTypes = propTypes;
export default ReactionsButton;
export default withShortcutHelper(ReactionsButton, ['raiseHand']);

View File

@ -101,28 +101,13 @@ const ReactionsDropdown = styled.div`
`;
const Wrapper = styled.div`
.emoji-mart-bar {
display: none;
}
.emoji-mart-search {
display: none;
}
.emoji-mart-category[aria-label="Frequently Used"] {
display: none;
}
.emoji-mart-category-label{
display: none;
}
.emoji-mart{
border: none;
}
@media(min-width: 600px) {
.emoji-mart-scroll{
overflow:hidden;
padding: 0;
height: 270px;
width: 280px;
}
overflow: hidden;
margin: 0.2em 0.2em 0.2em 0.2em;
text-align: center;
max-height: 270px;
width: 270px;
em-emoji {
cursor: pointer;
}
`;

View File

@ -29,6 +29,7 @@ import WebcamContainer from '../webcam/container';
import PresentationContainer from '../presentation/container';
import ScreenshareContainer from '../screenshare/container';
import ExternalVideoContainer from '../external-video-player/container';
import EmojiRainContainer from '../emoji-rain/container';
import Styled from './styles';
import { DEVICE_TYPE, ACTIONS, SMALL_VIEWPORT_BREAKPOINT, PANELS } from '../layout/enums';
import {
@ -521,6 +522,7 @@ class App extends Component {
setPushLayout,
shouldShowScreenshare,
shouldShowExternalVideo,
enforceLayout,
} = this.props;
return (
@ -551,6 +553,7 @@ class App extends Component {
setPushLayout,
shouldShowScreenshare,
shouldShowExternalVideo: !!shouldShowExternalVideo,
enforceLayout,
}}
/>
);
@ -663,6 +666,7 @@ class App extends Component {
<PadsSessionsContainer />
<WakeLockContainer />
{this.renderActionsBar()}
<EmojiRainContainer />
{customStyleUrl ? <link rel="stylesheet" type="text/css" href={customStyleUrl} /> : null}
{customStyle ? <link rel="stylesheet" type="text/css" href={`data:text/css;charset=UTF-8,${encodeURIComponent(customStyle)}`} /> : null}
{isRandomUserSelectModalOpen ? <RandomUserSelectContainer

View File

@ -22,6 +22,8 @@ import {
layoutDispatch,
} from '../layout/context';
import { isEqual } from 'radash';
import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser';
import { LAYOUT_TYPE } from '/imports/ui/components/layout/enums';
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
@ -147,6 +149,16 @@ const AppContainer = (props) => {
MediaService.buildLayoutWhenPresentationAreaIsDisabled(layoutContextDispatch)
});
const validateEnforceLayout = (currentUserData) => {
const layoutTypes = Object.values(LAYOUT_TYPE);
const enforceLayout = currentUserData?.enforceLayout;
return enforceLayout && layoutTypes.includes(enforceLayout) ? enforceLayout : null;
};
const { data: currentUserData } = useCurrentUser((user) => ({
enforceLayout: user.enforceLayout,
}));
return currentUserId
? (
<App
@ -185,6 +197,7 @@ const AppContainer = (props) => {
setMountRandomUserModal,
isPresenter,
numCameras: cameraDockInput.numCameras,
enforceLayout: validateEnforceLayout(currentUserData),
}}
{...otherProps}
/>

View File

@ -1,183 +0,0 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import deviceInfo from '/imports/utils/deviceInfo';
import withShortcutHelper from '/imports/ui/components/shortcut-help/service';
import InputStreamLiveSelectorContainer from './input-stream-live-selector/container';
import MutedAlert from '/imports/ui/components/muted-alert/component';
import Styled from './styles';
import Button from '/imports/ui/components/common/button/component';
import AudioModalContainer from '../audio-modal/container';
const intlMessages = defineMessages({
joinAudio: {
id: 'app.audio.joinAudio',
description: 'Join audio button label',
},
leaveAudio: {
id: 'app.audio.leaveAudio',
description: 'Leave audio button label',
},
muteAudio: {
id: 'app.actionsBar.muteLabel',
description: 'Mute audio button label',
},
unmuteAudio: {
id: 'app.actionsBar.unmuteLabel',
description: 'Unmute audio button label',
},
});
const propTypes = {
shortcuts: PropTypes.objectOf(PropTypes.string).isRequired,
handleToggleMuteMicrophone: PropTypes.func.isRequired,
handleLeaveAudio: PropTypes.func.isRequired,
disable: PropTypes.bool.isRequired,
muted: PropTypes.bool.isRequired,
showMute: PropTypes.bool.isRequired,
inAudio: PropTypes.bool.isRequired,
listenOnly: PropTypes.bool.isRequired,
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
talking: PropTypes.bool.isRequired,
};
class AudioControls extends PureComponent {
constructor(props) {
super(props);
this.state = {
isAudioModalOpen: false,
};
this.renderButtonsAndStreamSelector = this.renderButtonsAndStreamSelector.bind(this);
this.renderJoinLeaveButton = this.renderJoinLeaveButton.bind(this);
this.setAudioModalIsOpen = this.setAudioModalIsOpen.bind(this);
}
renderJoinButton() {
const {
disable,
intl,
shortcuts,
joinListenOnly,
isConnected
} = this.props;
return (
<Button
onClick={() => this.handleJoinAudio(joinListenOnly, isConnected)}
disabled={disable}
hideLabel
aria-label={intl.formatMessage(intlMessages.joinAudio)}
label={intl.formatMessage(intlMessages.joinAudio)}
data-test="joinAudio"
color="default"
ghost
icon="no_audio"
size="lg"
circle
accessKey={shortcuts.joinaudio}
/>
);
}
renderButtonsAndStreamSelector(_enableDynamicDeviceSelection) {
const {
handleLeaveAudio, handleToggleMuteMicrophone, muted, disable, talking,
} = this.props;
const { isMobile } = deviceInfo;
return (
<InputStreamLiveSelectorContainer {...{
handleLeaveAudio,
handleToggleMuteMicrophone,
muted,
disable,
talking,
isMobile,
_enableDynamicDeviceSelection,
}}
/>
);
}
renderJoinLeaveButton() {
const {
inAudio,
} = this.props;
const { isMobile } = deviceInfo;
let { enableDynamicAudioDeviceSelection } = Meteor.settings.public.app;
if (typeof enableDynamicAudioDeviceSelection === 'undefined') {
enableDynamicAudioDeviceSelection = true;
}
const _enableDynamicDeviceSelection = enableDynamicAudioDeviceSelection && !isMobile;
if (inAudio) {
return this.renderButtonsAndStreamSelector(_enableDynamicDeviceSelection);
}
return this.renderJoinButton();
}
handleJoinAudio(joinListenOnly, isConnected) {
(isConnected()
? joinListenOnly()
: this.setAudioModalIsOpen(true)
)}
setAudioModalIsOpen(value) {
this.setState({ isAudioModalOpen: value })
}
render() {
const {
showMute,
muted,
isVoiceUser,
listenOnly,
inputStream,
isViewer,
isPresenter,
} = this.props;
const { isAudioModalOpen } = this.state;
const MUTE_ALERT_CONFIG = Meteor.settings.public.app.mutedAlert;
const { enabled: muteAlertEnabled } = MUTE_ALERT_CONFIG;
return (
<Styled.Container>
{isVoiceUser && inputStream && muteAlertEnabled && !listenOnly && muted && showMute ? (
<MutedAlert {...{
muted, inputStream, isViewer, isPresenter,
}}
/>
) : null}
{
this.renderJoinLeaveButton()
}
{
isAudioModalOpen ? <AudioModalContainer
{...{
priority: "low",
setIsOpen: this.setAudioModalIsOpen,
isOpen: isAudioModalOpen
}}
/> : null
}
</Styled.Container>
);
}
}
AudioControls.propTypes = propTypes;
export default withShortcutHelper(injectIntl(AudioControls), ['joinAudio',
'leaveAudio', 'toggleMute']);

View File

@ -1,104 +0,0 @@
import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import AudioManager from '/imports/ui/services/audio-manager';
import lockContextContainer from '/imports/ui/components/lock-viewers/context/container';
import { withUsersConsumer } from '/imports/ui/components/components-data/users-context/context';
import logger from '/imports/startup/client/logger';
import Auth from '/imports/ui/services/auth';
import Storage from '/imports/ui/services/storage/session';
import getFromUserSettings from '/imports/ui/services/users-settings';
import AudioControls from './component';
import {
setUserSelectedMicrophone,
setUserSelectedListenOnly,
} from '../audio-modal/service';
import { layoutSelect } from '/imports/ui/components/layout/context';
import AudioControlsContainerGraphql from '../audio-graphql/audio-controls/component';
import Service from '../service';
import AppService from '/imports/ui/components/app/service';
const ROLE_VIEWER = Meteor.settings.public.user.role_viewer;
const APP_CONFIG = Meteor.settings.public.app;
const AudioControlsContainer = (props) => {
const { users, lockSettings, userLocks, children, ...newProps } = props;
const isRTL = layoutSelect((i) => i.isRTL);
return <AudioControls {...{ ...newProps, isRTL }} />;
};
const handleLeaveAudio = () => {
const meetingIsBreakout = AppService.meetingIsBreakout();
if (!meetingIsBreakout) {
setUserSelectedMicrophone(false);
setUserSelectedListenOnly(false);
}
const skipOnFistJoin = getFromUserSettings(
'bbb_skip_check_audio_on_first_join',
APP_CONFIG.skipCheckOnJoin
);
if (skipOnFistJoin && !Storage.getItem('getEchoTest')) {
Storage.setItem('getEchoTest', true);
}
Service.forceExitAudio();
logger.info(
{
logCode: 'audiocontrols_leave_audio',
extraInfo: { logType: 'user_action' },
},
'audio connection closed by user'
);
};
const {
isVoiceUser,
isConnected,
isListenOnly,
isEchoTest,
isMuted,
isConnecting,
isHangingUp,
isTalking,
toggleMuteMicrophone,
joinListenOnly,
} = Service;
withUsersConsumer(
lockContextContainer(
withTracker(({ userLocks, users }) => {
const currentUser = users[Auth.meetingID][Auth.userID];
const isViewer = currentUser.role === ROLE_VIEWER;
const isPresenter = currentUser.presenter;
const { status } = Service.getBreakoutAudioTransferStatus();
if (status === AudioManager.BREAKOUT_AUDIO_TRANSFER_STATES.RETURNING) {
Service.setBreakoutAudioTransferStatus({
status: AudioManager.BREAKOUT_AUDIO_TRANSFER_STATES.DISCONNECTED,
});
Service.recoverMicState();
}
return {
showMute: isConnected() && !isListenOnly() && !isEchoTest() && !userLocks.userMic,
muted: isConnected() && !isListenOnly() && isMuted(),
inAudio: isConnected() && !isEchoTest(),
listenOnly: isConnected() && isListenOnly(),
disable: isConnecting() || isHangingUp() || !Meteor.status().connected,
talking: isTalking() && !isMuted(),
isVoiceUser: isVoiceUser(),
handleToggleMuteMicrophone: () => toggleMuteMicrophone(),
joinListenOnly,
handleLeaveAudio,
inputStream: AudioManager.inputStream,
isViewer,
isPresenter,
isConnected,
};
})(AudioControlsContainer)
)
);
export default AudioControlsContainerGraphql;

View File

@ -1,501 +0,0 @@
import React, { Component } from 'react';
import logger from '/imports/startup/client/logger';
import Auth from '/imports/ui/services/auth';
import Settings from '/imports/ui/services/settings';
import { defineMessages, injectIntl } from 'react-intl';
import PropTypes from 'prop-types';
import Button from '/imports/ui/components/common/button/component';
import BBBMenu from '/imports/ui/components/common/menu/component';
import withShortcutHelper from '/imports/ui/components/shortcut-help/service';
import Styled from './styles';
const AUDIO_INPUT = 'audioinput';
const AUDIO_OUTPUT = 'audiooutput';
const DEFAULT_DEVICE = 'default';
const DEVICE_LABEL_MAX_LENGTH = 40;
const SET_SINK_ID_SUPPORTED = 'setSinkId' in HTMLMediaElement.prototype;
const intlMessages = defineMessages({
changeAudioDevice: {
id: 'app.audio.changeAudioDevice',
description: 'Change audio device button label',
},
leaveAudio: {
id: 'app.audio.leaveAudio',
description: 'Leave audio dropdown item label',
},
loading: {
id: 'app.audio.loading',
description: 'Loading audio dropdown item label',
},
noDeviceFound: {
id: 'app.audio.noDeviceFound',
description: 'No device found',
},
microphones: {
id: 'app.audio.microphones',
description: 'Input audio dropdown item label',
},
speakers: {
id: 'app.audio.speakers',
description: 'Output audio dropdown item label',
},
muteAudio: {
id: 'app.actionsBar.muteLabel',
description: 'Mute audio button label',
},
unmuteAudio: {
id: 'app.actionsBar.unmuteLabel',
description: 'Unmute audio button label',
},
deviceChangeFailed: {
id: 'app.audioNotification.deviceChangeFailed',
description: 'Device change failed',
},
defaultOutputDeviceLabel: {
id: 'app.audio.audioSettings.defaultOutputDeviceLabel',
description: 'Default output device label',
},
});
const propTypes = {
liveChangeInputDevice: PropTypes.func.isRequired,
handleLeaveAudio: PropTypes.func.isRequired,
liveChangeOutputDevice: PropTypes.func.isRequired,
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
shortcuts: PropTypes.objectOf(PropTypes.string).isRequired,
currentInputDeviceId: PropTypes.string.isRequired,
currentOutputDeviceId: PropTypes.string.isRequired,
isListenOnly: PropTypes.bool.isRequired,
isAudioConnected: PropTypes.bool.isRequired,
_enableDynamicDeviceSelection: PropTypes.bool.isRequired,
handleToggleMuteMicrophone: PropTypes.func.isRequired,
muted: PropTypes.bool.isRequired,
disable: PropTypes.bool.isRequired,
talking: PropTypes.bool,
notify: PropTypes.func.isRequired,
};
const defaultProps = {
talking: false,
};
class InputStreamLiveSelector extends Component {
static truncateDeviceName(deviceName) {
if (deviceName && (deviceName.length <= DEVICE_LABEL_MAX_LENGTH)) {
return deviceName;
}
return `${deviceName.substring(0, DEVICE_LABEL_MAX_LENGTH - 3)}...`;
}
constructor(props) {
super(props);
this.updateDeviceList = this.updateDeviceList.bind(this);
this.renderDeviceList = this.renderDeviceList.bind(this);
this.renderListenOnlyButton = this.renderListenOnlyButton.bind(this);
this.renderMuteToggleButton = this.renderMuteToggleButton.bind(this);
this.renderButtonsWithSelectorDevice = this.renderButtonsWithSelectorDevice.bind(this);
this.renderButtonsWithoutSelectorDevice = this.renderButtonsWithoutSelectorDevice.bind(this);
this.state = {
audioInputDevices: null,
audioOutputDevices: null,
selectedInputDeviceId: null,
selectedOutputDeviceId: null,
};
}
componentDidMount() {
this.updateDeviceList().then(() => {
navigator.mediaDevices
.addEventListener('devicechange', this.updateDeviceList);
this.setCurrentDevices();
});
}
componentWillUnmount() {
navigator.mediaDevices.removeEventListener('devicechange',
this.updateDeviceList);
}
onDeviceListClick(deviceId, deviceKind, callback) {
if (!deviceId) return;
const { intl, notify } = this.props;
if (deviceKind === AUDIO_INPUT) {
callback(deviceId).then(() => {
this.setState({ selectedInputDeviceId: deviceId });
}).catch((error) => {
notify(intl.formatMessage(intlMessages.deviceChangeFailed), true);
});
} else {
callback(deviceId, true).then(() => {
this.setState({ selectedOutputDeviceId: deviceId });
}).catch((error) => {
notify(intl.formatMessage(intlMessages.deviceChangeFailed), true);
});
}
}
setCurrentDevices() {
const {
currentInputDeviceId,
currentOutputDeviceId,
} = this.props;
const {
audioInputDevices,
audioOutputDevices,
} = this.state;
if (!audioInputDevices
|| !audioInputDevices[0]
|| !audioOutputDevices
|| !audioOutputDevices[0]) return;
const _currentInputDeviceId = audioInputDevices.find(
(d) => d.deviceId === currentInputDeviceId,
) ? currentInputDeviceId : audioInputDevices[0].deviceId;
const _currentOutputDeviceId = audioOutputDevices.find(
(d) => d.deviceId === currentOutputDeviceId,
) ? currentOutputDeviceId : audioOutputDevices[0].deviceId;
this.setState({
selectedInputDeviceId: _currentInputDeviceId,
selectedOutputDeviceId: _currentOutputDeviceId,
});
}
fallbackInputDevice(fallbackDevice) {
if (!fallbackDevice || !fallbackDevice.deviceId) return;
const {
liveChangeInputDevice,
} = this.props;
logger.info({
logCode: 'audio_device_live_selector',
extraInfo: {
userId: Auth.userID,
meetingId: Auth.meetingID,
},
}, 'Current input device was removed. Fallback to default device');
liveChangeInputDevice(fallbackDevice.deviceId).then(() => {
this.setState({ selectedInputDeviceId: fallbackDevice.deviceId });
}).catch((error) => {
notify(intl.formatMessage(intlMessages.deviceChangeFailed), true);
});
}
fallbackOutputDevice(fallbackDevice) {
if (!fallbackDevice || !fallbackDevice.deviceId) return;
const {
liveChangeOutputDevice,
} = this.props;
logger.info({
logCode: 'audio_device_live_selector',
extraInfo: {
userId: Auth.userID,
meetingId: Auth.meetingID,
},
}, 'Current output device was removed. Fallback to default device');
liveChangeOutputDevice(fallbackDevice.deviceId, true).then(() => {
this.setState({ selectedOutputDeviceId: fallbackDevice.deviceId });
}).catch((error) => {
notify(intl.formatMessage(intlMessages.deviceChangeFailed), true);
});
}
updateRemovedDevices(audioInputDevices, audioOutputDevices) {
const {
selectedInputDeviceId,
selectedOutputDeviceId,
} = this.state;
if (selectedInputDeviceId
&& (selectedInputDeviceId !== DEFAULT_DEVICE)
&& !audioInputDevices.find((d) => d.deviceId === selectedInputDeviceId)) {
this.fallbackInputDevice(audioInputDevices[0]);
}
if (selectedOutputDeviceId
&& (selectedOutputDeviceId !== DEFAULT_DEVICE)
&& !audioOutputDevices.find((d) => d.deviceId === selectedOutputDeviceId)) {
this.fallbackOutputDevice(audioOutputDevices[0]);
}
}
updateDeviceList() {
const {
isAudioConnected,
} = this.props;
return navigator.mediaDevices.enumerateDevices()
.then((devices) => {
const audioInputDevices = devices.filter((i) => i.kind === AUDIO_INPUT);
const audioOutputDevices = devices.filter((i) => i.kind === AUDIO_OUTPUT);
this.setState({
audioInputDevices,
audioOutputDevices,
});
if (isAudioConnected) {
this.updateRemovedDevices(audioInputDevices, audioOutputDevices);
}
});
}
renderDeviceList(deviceKind, list, callback, title, currentDeviceId) {
const {
intl,
} = this.props;
const listLength = list ? list.length : -1;
const listTitle = [
{
key: `audioDeviceList-${deviceKind}`,
label: title,
iconRight: (deviceKind === 'audioinput') ? 'unmute' : 'volume_level_2',
disabled: true,
customStyles: Styled.DisabledLabel,
},
{
key: 'separator-01',
isSeparator: true,
},
];
let deviceList = [];
if (listLength > 0) {
deviceList = list.map((device, index) => (
{
key: `${device.deviceId}-${deviceKind}`,
dataTest: `${deviceKind}-${index + 1}`,
label: InputStreamLiveSelector.truncateDeviceName(device.label),
customStyles: (device.deviceId === currentDeviceId) && Styled.SelectedLabel,
iconRight: (device.deviceId === currentDeviceId) ? 'check' : null,
onClick: () => this.onDeviceListClick(device.deviceId, deviceKind, callback),
}
));
} else if (deviceKind === AUDIO_OUTPUT && !SET_SINK_ID_SUPPORTED && listLength === 0) {
// If the browser doesn't support setSinkId, show the chosen output device
// as a placeholder Default - like it's done in audio/device-selector
deviceList = [
{
key: `defaultDeviceKey-${deviceKind}`,
label: intl.formatMessage(intlMessages.defaultOutputDeviceLabel),
customStyles: Styled.SelectedLabel,
iconRight: 'check',
disabled: true,
},
];
} else {
deviceList = [
{
key: `noDeviceFoundKey-${deviceKind}-`,
label: listLength < 0
? intl.formatMessage(intlMessages.loading)
: intl.formatMessage(intlMessages.noDeviceFound),
},
];
}
return listTitle.concat(deviceList);
}
renderMuteToggleButton() {
const {
intl,
shortcuts,
handleToggleMuteMicrophone,
muted,
disable,
talking,
} = this.props;
const label = muted ? intl.formatMessage(intlMessages.unmuteAudio)
: intl.formatMessage(intlMessages.muteAudio);
const { animations } = Settings.application;
return (
<Styled.MuteToggleButton
onClick={(e) => {
e.stopPropagation();
handleToggleMuteMicrophone();
}}
disabled={disable}
hideLabel
label={label}
aria-label={label}
color={!muted ? 'primary' : 'default'}
ghost={muted}
icon={muted ? 'mute' : 'unmute'}
size="lg"
circle
accessKey={shortcuts.togglemute}
$talking={talking || undefined}
animations={animations}
data-test={muted ? 'unmuteMicButton' : 'muteMicButton'}
/>
);
}
renderListenOnlyButton() {
const {
handleLeaveAudio,
intl,
shortcuts,
isListenOnly,
} = this.props;
return (
<Button
aria-label={intl.formatMessage(intlMessages.leaveAudio)}
label={intl.formatMessage(intlMessages.leaveAudio)}
accessKey={shortcuts.leaveaudio}
data-test="leaveListenOnly"
hideLabel
color="primary"
icon={isListenOnly ? 'listen' : 'volume_level_2'}
size="lg"
circle
onClick={(e) => {
e.stopPropagation();
handleLeaveAudio();
}}
/>
);
}
renderButtonsWithSelectorDevice() {
const {
audioInputDevices,
audioOutputDevices,
selectedInputDeviceId,
selectedOutputDeviceId,
} = this.state;
const {
liveChangeInputDevice,
handleLeaveAudio,
liveChangeOutputDevice,
intl,
currentInputDeviceId,
currentOutputDeviceId,
isListenOnly,
shortcuts,
isMobile,
} = this.props;
const inputDeviceList = !isListenOnly
? this.renderDeviceList(
AUDIO_INPUT,
audioInputDevices,
liveChangeInputDevice,
intl.formatMessage(intlMessages.microphones),
selectedInputDeviceId || currentInputDeviceId,
) : [];
const outputDeviceList = this.renderDeviceList(
AUDIO_OUTPUT,
audioOutputDevices,
liveChangeOutputDevice,
intl.formatMessage(intlMessages.speakers),
selectedOutputDeviceId || currentOutputDeviceId,
false,
);
const leaveAudioOption = {
icon: 'logout',
label: intl.formatMessage(intlMessages.leaveAudio),
key: 'leaveAudioOption',
dataTest: 'leaveAudio',
customStyles: Styled.DangerColor,
onClick: () => handleLeaveAudio(),
};
const dropdownListComplete = inputDeviceList.concat(outputDeviceList)
.concat({
key: 'separator-02',
isSeparator: true,
})
.concat(leaveAudioOption);
const customStyles = { top: '-1rem' };
return (
<>
{!isListenOnly ? (
<span
style={{ display: 'none' }}
accessKey={shortcuts.leaveaudio}
onClick={() => handleLeaveAudio()}
aria-hidden="true"
/>
) : null}
<BBBMenu
customStyles={!isMobile ? customStyles : null}
trigger={(
<>
{isListenOnly
? this.renderListenOnlyButton()
: this.renderMuteToggleButton()}
<Styled.AudioDropdown
data-test="audioDropdownMenu"
emoji="device_list_selector"
label={intl.formatMessage(intlMessages.changeAudioDevice)}
hideLabel
tabIndex={0}
rotate
/>
</>
)}
actions={dropdownListComplete}
opts={{
id: 'audio-selector-dropdown-menu',
keepMounted: true,
transitionDuration: 0,
elevation: 3,
getcontentanchorel: null,
fullwidth: 'true',
anchorOrigin: { vertical: 'top', horizontal: 'center' },
transformOrigin: { vertical: 'bottom', horizontal: 'center'},
}}
/>
</>
);
}
renderButtonsWithoutSelectorDevice() {
const { isListenOnly } = this.props;
return isListenOnly
? this.renderListenOnlyButton()
: (
<>
{this.renderMuteToggleButton()}
{this.renderListenOnlyButton()}
</>
);
}
render() {
const { _enableDynamicDeviceSelection } = this.props;
return _enableDynamicDeviceSelection
? this.renderButtonsWithSelectorDevice()
: this.renderButtonsWithoutSelectorDevice();
}
}
InputStreamLiveSelector.propTypes = propTypes;
InputStreamLiveSelector.defaultProps = defaultProps;
export default withShortcutHelper(injectIntl(InputStreamLiveSelector),
['leaveAudio', 'toggleMute']);

View File

@ -1,23 +0,0 @@
import React, { PureComponent } from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import InputStreamLiveSelector from './component';
import Service from '../../service';
class InputStreamLiveSelectorContainer extends PureComponent {
render() {
return (
<InputStreamLiveSelector {...this.props} />
);
}
}
export default withTracker(({ handleLeaveAudio }) => ({
isAudioConnected: Service.isConnected(),
isListenOnly: Service.isListenOnly(),
currentInputDeviceId: Service.inputDeviceId(),
currentOutputDeviceId: Service.outputDeviceId(),
liveChangeInputDevice: Service.liveChangeInputDevice,
liveChangeOutputDevice: Service.changeOutputDevice,
notify: Service.notify,
handleLeaveAudio,
}))(InputStreamLiveSelectorContainer);

View File

@ -1,83 +0,0 @@
import styled, { css, keyframes } from 'styled-components';
import ButtonEmoji from '/imports/ui/components/common/button/button-emoji/ButtonEmoji';
import Button from '/imports/ui/components/common/button/component';
import {
colorPrimary,
colorDanger,
colorGrayDark,
colorOffWhite,
colorWhite,
} from '/imports/ui/stylesheets/styled-components/palette';
const pulse = keyframes`
0% {
box-shadow: 0 0 0 0 white;
}
70% {
box-shadow: 0 0 0 0.5625rem transparent;
}
100% {
box-shadow: 0 0 0 0 transparent;
}
`;
const AudioDropdown = styled(ButtonEmoji)`
span {
i {
width: 0px !important;
bottom: 1px;
}
}
`;
const MuteToggleButton = styled(Button)`
${({ ghost }) => ghost && `
span {
box-shadow: none;
background-color: transparent !important;
border-color: ${colorWhite} !important;
}
`}
${({ $talking }) => $talking && `
border-radius: 50%;
`}
${({ $talking, animations }) => $talking && animations && css`
animation: ${pulse} 1s infinite ease-in;
`}
${({ $talking, animations }) => $talking && !animations && css`
& span {
content: '';
outline: none !important;
background-clip: padding-box;
box-shadow: 0 0 0 4px rgba(255,255,255,.5);
}
`}
`;
const DangerColor = {
color: colorDanger,
paddingLeft: 12,
};
const SelectedLabel = {
color: colorPrimary,
backgroundColor: colorOffWhite,
};
const DisabledLabel = {
color: colorGrayDark,
fontWeight: 'bold',
opacity: 1,
};
export default {
AudioDropdown,
MuteToggleButton,
DangerColor,
SelectedLabel,
DisabledLabel,
};

View File

@ -1,102 +0,0 @@
import styled, { css, keyframes } from 'styled-components';
import Button from '/imports/ui/components/common/button/component';
import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
import { smPaddingX, smPaddingY } from '/imports/ui/stylesheets/styled-components/general';
const pulse = keyframes`
0% {
box-shadow: 0 0 0 0 white;
}
70% {
box-shadow: 0 0 0 0.5625rem transparent;
}
100% {
box-shadow: 0 0 0 0 transparent;
}
`;
const LeaveButtonWithoutLiveStreamSelector = styled(Button)`
${({ ghost }) => ghost && `
span {
background-color: transparent !important;
border-color: ${colorWhite} !important;
}
`}
`;
const MuteToggleButton = styled(Button)`
margin-right: ${smPaddingX};
margin-left: 0;
@media ${smallOnly} {
margin-right: ${smPaddingY};
}
[dir="rtl"] & {
margin-left: ${smPaddingX};
margin-right: 0;
@media ${smallOnly} {
margin-left: ${smPaddingY};
}
}
${({ ghost }) => ghost && `
span {
background-color: transparent !important;
border-color: ${colorWhite} !important;
}
`}
${({ talking }) => talking && `
border-radius: 50%;
`}
${({ talking, animations }) => talking && animations && css`
animation: ${pulse} 1s infinite ease-in;
`}
${({ talking, animations }) => talking && !animations && css`
& span {
content: '';
outline: none !important;
background-clip: padding-box;
box-shadow: 0 0 0 4px rgba(255,255,255,.5);
}
`}
`;
const Container = styled.span`
display: flex;
flex-flow: row;
position: relative;
& > div {
position: relative;
}
& > :last-child {
margin-left: ${smPaddingX};
margin-right: 0;
@media ${smallOnly} {
margin-left: ${smPaddingY};
}
[dir="rtl"] & {
margin-left: 0;
margin-right: ${smPaddingX};
@media ${smallOnly} {
margin-right: ${smPaddingY};
}
}
}
`;
export default {
LeaveButtonWithoutLiveStreamSelector,
MuteToggleButton,
Container,
};

View File

@ -113,7 +113,7 @@ const AudioControls: React.FC<AudioControlsProps> = ({
};
export const AudioControlsContainer: React.FC = () => {
const currentUser: Partial<User> = useCurrentUser((u: Partial<User>) => {
const { data: currentUser } = useCurrentUser((u: Partial<User>) => {
return {
presenter: u.presenter,
isModerator: u.isModerator,
@ -122,7 +122,7 @@ export const AudioControlsContainer: React.FC = () => {
};
});
const currentMeeting: Partial<Meeting> = useMeeting((m: Partial<Meeting>) => {
const { data: currentMeeting } = useMeeting((m: Partial<Meeting>) => {
return {
lockSettings: m.lockSettings,
};

View File

@ -216,7 +216,7 @@ const InputStreamLiveSelector: React.FC<InputStreamLiveSelectorProps> = ({
};
const InputStreamLiveSelectorContainer: React.FC = () => {
const currentUser: Partial<User> = useCurrentUser((u: Partial<User>) => {
const { data: currentUser } = useCurrentUser((u: Partial<User>) => {
if (!u.voice) {
return {
presenter: u.presenter,
@ -237,7 +237,7 @@ const InputStreamLiveSelectorContainer: React.FC = () => {
};
});
const currentMeeting: Partial<Meeting> = useMeeting((m: Partial<Meeting>) => {
const { data: currentMeeting } = useMeeting((m: Partial<Meeting>) => {
return {
lockSettings: m?.lockSettings,
isBreakout: m?.isBreakout,

View File

@ -1,8 +1,8 @@
import React, { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import BBBMenu from "/imports/ui/components/common/menu/component";
import CreateBreakoutRoomContainer from '/imports/ui/components/actions-bar/create-breakout-room/container';
import Trigger from "/imports/ui/components/common/control-header/right/component";
import BBBMenu from '/imports/ui/components/common/menu/component';
import CreateBreakoutRoomContainerGraphql from '/imports/ui/components/breakout-room/breakout-room-graphql/create-breakout-room/component';
import Trigger from '/imports/ui/components/common/control-header/right/component';
const intlMessages = defineMessages({
options: {
@ -51,8 +51,8 @@ class BreakoutDropdown extends PureComponent {
label: intl.formatMessage(intlMessages.manageDuration),
onClick: () => {
openBreakoutTimeManager();
}
}
},
},
);
this.menuItems.push(
@ -62,8 +62,8 @@ class BreakoutDropdown extends PureComponent {
label: intl.formatMessage(intlMessages.manageUsers),
onClick: () => {
this.setCreateBreakoutRoomModalIsOpen(true);
}
}
},
},
);
if (amIModerator) {
@ -75,8 +75,8 @@ class BreakoutDropdown extends PureComponent {
disabled: !isMeteorConnected,
onClick: () => {
endAllBreakouts();
}
}
},
},
);
}
@ -100,35 +100,39 @@ class BreakoutDropdown extends PureComponent {
<>
<BBBMenu
trigger={
<Trigger
data-test="breakoutOptionsMenu"
icon="more"
label={intl.formatMessage(intlMessages.options)}
aria-label={intl.formatMessage(intlMessages.options)}
onClick={() => null}
/>
(
<Trigger
data-test="breakoutOptionsMenu"
icon="more"
label={intl.formatMessage(intlMessages.options)}
aria-label={intl.formatMessage(intlMessages.options)}
onClick={() => null}
/>
)
}
opts={{
id: "breakoutroom-dropdown-menu",
id: 'breakoutroom-dropdown-menu',
keepMounted: true,
transitionDuration: 0,
elevation: 3,
getcontentanchorel: null,
fullwidth: "true",
fullwidth: 'true',
anchorOrigin: { vertical: 'bottom', horizontal: isRTL ? 'right' : 'left' },
transformOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' },
}}
actions={this.getAvailableActions()}
/>
{isCreateBreakoutRoomModalOpen ? <CreateBreakoutRoomContainer
{...{
isUpdate: true,
onRequestClose: () => this.setCreateBreakoutRoomModalIsOpen(false),
priority: "low",
setIsOpen: this.setCreateBreakoutRoomModalIsOpen,
isOpen: isCreateBreakoutRoomModalOpen
}}
/> : null}
{isCreateBreakoutRoomModalOpen ? (
<CreateBreakoutRoomContainerGraphql
{...{
isUpdate: true,
onRequestClose: () => this.setCreateBreakoutRoomModalIsOpen(false),
priority: 'low',
setIsOpen: this.setCreateBreakoutRoomModalIsOpen,
isOpen: isCreateBreakoutRoomModalOpen,
}}
/>
) : null}
</>
);
}

View File

@ -0,0 +1,115 @@
import React, { useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Button from '/imports/ui/components/common/button/component';
import { uniqueId } from '/imports/utils/string-utils';
import Styled from '../styles';
import RoomUserList from './room-user-list/component';
import { ChildComponentProps } from '../room-managment-state/types';
const intlMessages = defineMessages({
nextLabel: {
id: 'app.createBreakoutRoom.nextLabel',
description: 'Next label',
},
backLabel: {
id: 'app.audio.backLabel',
description: 'Back label',
},
breakoutRoomDesc: {
id: 'app.createBreakoutRoom.modalDesc',
description: 'modal description',
},
addParticipantLabel: {
id: 'app.createBreakoutRoom.addParticipantLabel',
description: 'add Participant label',
},
breakoutRoomLabel: {
id: 'app.createBreakoutRoom.breakoutRoomLabel',
description: 'breakout room label',
},
});
const BreakoutRoomUserAssignmentMobile: React.FC<ChildComponentProps> = ({
numberOfRooms,
selectedRoom,
setSelectedRoom,
moveUser,
rooms,
}) => {
const intl = useIntl();
const [layer, setLayer] = useState<1 | 2 | 3>(1);
const btnLevelId = useMemo(() => uniqueId('btn-set-level-'), []);
const levelingButton = useMemo(() => {
return (
<Button
color="primary"
size="lg"
label={layer === 1 ? intl.formatMessage(intlMessages.nextLabel)
: intl.formatMessage(intlMessages.backLabel)}
onClick={() => (layer === 1 ? setLayer(2) : setLayer(1))}
key={btnLevelId}
/>
);
}, [layer]);
const layerTwo = useMemo(() => {
if (layer === 2) {
return (
<>
<Styled.SubTitle>
{intl.formatMessage(intlMessages.breakoutRoomDesc)}
</Styled.SubTitle>
<Styled.ListContainer>
<span>
{
new Array(numberOfRooms).fill(1).map((_, idx) => (
<Styled.RoomItem>
<Styled.ItemTitle>
{intl.formatMessage(intlMessages.breakoutRoomLabel, { 0: idx + 1 })}
</Styled.ItemTitle>
<Styled.ItemButton
label={intl.formatMessage(intlMessages.addParticipantLabel)}
size="lg"
ghost
color="primary"
onClick={() => {
setLayer(3);
setSelectedRoom(idx + 1);
}}
/>
</Styled.RoomItem>
))
}
</span>
</Styled.ListContainer>
</>
);
}
return null;
}, [layer, numberOfRooms]);
const layerThree = useMemo(() => {
return (
<RoomUserList
confirm={() => setLayer(2)}
selectedRoom={selectedRoom}
rooms={rooms}
moveUser={moveUser}
/>
);
}, [selectedRoom, rooms]);
const layers = {
1: null,
2: layerTwo,
3: layerThree,
};
return (
<>
{layers[layer]}
{levelingButton}
</>
);
};
export default BreakoutRoomUserAssignmentMobile;

View File

@ -0,0 +1,95 @@
import React, { useMemo } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Styled from './styles';
import { BreakoutUser } from '../../room-managment-state/types';
const intlMessages = defineMessages({
breakoutRoomLabel: {
id: 'app.createBreakoutRoom.breakoutRoomLabel',
description: 'breakout room label',
},
doneLabel: {
id: 'app.createBreakoutRoom.doneLabel',
description: 'done label',
},
});
interface RoomUserListProps {
confirm: () => void;
selectedRoom: number;
rooms: {
[key:number]: {
id: number;
name: string;
users: BreakoutUser[];
}
}
moveUser: (userId: string, fromRoomId: number, toRoomId: number) => void;
}
const RoomUserList: React.FC<RoomUserListProps> = ({
selectedRoom,
rooms,
moveUser,
confirm,
}) => {
const intl = useIntl();
const users = useMemo(() => {
const userElements = Object.values(rooms).sort((a, b) => {
if (a.id === selectedRoom) {
return -1; // Move itemToMove to the front
}
if (b.id === selectedRoom) {
return 1; // Move itemToMove to the front
}
return 0;
}).map((room) => {
return room.users.map((user) => {
return (
<Styled.SelectUserContainer id={user.userId} key={`breakout-user-${user.userId}`}>
<Styled.Round>
<input
type="checkbox"
id={`itemId${room.id}`}
defaultChecked={selectedRoom === room.id}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
moveUser(user.userId, room.id, selectedRoom);
} else {
moveUser(user.userId, room.id, 0);
}
}}
/>
<label htmlFor={`itemId${room.id}`}>
<input type="hidden" id={`itemId${room.id}`} />
</label>
</Styled.Round>
<Styled.TextName>
{user.name}
{((room.id !== selectedRoom) && room.id !== 0) ? `\t[${room.id}]` : ''}
</Styled.TextName>
</Styled.SelectUserContainer>
);
});
}).flat();
return userElements;
}, [rooms, selectedRoom]);
return (
<Styled.SelectUserScreen>
<Styled.Header>
<Styled.Title>
{intl.formatMessage(intlMessages.breakoutRoomLabel, { 0: selectedRoom })}
</Styled.Title>
<Styled.ButtonAdd
size="md"
label={intl.formatMessage(intlMessages.doneLabel)}
color="primary"
onClick={confirm}
/>
</Styled.Header>
{users}
</Styled.SelectUserScreen>
);
};
export default RoomUserList;

View File

@ -0,0 +1,133 @@
import styled from 'styled-components';
import Button from '/imports/ui/components/common/button/component';
import { TextElipsis, TitleElipsis } from '/imports/ui/stylesheets/styled-components/placeholders';
import Styled from '/imports/ui/components/actions-bar/create-breakout-room/styles';
import { colorWhite, colorGrayLighter } from '/imports/ui/stylesheets/styled-components/palette';
import { borderSize } from '/imports/ui/stylesheets/styled-components/general';
import { lineHeightComputed } from '/imports/ui/stylesheets/styled-components/typography';
const SelectUserContainer = styled.div`
margin: 1.5rem 1rem;
`;
const Round = styled.span`
position: relative;
& > label {
margin-top: -10px;
background-color: #fff;
border: 1px solid #ccc;
border-radius: 50%;
cursor: pointer;
height: 28px;
left: 0;
right : auto;
position: absolute;
top: 0;
width: 28px;
[dir="rtl"] & {
left : auto;
right: 0;
}
}
& > label:after {
border: {
style: solid;
color: #fff;
width: 2px;
right: {
style : none;
}
top: {
style: none;
}
}
content: "";
height: 6px;
left: 7px;
opacity: 0;
position: absolute;
top: 8px;
transform: rotate(-45deg);
width: 12px;
[dir="rtl"] & {
border: {
style: solid;
color: #fff;
width: 2px;
left: {
style : none;
}
top: {
style: none;
}
}
}
}
& > input[type="checkbox"] {
visibility: hidden;
}
& > input[type="checkbox"]:checked + label {
background-color: #66bb6a;
border-color: #66bb6a;
}
& > input[type="checkbox"]:checked + label:after {
opacity: 1;
}
`;
const TextName = styled(TextElipsis)`
margin-left: 1.5rem;
`;
const LockIcon = styled(Styled.LockIcon)`
background:red;
`;
const SelectUserScreen = styled.div`
position: fixed;
display: block;
height: 100vh;
width: 100%;
background-color: ${colorWhite};
z-index: 1002;
top: 0;
bottom: 0;
left: 0;
right: 0;
`;
const Header = styled.header`
display: flex;
padding: ${lineHeightComputed} 0;
border-bottom: ${borderSize} solid ${colorGrayLighter};
margin: 0 1rem 0 1rem;
`;
const Title = styled(TitleElipsis)`
align-content: flex-end;
flex: 1;
margin: 0;
font-weight: 400;
`;
// @ts-ignore - button is a JS component
const ButtonAdd = styled(Button)`
flex: 0 1 35%;
`;
export default {
SelectUserContainer,
Round,
TextName,
LockIcon,
SelectUserScreen,
Header,
Title,
ButtonAdd,
};

View File

@ -0,0 +1,351 @@
import React, { useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { range } from '/imports/utils/array-utils';
import Icon from '/imports/ui/components/common/icon/icon-ts/component';
import Styled from '../styles';
import Auth from '/imports/ui/services/auth';
import ManageRoomLabel from '../manage-room-label/component';
import { ChildComponentProps } from '../room-managment-state/types';
const intlMessages = defineMessages({
breakoutRoomTitle: {
id: 'app.createBreakoutRoom.title',
description: 'modal title',
},
breakoutRoomDesc: {
id: 'app.createBreakoutRoom.modalDesc',
description: 'modal description',
},
breakoutRoomUpdateDesc: {
id: 'app.updateBreakoutRoom.modalDesc',
description: 'update modal description',
},
cancelLabel: {
id: 'app.updateBreakoutRoom.cancelLabel',
description: 'used in the button that close update modal',
},
updateTitle: {
id: 'app.updateBreakoutRoom.title',
description: 'update breakout title',
},
updateConfirm: {
id: 'app.updateBreakoutRoom.confirm',
description: 'Update to breakout confirm button label',
},
resetUserRoom: {
id: 'app.update.resetRoom',
description: 'Reset user room button label',
},
confirmButton: {
id: 'app.createBreakoutRoom.confirm',
description: 'confirm button label',
},
dismissLabel: {
id: 'app.presentationUploder.dismissLabel',
description: 'used in the button that close modal',
},
numberOfRooms: {
id: 'app.createBreakoutRoom.numberOfRooms',
description: 'number of rooms label',
},
duration: {
id: 'app.createBreakoutRoom.durationInMinutes',
description: 'duration time label',
},
resetAssignments: {
id: 'app.createBreakoutRoom.resetAssignments',
description: 'reset assignments label',
},
resetAssignmentsDesc: {
id: 'app.createBreakoutRoom.resetAssignmentsDesc',
description: 'reset assignments label description',
},
randomlyAssign: {
id: 'app.createBreakoutRoom.randomlyAssign',
description: 'randomly assign label',
},
randomlyAssignDesc: {
id: 'app.createBreakoutRoom.randomlyAssignDesc',
description: 'randomly assign label description',
},
breakoutRoom: {
id: 'app.createBreakoutRoom.room',
description: 'breakout room',
},
freeJoinLabel: {
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',
},
captureNotesType: {
id: 'app.notes.label',
description: 'indicates notes have been captured',
},
captureSlidesType: {
id: 'app.shortcut-help.whiteboard',
description: 'indicates the whiteboard has been captured',
},
roomLabel: {
id: 'app.createBreakoutRoom.room',
description: 'Room label',
},
leastOneWarnBreakout: {
id: 'app.createBreakoutRoom.leastOneWarnBreakout',
description: 'warn message label',
},
notAssigned: {
id: 'app.createBreakoutRoom.notAssigned',
description: 'Not assigned label',
},
breakoutRoomLabel: {
id: 'app.createBreakoutRoom.breakoutRoomLabel',
description: 'breakout room label',
},
addParticipantLabel: {
id: 'app.createBreakoutRoom.addParticipantLabel',
description: 'add Participant label',
},
nextLabel: {
id: 'app.createBreakoutRoom.nextLabel',
description: 'Next label',
},
backLabel: {
id: 'app.audio.backLabel',
description: 'Back label',
},
minusRoomTime: {
id: 'app.createBreakoutRoom.minusRoomTime',
description: 'aria label for btn to decrease room time',
},
addRoomTime: {
id: 'app.createBreakoutRoom.addRoomTime',
description: 'aria label for btn to increase room time',
},
record: {
id: 'app.createBreakoutRoom.record',
description: 'label for checkbox to allow record',
},
roomTime: {
id: 'app.createBreakoutRoom.roomTime',
description: 'used to provide current room time for aria label',
},
numberOfRoomsIsValid: {
id: 'app.createBreakoutRoom.numberOfRoomsError',
description: 'Label an error message',
},
roomNameEmptyIsValid: {
id: 'app.createBreakoutRoom.emptyRoomNameError',
description: 'Label an error message',
},
roomNameDuplicatedIsValid: {
id: 'app.createBreakoutRoom.duplicatedRoomNameError',
description: 'Label an error message',
},
you: {
id: 'app.userList.you',
description: 'Text for identifying your user',
},
minimumDurationWarnBreakout: {
id: 'app.createBreakoutRoom.minimumDurationWarnBreakout',
description: 'minimum duration warning message label',
},
roomNameInputDesc: {
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',
},
manageRooms: {
id: 'app.createBreakoutRoom.manageRoomsLabel',
description: 'Label for manage rooms',
},
sendInvitationToMods: {
id: 'app.createBreakoutRoom.sendInvitationToMods',
description: 'label for checkbox send invitation to moderators',
},
});
const isMe = (intId: string) => intId === Auth.userID;
const BreakoutRoomUserAssignment: React.FC<ChildComponentProps> = ({
moveUser,
rooms,
getRoomName,
changeRoomName,
numberOfRooms,
setSelectedId,
randomlyAssign,
resetRooms,
users,
}) => {
const intl = useIntl();
const dragStart = (ev: React.DragEvent<HTMLParagraphElement>) => {
const paragraphElement = ev.target as HTMLParagraphElement;
ev.dataTransfer.setData('text', paragraphElement.id);
setSelectedId(paragraphElement.id);
};
const dragEnd = () => {
setSelectedId('');
};
const allowDrop = (ev: React.DragEvent) => {
ev.preventDefault();
};
const drop = (roomNumber: number) => (ev: React.DragEvent) => {
if (ev.preventDefault) {
ev.preventDefault();
}
const data = ev.dataTransfer.getData('text');
const [userId, from] = data.split('-');
moveUser(userId, Number(from), roomNumber);
setSelectedId('');
};
const hasNameDuplicated = (room: number) => {
const roomName = rooms[room]?.name || '';
return Object.values(rooms).filter((r) => r.name === roomName).length > 1;
};
useEffect(() => {
if (numberOfRooms) {
resetRooms(numberOfRooms);
}
}, [numberOfRooms]);
const roomUserList = (room: number) => {
if (rooms[room]) {
return rooms[room].users.map((user) => {
return (
<Styled.RoomUserItem
tabIndex={-1}
id={`${user.userId}-${room}`}
key={user.userId}
draggable
onDragStart={dragStart}
onDragEnd={dragEnd}
>
<span>
<span>{user.name}</span>
<i>{(isMe(user.userId)) ? ` (${intl.formatMessage(intlMessages.you)})` : ''}</i>
</span>
{room !== 0
? (
<span
key={`${user.userId}-${room}`}
tabIndex={0}
className="close"
role="button"
aria-label={intl.formatMessage(intlMessages.resetUserRoom)}
onKeyDown={() => {
moveUser(user.userId, room, 0);
}}
onClick={() => {
moveUser(user.userId, room, 0);
}}
>
<Icon iconName="close" />
</span>
) : null}
</Styled.RoomUserItem>
);
});
}
return '';
};
return (
<>
<ManageRoomLabel
onAssignReset={() => { resetRooms(0); }}
onAssignRandomly={randomlyAssign}
numberOfRoomsIsValid={numberOfRooms > 0}
leastOneUserIsValid={rooms[0]?.users?.length < users.length}
/>
<Styled.ContentContainer>
<Styled.Alert valid role="alert">
<Styled.FreeJoinLabel>
<Styled.BreakoutNameInput
type="text"
readOnly
value={
intl.formatMessage(intlMessages.notAssigned, { 0: 0 })
}
/>
</Styled.FreeJoinLabel>
<Styled.BreakoutBox
hundred
id="breakoutBox-0"
onDrop={drop(0)}
onDragOver={allowDrop}
tabIndex={0}
>
{roomUserList(0)}
</Styled.BreakoutBox>
<Styled.SpanWarn data-test="warningNoUserAssigned" valid={rooms[0]?.users?.length < users.length}>
{intl.formatMessage(intlMessages.leastOneWarnBreakout)}
</Styled.SpanWarn>
</Styled.Alert>
<Styled.BoxContainer key="rooms-grid-" data-test="roomGrid">
{
range(1, numberOfRooms + 1).map((value) => (
<div key={`room-${value}`}>
<Styled.FreeJoinLabel>
<Styled.RoomName
type="text"
maxLength={255}
duplicated={hasNameDuplicated(value)}
value={getRoomName(value)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
changeRoomName(value, e.target.value);
}}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
changeRoomName(value, e.target.value);
}}
data-test={getRoomName(value).length === 0 ? `room-error-${value}` : `roomName-${value}`}
/>
<div aria-hidden id={`room-input-${value}`} className="sr-only">
{intl.formatMessage(intlMessages.roomNameInputDesc)}
</div>
</Styled.FreeJoinLabel>
<Styled.BreakoutBox
id={`breakoutBox-${value}`}
onDrop={drop(value)}
onDragOver={allowDrop}
hundred={false}
tabIndex={0}
>
{roomUserList(value)}
</Styled.BreakoutBox>
{hasNameDuplicated(value) ? (
<Styled.SpanWarn valid>
{intl.formatMessage(intlMessages.roomNameDuplicatedIsValid)}
</Styled.SpanWarn>
) : null}
{getRoomName(value).length === 0 ? (
<Styled.SpanWarn valid aria-hidden id={`room-error-${value}`}>
{intl.formatMessage(intlMessages.roomNameEmptyIsValid)}
</Styled.SpanWarn>
) : null}
</div>
))
}
</Styled.BoxContainer>
</Styled.ContentContainer>
</>
);
};
export default BreakoutRoomUserAssignment;

View File

@ -0,0 +1,566 @@
import React, { useMemo } from 'react';
import ModalFullscreen from '/imports/ui/components/common/modal/fullscreen/component';
import { defineMessages, useIntl } from 'react-intl';
import { range } from 'ramda';
import { Meteor } from 'meteor/meteor';
import { uniqueId } from '/imports/utils/string-utils';
import { isImportPresentationWithAnnotationsFromBreakoutRoomsEnabled, isImportSharedNotesFromBreakoutRoomsEnabled } from '/imports/ui/services/features';
import useMeeting from '/imports/ui/core/hooks/useMeeting';
import { useLazyQuery, useQuery } from '@apollo/client';
import Styled from './styles';
import {
getBreakouts,
getBreakoutsResponse,
getUser,
getUserResponse,
} from './queries';
import logger from '/imports/startup/client/logger';
import BreakoutRoomUserAssignment from './breakout-room-user-assignment/component';
import deviceInfo from '/imports/utils/deviceInfo';
import BreakoutRoomUserAssignmentMobile from './breakout-room-user-assignment-mobile/component';
import RoomManagmentState from './room-managment-state/component';
import {
Rooms,
RoomToWithSettings,
BreakoutUser,
moveUserRegistery,
} from './room-managment-state/types';
import { createBreakoutRoom, moveUser } from './service';
const BREAKOUT_LIM = Meteor.settings.public.app.breakouts.breakoutRoomLimit;
const MIN_BREAKOUT_ROOMS = 2;
const MAX_BREAKOUT_ROOMS = BREAKOUT_LIM > MIN_BREAKOUT_ROOMS ? BREAKOUT_LIM : MIN_BREAKOUT_ROOMS;
const MIN_BREAKOUT_TIME = 5;
const DEFAULT_BREAKOUT_TIME = 15;
interface CreateBreakoutRoomContainerProps {
isOpen: boolean
setIsOpen: (isOpen: boolean) => void
priority: number,
isUpdate?: boolean,
}
interface CreateBreakoutRoomProps extends CreateBreakoutRoomContainerProps {
isBreakoutRecordable: boolean,
users: Array<BreakoutUser>,
runningRooms: getBreakoutsResponse['breakoutRoom'],
}
const intlMessages = defineMessages({
breakoutRoomTitle: {
id: 'app.createBreakoutRoom.title',
description: 'modal title',
},
breakoutRoomDesc: {
id: 'app.createBreakoutRoom.modalDesc',
description: 'modal description',
},
breakoutRoomUpdateDesc: {
id: 'app.updateBreakoutRoom.modalDesc',
description: 'update modal description',
},
cancelLabel: {
id: 'app.updateBreakoutRoom.cancelLabel',
description: 'used in the button that close update modal',
},
updateTitle: {
id: 'app.updateBreakoutRoom.title',
description: 'update breakout title',
},
updateConfirm: {
id: 'app.updateBreakoutRoom.confirm',
description: 'Update to breakout confirm button label',
},
resetUserRoom: {
id: 'app.update.resetRoom',
description: 'Reset user room button label',
},
confirmButton: {
id: 'app.createBreakoutRoom.confirm',
description: 'confirm button label',
},
dismissLabel: {
id: 'app.presentationUploder.dismissLabel',
description: 'used in the button that close modal',
},
numberOfRooms: {
id: 'app.createBreakoutRoom.numberOfRooms',
description: 'number of rooms label',
},
duration: {
id: 'app.createBreakoutRoom.durationInMinutes',
description: 'duration time label',
},
resetAssignments: {
id: 'app.createBreakoutRoom.resetAssignments',
description: 'reset assignments label',
},
resetAssignmentsDesc: {
id: 'app.createBreakoutRoom.resetAssignmentsDesc',
description: 'reset assignments label description',
},
randomlyAssign: {
id: 'app.createBreakoutRoom.randomlyAssign',
description: 'randomly assign label',
},
randomlyAssignDesc: {
id: 'app.createBreakoutRoom.randomlyAssignDesc',
description: 'randomly assign label description',
},
breakoutRoom: {
id: 'app.createBreakoutRoom.room',
description: 'breakout room',
},
freeJoinLabel: {
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',
},
captureNotesType: {
id: 'app.notes.label',
description: 'indicates notes have been captured',
},
captureSlidesType: {
id: 'app.shortcut-help.whiteboard',
description: 'indicates the whiteboard has been captured',
},
roomLabel: {
id: 'app.createBreakoutRoom.room',
description: 'Room label',
},
leastOneWarnBreakout: {
id: 'app.createBreakoutRoom.leastOneWarnBreakout',
description: 'warn message label',
},
notAssigned: {
id: 'app.createBreakoutRoom.notAssigned',
description: 'Not assigned label',
},
breakoutRoomLabel: {
id: 'app.createBreakoutRoom.breakoutRoomLabel',
description: 'breakout room label',
},
addParticipantLabel: {
id: 'app.createBreakoutRoom.addParticipantLabel',
description: 'add Participant label',
},
nextLabel: {
id: 'app.createBreakoutRoom.nextLabel',
description: 'Next label',
},
backLabel: {
id: 'app.audio.backLabel',
description: 'Back label',
},
minusRoomTime: {
id: 'app.createBreakoutRoom.minusRoomTime',
description: 'aria label for btn to decrease room time',
},
addRoomTime: {
id: 'app.createBreakoutRoom.addRoomTime',
description: 'aria label for btn to increase room time',
},
record: {
id: 'app.createBreakoutRoom.record',
description: 'label for checkbox to allow record',
},
roomTime: {
id: 'app.createBreakoutRoom.roomTime',
description: 'used to provide current room time for aria label',
},
numberOfRoomsIsValid: {
id: 'app.createBreakoutRoom.numberOfRoomsError',
description: 'Label an error message',
},
roomNameEmptyIsValid: {
id: 'app.createBreakoutRoom.emptyRoomNameError',
description: 'Label an error message',
},
roomNameDuplicatedIsValid: {
id: 'app.createBreakoutRoom.duplicatedRoomNameError',
description: 'Label an error message',
},
you: {
id: 'app.userList.you',
description: 'Text for identifying your user',
},
minimumDurationWarnBreakout: {
id: 'app.createBreakoutRoom.minimumDurationWarnBreakout',
description: 'minimum duration warning message label',
},
roomNameInputDesc: {
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',
},
manageRooms: {
id: 'app.createBreakoutRoom.manageRoomsLabel',
description: 'Label for manage rooms',
},
sendInvitationToMods: {
id: 'app.createBreakoutRoom.sendInvitationToMods',
description: 'label for checkbox send invitation to moderators',
},
});
const CreateBreakoutRoom: React.FC<CreateBreakoutRoomProps> = ({
isOpen,
setIsOpen,
priority,
isUpdate = false,
isBreakoutRecordable,
users,
runningRooms,
}) => {
const { isMobile } = deviceInfo;
const intl = useIntl();
const [numberOfRoomsIsValid, setNumberOfRoomsIsValid] = React.useState(true);
const [durationIsValid, setDurationIsValid] = React.useState(true);
const [freeJoin, setFreeJoin] = React.useState(false);
const [record, setRecord] = React.useState(false);
const [captureSlides, setCaptureSlides] = React.useState(false);
const [leastOneUserIsValid, setLeastOneUserIsValid] = React.useState(false);
const [captureNotes, setCaptureNotes] = React.useState(false);
const [inviteMods, setInviteMods] = React.useState(false);
const [numberOfRooms, setNumberOfRooms] = React.useState(MIN_BREAKOUT_ROOMS);
const [durationTime, setDurationTime] = React.useState(DEFAULT_BREAKOUT_TIME);
const roomsRef = React.useRef<Rooms>({});
const moveRegisterRef = React.useRef<moveUserRegistery>({});
const setRoomsRef = (rooms: Rooms) => {
roomsRef.current = rooms;
};
const setMoveRegisterRef = (moveRegister: moveUserRegistery) => {
moveRegisterRef.current = moveRegister;
};
const checkboxCallbackFactory = (call: (value: boolean) => void) => (e: React.ChangeEvent<HTMLInputElement>) => {
const { checked } = e.target;
call(checked);
};
const createRoom = () => {
const rooms = roomsRef.current;
const roomsArray: RoomToWithSettings[] = [];
/* eslint no-restricted-syntax: "off" */
for (let i = 0; i < numberOfRooms; i += 1) {
const roomNumber = i + 1;
if (rooms[roomNumber]) {
const r = rooms[roomNumber];
roomsArray.push({
name: r.name,
sequence: r.id,
captureNotesFilename: `${r.name.replace(/\s/g, '_')}_${intl.formatMessage(intlMessages.captureNotesType)}`,
captureSlidesFilename: `${r.name.replace(/\s/g, '_')}_${intl.formatMessage(intlMessages.captureSlidesType)}`,
isDefaultName: r.name === intl.formatMessage(intlMessages.breakoutRoom, {
0: r.id,
}),
users: r.users.map((u) => u.userId),
freeJoin,
shortName: r.name,
});
} else {
const defaultName = intl.formatMessage(intlMessages.breakoutRoom, {
0: roomNumber,
});
roomsArray.push({
name: defaultName,
sequence: roomNumber,
captureNotesFilename: `${defaultName.replace(/\s/g, '_')}_${intl.formatMessage(intlMessages.captureNotesType)}`,
captureSlidesFilename: `${defaultName.replace(/\s/g, '_')}_${intl.formatMessage(intlMessages.captureSlidesType)}`,
isDefaultName: true,
freeJoin,
shortName: defaultName,
users: [],
});
}
}
createBreakoutRoom(roomsArray, durationTime, record, captureSlides, captureNotes, inviteMods);
setIsOpen(false);
};
const userUpdate = () => {
const userIds = Object.keys(moveRegisterRef.current);
userIds.forEach((userId) => {
const { fromRoomId, toRoomId } = moveRegisterRef.current[userId];
if (fromRoomId !== toRoomId) {
moveUser(userId, fromRoomId, toRoomId);
}
});
setIsOpen(false);
};
const title = useMemo(() => (
<Styled.SubTitle>
{isUpdate
? intl.formatMessage(intlMessages.breakoutRoomUpdateDesc)
: intl.formatMessage(intlMessages.breakoutRoomDesc)}
</Styled.SubTitle>
), [isUpdate]);
const checkboxesInfo = useMemo(() => {
return [
{
allowed: true,
htmlFor: 'freeJoinCheckbox',
key: 'free-join-breakouts',
id: 'freeJoinCheckbox',
onChange: checkboxCallbackFactory((e: boolean) => {
setFreeJoin(e);
setLeastOneUserIsValid(true);
}),
label: intl.formatMessage(intlMessages.freeJoinLabel),
},
{
allowed: isBreakoutRecordable,
htmlFor: 'recordBreakoutCheckbox',
key: 'record-breakouts',
id: 'recordBreakoutCheckbox',
onChange: checkboxCallbackFactory(setRecord),
label: intl.formatMessage(intlMessages.record),
},
{
allowed: isImportPresentationWithAnnotationsFromBreakoutRoomsEnabled(),
htmlFor: 'captureSlidesBreakoutCheckbox',
key: 'capture-slides-breakouts',
id: 'captureSlidesBreakoutCheckbox',
onChange: checkboxCallbackFactory(setCaptureSlides),
label: intl.formatMessage(intlMessages.captureSlidesLabel),
},
{
allowed: isImportSharedNotesFromBreakoutRoomsEnabled(),
htmlFor: 'captureNotesBreakoutCheckbox',
key: 'capture-notes-breakouts',
id: 'captureNotesBreakoutCheckbox',
onChange: checkboxCallbackFactory(setCaptureNotes),
label: intl.formatMessage(intlMessages.captureNotesLabel),
},
{
allowed: true,
htmlFor: 'sendInvitationToAssignedModeratorsCheckbox',
key: 'send-invitation-to-assigned-moderators-breakouts',
id: 'sendInvitationToAssignedModeratorsCheckbox',
onChange: checkboxCallbackFactory(setInviteMods),
label: intl.formatMessage(intlMessages.sendInvitationToMods),
},
];
}, [isBreakoutRecordable]);
const form = useMemo(() => {
return (
<React.Fragment key="breakout-form">
<Styled.BreakoutSettings>
<div>
<Styled.FormLabel valid={numberOfRoomsIsValid} aria-hidden>
{intl.formatMessage(intlMessages.numberOfRooms)}
</Styled.FormLabel>
<Styled.InputRooms
id="numberOfRooms"
name="numberOfRooms"
valid={numberOfRoomsIsValid}
value={numberOfRooms}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
const { value } = e.target;
setNumberOfRooms(Number.parseInt(value, 10));
setNumberOfRoomsIsValid(true);
}}
aria-label={intl.formatMessage(intlMessages.numberOfRooms)}
>
{
range(MIN_BREAKOUT_ROOMS, MAX_BREAKOUT_ROOMS + 1).map((item) => (<option key={uniqueId('value-')}>{item}</option>))
}
</Styled.InputRooms>
</div>
<Styled.DurationLabel valid={durationIsValid} htmlFor="breakoutRoomTime">
<Styled.LabelText bold={false} aria-hidden>
{intl.formatMessage(intlMessages.duration)}
</Styled.LabelText>
<Styled.DurationArea>
<Styled.DurationInput
type="number"
min="1"
value={durationTime}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const { value } = e.target;
const v = Number.parseInt(value, 10);
setDurationTime(v);
setDurationIsValid(v >= MIN_BREAKOUT_TIME);
}}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
const { value } = e.target;
const v = Number.parseInt(value, 10);
setDurationTime((v && !(v <= 0)) ? v : MIN_BREAKOUT_TIME);
setDurationIsValid(true);
}}
aria-label={intl.formatMessage(intlMessages.duration)}
data-test="durationTime"
/>
</Styled.DurationArea>
<Styled.SpanWarn data-test="minimumDurationWarnBreakout" valid={durationIsValid}>
{
intl.formatMessage(
intlMessages.minimumDurationWarnBreakout,
{ 0: MIN_BREAKOUT_TIME },
)
}
</Styled.SpanWarn>
</Styled.DurationLabel>
<Styled.CheckBoxesContainer key="breakout-checkboxes">
{checkboxesInfo
.filter((item) => item.allowed)
.map((item) => (
<Styled.FreeJoinLabel htmlFor={item.htmlFor} key={item.key}>
<Styled.FreeJoinCheckbox
type="checkbox"
id={item.id}
onChange={item.onChange}
aria-label={item.label}
/>
<span aria-hidden>{item.label}</span>
</Styled.FreeJoinLabel>
))}
</Styled.CheckBoxesContainer>
</Styled.BreakoutSettings>
<Styled.SpanWarn valid={numberOfRoomsIsValid}>
{intl.formatMessage(intlMessages.numberOfRoomsIsValid)}
</Styled.SpanWarn>
<span aria-hidden id="randomlyAssignDesc" className="sr-only">
{intl.formatMessage(intlMessages.randomlyAssignDesc)}
</span>
<Styled.Separator />
</React.Fragment>
);
}, [durationTime, durationIsValid, numberOfRooms, numberOfRoomsIsValid]);
return (
<ModalFullscreen
title={
isUpdate
? intl.formatMessage(intlMessages.updateTitle)
: intl.formatMessage(intlMessages.breakoutRoomTitle)
}
confirm={
{
label: isUpdate
? intl.formatMessage(intlMessages.updateConfirm)
: intl.formatMessage(intlMessages.confirmButton),
callback: isUpdate ? userUpdate : createRoom,
disabled: !leastOneUserIsValid || !numberOfRoomsIsValid || !durationIsValid,
}
}
dismiss={{
label: isUpdate
? intl.formatMessage(intlMessages.cancelLabel)
: intl.formatMessage(intlMessages.dismissLabel),
callback: () => setIsOpen(false),
disabled: false,
}}
isOpen={isOpen}
priority={priority}
>
<Styled.Content>
{title}
{form}
<RoomManagmentState
numberOfRooms={numberOfRooms}
users={users}
RendererComponent={isMobile ? BreakoutRoomUserAssignmentMobile : BreakoutRoomUserAssignment}
runningRooms={runningRooms}
setRoomsRef={setRoomsRef}
setMoveRegisterRef={setMoveRegisterRef}
setFormIsValid={setLeastOneUserIsValid}
/>
</Styled.Content>
</ModalFullscreen>
);
};
const CreateBreakoutRoomContainer: React.FC<CreateBreakoutRoomContainerProps> = ({
isOpen,
setIsOpen,
priority,
isUpdate,
}) => {
const [fetchedBreakouts, setFetchedBreakouts] = React.useState(false);
// isBreakoutRecordable - get from meeting breakout policies breakoutPolicies/record
const {
data: currentMeeting,
} = useMeeting((m) => ({
breakoutPolicies: m.breakoutPolicies,
}));
const {
data: usersData,
loading: usersLoading,
error: usersError,
} = useQuery<getUserResponse>(getUser, {
fetchPolicy: 'network-only',
});
const [
loadBreakouts,
{
data: breakoutsData,
loading: breakoutsLoading,
error: breakoutsError,
},
] = useLazyQuery<getBreakoutsResponse>(getBreakouts, {
fetchPolicy: 'network-only',
});
if (usersLoading || breakoutsLoading || !currentMeeting) {
return null;
}
if (true && !fetchedBreakouts) {
loadBreakouts();
setFetchedBreakouts(true);
}
if (true && breakoutsLoading) return null;
if (usersError || breakoutsError) {
logger.info('Error loading users', usersError);
logger.info('Error loading breakouts', breakoutsError);
return (
<div>
{JSON.stringify(usersError) || JSON.stringify(breakoutsError)}
</div>
);
}
return (
<CreateBreakoutRoom
isOpen={isOpen}
setIsOpen={setIsOpen}
priority={priority}
isUpdate={isUpdate}
isBreakoutRecordable={currentMeeting?.breakoutPolicies?.record ?? true}
users={usersData?.user ?? []}
runningRooms={breakoutsData?.breakoutRoom ?? []}
/>
);
};
CreateBreakoutRoomContainer.defaultProps = {
isUpdate: false,
};
CreateBreakoutRoom.defaultProps = {
isUpdate: false,
};
export default CreateBreakoutRoomContainer;

View File

@ -0,0 +1,72 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Styled from '../styles';
const intlMessages = defineMessages({
manageRooms: {
id: 'app.createBreakoutRoom.manageRoomsLabel',
description: 'Label for manage rooms',
},
resetAssignments: {
id: 'app.createBreakoutRoom.resetAssignments',
description: 'reset assignments label',
},
resetAssignmentsDesc: {
id: 'app.createBreakoutRoom.resetAssignmentsDesc',
description: 'reset assignments label description',
},
randomlyAssign: {
id: 'app.createBreakoutRoom.randomlyAssign',
description: 'randomly assign label',
},
randomlyAssignDesc: {
id: 'app.createBreakoutRoom.randomlyAssignDesc',
description: 'randomly assign label description',
},
});
interface ManageRoomLabelProps {
numberOfRoomsIsValid: boolean;
leastOneUserIsValid: boolean;
onAssignReset: () => void;
onAssignRandomly: () => void;
}
const ManageRoomLabel: React.FC<ManageRoomLabelProps> = ({
numberOfRoomsIsValid,
leastOneUserIsValid,
onAssignReset,
onAssignRandomly,
}) => {
const intl = useIntl();
return (
<Styled.AssignBtnsContainer>
<Styled.LabelText bold aria-hidden>
{intl.formatMessage(intlMessages.manageRooms)}
</Styled.LabelText>
{leastOneUserIsValid ? (
<Styled.AssignBtns
data-test="resetAssignments"
label={intl.formatMessage(intlMessages.resetAssignments)}
aria-describedby="resetAssignmentsDesc"
onClick={onAssignReset}
size="sm"
color="default"
disabled={!numberOfRoomsIsValid}
/>
) : (
<Styled.AssignBtns
random
data-test="randomlyAssign"
label={intl.formatMessage(intlMessages.randomlyAssign)}
aria-describedby="randomlyAssignDesc"
onClick={onAssignRandomly}
size="sm"
color="default"
/>
)}
</Styled.AssignBtnsContainer>
);
};
export default ManageRoomLabel;

View File

@ -0,0 +1,75 @@
import { gql } from '@apollo/client';
import { BreakoutUser } from './room-managment-state/types';
export interface getUserResponse {
user: Array<BreakoutUser>;
}
export interface breakoutRoom {
sequence: number;
name: string;
breakoutRoomId: string;
participants: Array<{
user: {
name: string;
userId: string;
isModerator: boolean;
}
}>
}
export interface getBreakoutsResponse {
breakoutRoom: Array<breakoutRoom>
}
export const getUser = gql`
query getUser {
user(
order_by: [
{role: asc},
{raiseHandTime: asc_nulls_last},
{awayTime: asc_nulls_last},
{emojiTime: asc_nulls_last},
{isDialIn: desc},
{hasDrawPermissionOnCurrentPage: desc},
{nameSortable: asc},
{userId: asc}
]) {
userId
name
isModerator
}
}
`;
export const getBreakouts = gql`
query getBreakouts {
breakoutRoom {
sequence
name
breakoutRoomId
participants {
user {
name
userId
isModerator
}
}
}
}
`;
export const getBreakoutCount = gql`
query getBreakoutCount {
breakoutRoom_aggregate {
aggregate {
count
}
}
}
`;
export default {
getUser,
getBreakouts,
};

View File

@ -0,0 +1,218 @@
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import {
BreakoutUser,
Rooms,
ChildComponentProps,
Room,
moveUserRegistery,
} from './types';
import { breakoutRoom, getBreakoutsResponse } from '../queries';
const intlMessages = defineMessages({
breakoutRoom: {
id: 'app.createBreakoutRoom.room',
description: 'breakout room',
},
notAssigned: {
id: 'app.createBreakoutRoom.notAssigned',
description: 'Not assigned label',
},
});
interface RoomManagmentStateProps {
numberOfRooms: number;
users: BreakoutUser[];
RendererComponent: React.FC<ChildComponentProps>;
runningRooms: getBreakoutsResponse['breakoutRoom'] | null;
setFormIsValid: (isValid: boolean) => void;
setRoomsRef: (rooms: Rooms) => void;
setMoveRegisterRef: (moveRegister: moveUserRegistery) => void;
}
const RoomManagmentState: React.FC<RoomManagmentStateProps> = ({
numberOfRooms,
users,
RendererComponent,
setFormIsValid,
runningRooms,
setRoomsRef,
setMoveRegisterRef,
}) => {
const intl = useIntl();
const [selectedId, setSelectedId] = useState<string>('');
const [selectedRoom, setSelectedRoom] = useState<number>(0);
const [rooms, setRooms] = useState<Rooms>({});
const [init, setInit] = useState<boolean>(false);
const [movementRegistered, setMovementRegistered] = useState<moveUserRegistery>({});
const moveUser = (userId: string, from: number, to: number) => {
const room = rooms[to];
const roomFrom = rooms[Number(from)];
if (from === to) return null;
setMovementRegistered({
...movementRegistered,
[userId]: {
fromSequence: from,
toSequence: to,
toRoomId: runningRooms?.find((r) => r.sequence === to)?.breakoutRoomId,
fromRoomId: runningRooms?.find((r) => r.sequence === from)?.breakoutRoomId,
},
});
if (!rooms[to]) {
const oldStateRooms = { ...rooms };
oldStateRooms[from].users = (oldStateRooms[from].users.filter((user) => user.userId !== userId) || []);
return setRooms({
...oldStateRooms,
[to]: {
id: to,
name: intl.formatMessage(intlMessages.breakoutRoom, {
0: to,
}),
users: [users.find((user) => user.userId === userId)],
} as Room,
});
}
return setRooms({
...rooms,
[to]: {
...room,
users: [
...(room?.users ?? []),
roomFrom?.users?.find((user) => user.userId === userId),
],
} as Room,
[Number(from)]: {
...roomFrom,
users: roomFrom?.users.filter((user) => user.userId !== userId),
} as Room,
});
};
const roomName = (room: number) => {
const defaultName = intl.formatMessage(intlMessages.breakoutRoom, {
0: room,
});
if (rooms[room]) {
return rooms[room].name || defaultName;
}
return defaultName;
};
const changeRoomName = (room: number, name: string) => {
setRooms((prevRooms) => {
const rooms = { ...prevRooms };
if (!rooms[room]) {
rooms[room] = {
id: room,
name: '',
users: [],
};
}
rooms[room].name = name;
return rooms;
});
};
const randomlyAssign = () => {
const withoutModerators = rooms[0].users.filter((user) => !user.isModerator);
withoutModerators.forEach((user) => {
const randomRoom = Math.floor(Math.random() * numberOfRooms) + 1;
moveUser(user.userId, 0, randomRoom);
});
};
const resetRooms = (cap: number) => {
const greterThanRooms = Object.keys(rooms).filter((room) => Number(room) > cap);
greterThanRooms.forEach((room) => {
if (rooms && rooms[Number(room)]) {
setRooms((prevRooms) => ({
...prevRooms,
0: {
...prevRooms[0],
users: [
...prevRooms[0].users,
...rooms[Number(room)].users,
],
},
[Number(room)]: {
...prevRooms[Number(room)],
users: [],
},
}));
}
});
};
useEffect(() => {
if (users && users.length > 0) {
// set users to room 0
setInit(true);
setRooms((prevRooms: Rooms) => ({
...(prevRooms ?? {}),
0: {
id: 0,
name: intl.formatMessage(intlMessages.notAssigned, { 0: 0 }),
users,
},
}));
}
}, [users]);
useEffect(() => {
if (runningRooms && init) {
runningRooms.forEach((r: breakoutRoom) => {
r.participants.forEach((u) => {
if (!rooms[r.sequence]?.users?.find((user) => user.userId === u.user.userId)) {
moveUser(u.user.userId, 0, r.sequence);
}
});
});
}
}, [runningRooms, init]);
useEffect(() => {
if (rooms) {
setRoomsRef(rooms);
if (!(rooms[0]?.users?.length === users.length)) {
setFormIsValid(true);
} else {
setFormIsValid(false);
}
}
}, [rooms]);
useEffect(() => {
if (movementRegistered) {
setMoveRegisterRef(movementRegistered);
}
}, [movementRegistered]);
return (
<>
{
init ? (
<RendererComponent
moveUser={moveUser}
rooms={rooms}
getRoomName={roomName}
changeRoomName={changeRoomName}
numberOfRooms={numberOfRooms}
selectedId={selectedId ?? ''}
setSelectedId={setSelectedId}
selectedRoom={selectedRoom}
setSelectedRoom={setSelectedRoom}
randomlyAssign={randomlyAssign}
resetRooms={resetRooms}
users={users}
/>
) : null
}
</>
);
};
export default RoomManagmentState;

View File

@ -0,0 +1,48 @@
import { User } from '/imports/ui/Types/user';
export type BreakoutUser = Pick<User, 'userId' | 'name' | 'isModerator'>;
export type Room = {
id: number;
name: string;
users: BreakoutUser[];
};
export interface moveUserRegistery {
[userId: string]: {
fromRoomId?: string;
toRoomId?: string;
fromSequence: number;
toSequence: number;
}
}
export type RoomToWithSettings = {
name: string;
users: string[];
captureNotesFilename: string;
captureSlidesFilename: string;
isDefaultName: boolean;
freeJoin: boolean;
sequence: number;
shortName: string;
};
export type Rooms = {
[key: number]: Room;
};
export type ChildComponentProps = {
moveUser: (userId: string, fromRoomId: number, toRoomId: number) => void;
rooms: Rooms;
getRoomName: (roomId: number) => string;
changeRoomName: (roomId: number, name: string) => void;
numberOfRooms: number;
selectedId: string;
setSelectedId: (id: string) => void;
selectedRoom: number;
setSelectedRoom: (id: number) => void;
randomlyAssign: () => void;
resetRooms: (cap: number) => void;
users: BreakoutUser[];
}

View File

@ -0,0 +1,30 @@
import { RoomToWithSettings } from './room-managment-state/types';
import { makeCall } from '/imports/ui/services/api';
export const createBreakoutRoom = (
rooms: RoomToWithSettings[],
durationInMinutes: number,
record: boolean = false,
captureNotes: boolean = false,
captureSlides: boolean = false,
sendInviteToModerators: boolean = false,
) => makeCall(
'createBreakoutRoom',
rooms,
durationInMinutes,
record,
captureNotes,
captureSlides,
sendInviteToModerators,
);
export const moveUser = (
userId: string,
fromBreakoutId: string | undefined,
toBreakoutId: string | undefined,
) => makeCall('moveUser', fromBreakoutId, toBreakoutId, userId);
export default {
createBreakoutRoom,
moveUser,
};

View File

@ -0,0 +1,383 @@
import styled from 'styled-components';
import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
import { ScrollboxVertical } from '/imports/ui/stylesheets/styled-components/scrollable';
import HoldButton from '/imports/ui/components/presentation/presentation-toolbar/zoom-tool/holdButton/component';
import Button from '/imports/ui/components/common/button/component';
import { FlexRow, FlexColumn } from '/imports/ui/stylesheets/styled-components/placeholders';
import {
colorDanger,
colorGray,
colorGrayLight,
colorGrayLighter,
colorWhite,
colorPrimary,
colorBlueLight,
colorGrayLightest,
} from '/imports/ui/stylesheets/styled-components/palette';
import { fontSizeSmall, fontSizeBase, fontSizeSmaller } from '/imports/ui/stylesheets/styled-components/typography';
import {
borderRadius,
borderSize,
lgPaddingX,
lgPaddingY,
} from '/imports/ui/stylesheets/styled-components/general';
type withValidProp = {
valid: boolean;
};
type BreakoutBoxProps = {
hundred: boolean;
};
type RoomNameProps = {
value: string;
duplicated: boolean;
maxLength: number;
};
type LabelTextProps = {
bold: boolean;
};
const BoxContainer = styled.div`
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-gap: 1.6rem 1rem;
box-sizing: border-box;
padding-bottom: 1rem;
`;
const ContentContainer = styled.div`
display: grid;
grid-template-columns: 1fr 2fr;
grid-template-areas: "sidebar content";
grid-gap: 1.5rem;
`;
const Alert = styled.div<withValidProp>`
grid-area: sidebar;
margin-bottom: 2.5rem;
${({ valid }) => valid === false && `
position: relative;
& > * {
border-color: ${colorDanger} !important;
color: ${colorDanger};
}
`}
`;
const FreeJoinLabel = styled.label`
font-size: ${fontSizeSmall};
font-weight: bolder;
display: flex;
align-items: center;
font-size: ${fontSizeSmall};
margin-bottom: 0.2rem;
& > * {
margin: 0 .5rem 0 0;
[dir="rtl"] & {
margin: 0 0 0 .5rem;
}
}
`;
const BreakoutNameInput = styled.input`
width: 100%;
text-align: center;
font-weight: 600;
padding: .25rem .25rem .25rem 0;
margin: 0;
&::placeholder {
color: ${colorGray};
opacity: 1;
}
border: 1px solid ${colorGrayLightest};
margin-bottom: 1rem;
${({ readOnly }) => readOnly && `
cursor: default;
`}
`;
const BreakoutBox = styled(ScrollboxVertical)<BreakoutBoxProps>`
width: 100%;
height: 10rem;
border: 1px solid ${colorGrayLightest};
border-radius: ${borderRadius};
padding: ${lgPaddingY} 0;
${({ hundred }) => hundred && `
height: 100%;
`}
`;
const SpanWarn = styled.span<withValidProp>`
${({ valid }) => valid && `
display: none;
`}
${({ valid }) => !valid && `
margin: .25rem;
position: absolute;
font-size: ${fontSizeSmall};
color: ${colorDanger};
font-weight: 200;
white-space: nowrap;
`}
`;
const RoomName = styled(BreakoutNameInput)<RoomNameProps>`
${({ value }) => value.length === 0 && `
border-color: ${colorDanger} !important;
`}
${({ duplicated }) => duplicated && `
border-color: ${colorDanger} !important;
`}
`;
const BreakoutSettings = styled.div`
display: grid;
grid-template-columns: 1fr 1fr 2fr;
grid-template-rows: 1fr;
grid-gap: 2rem;
@media ${smallOnly} {
grid-template-columns: 1fr ;
grid-template-rows: 1fr 1fr 1fr;
flex-direction: column;
}
`;
const FormLabel = styled.p<withValidProp>`
color: ${colorGray};
white-space: nowrap;
margin-bottom: .5rem;
${({ valid }) => !valid && `
color: ${colorDanger};
`}
`;
const InputRooms = styled.select<withValidProp>`
background-color: ${colorWhite};
color: ${colorGray};
border: 1px solid ${colorGrayLighter};
border-radius: ${borderRadius};
width: 100%;
padding-top: .25rem;
padding-bottom: .25rem;
padding: .25rem 0 .25rem .25rem;
${({ valid }) => !valid && `
border-color: ${colorDanger} !important;
`}
`;
const DurationLabel = styled.label<withValidProp>`
${({ valid }) => !valid && `
& > * {
border-color: ${colorDanger} !important;
color: ${colorDanger};
}
`}
`;
const LabelText = styled.p<LabelTextProps>`
color: ${colorGray};
white-space: nowrap;
margin-bottom: .5rem;
${({ bold }) => bold && `
font-weight: bold;
font-size: 1.5rem;
`}
`;
const DurationArea = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
`;
const DurationInput = styled.input`
background-color: ${colorWhite};
color: ${colorGray};
border: 1px solid ${colorGrayLighter};
border-radius: ${borderRadius};
width: 100%;
text-align: left;
padding: .25rem;
&::placeholder {
color: ${colorGray};
}
`;
const HoldButtonWrapper = styled(HoldButton)`
& > button > span {
padding-bottom: ${borderSize};
}
& > button > span > i {
color: ${colorGray};
width: ${lgPaddingX};
height: ${lgPaddingX};
font-size: 170% !important;
}
`;
const AssignBtnsContainer = styled.div`
justify-items: center;
display: flex;
flex-flow: row;
align-items: baseline;
margin-top: auto;
`;
// @ts-ignore - Button is a JS component
const AssignBtns = styled(Button)`
color: ${colorDanger};
font-size: ${fontSizeSmall};
white-space: nowrap;
margin-bottom: 0.5rem;
${({ random }) => random && `
color: ${colorPrimary};
`}
`;
const CheckBoxesContainer = styled(FlexRow)`
display: flex;
flex-flow: column;
justify-content: flex-end;
`;
const Separator = styled.div`
width: 100%;
height: 1px;
margin: 1rem 0;
border: 1px solid ${colorGrayLightest};
`;
const FreeJoinCheckbox = styled.input`
width: 1rem;
height: 1rem;
`;
const RoomUserItem = styled.p`
margin: 0;
padding: .25rem 0 .25rem .25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
display: flex;
justify-content: space-between;
[dir="rtl"] & {
padding: .25rem .25rem .25rem 0;
}
span.close {
display: flex;
flex-direction: column;
justify-content: center;
margin-right: 5px;
font-size: ${fontSizeSmaller};
}
&:focus {
background-color: ${colorPrimary};
color: ${colorWhite};
}
`;
const LockIcon = styled.span`
float: right;
margin-right: 1rem;
@media ${smallOnly} {
margin-left: .5rem;
margin-right: auto;
float: left;
}
&:after {
font-family: 'bbb-icons' !important;
content: '\\e926';
color: ${colorGrayLight};
}
`;
const ListContainer = styled(FlexColumn)`
justify-content: flex-start;
`;
const RoomItem = styled.div`
margin: 1rem 0 1rem 0;
`;
const ItemTitle = styled.h2`
margin: 0;
color: ${colorBlueLight};
`;
// @ts-ignore - Button is a JS component
const ItemButton = styled(Button)`
padding: 0;
outline: none !important;
& > span {
color: ${colorBlueLight};
}
`;
const WithError = styled.span`
color: ${colorDanger};
`;
const SubTitle = styled.p`
font-size: ${fontSizeBase};
text-align: justify;
color: ${colorGray};
`;
const Content = styled(FlexColumn)``;
export default {
BoxContainer,
Alert,
FreeJoinLabel,
BreakoutNameInput,
BreakoutBox,
SpanWarn,
RoomName,
BreakoutSettings,
FormLabel,
InputRooms,
DurationLabel,
LabelText,
DurationArea,
DurationInput,
HoldButtonWrapper,
AssignBtnsContainer,
AssignBtns,
CheckBoxesContainer,
Separator,
FreeJoinCheckbox,
RoomUserItem,
LockIcon,
ListContainer,
RoomItem,
ItemTitle,
ItemButton,
WithError,
SubTitle,
Content,
ContentContainer,
};

View File

@ -31,6 +31,7 @@ import { useMutation } from '@apollo/client';
import { SEND_GROUP_CHAT_MSG } from './mutations';
import Storage from '/imports/ui/services/storage/session';
import { indexOf, without } from '/imports/utils/array-utils';
import { GraphqlDataHookSubscriptionResponse } from '/imports/ui/Types/hook';
// @ts-ignore - temporary, while meteor exists in the project
const CHAT_CONFIG = Meteor.settings.public.chat;
@ -394,13 +395,13 @@ const ChatMessageFormContainer: React.FC = ({
const intl = useIntl();
const [showEmojiPicker, setShowEmojiPicker] = React.useState(false);
const idChatOpen: string = layoutSelect((i: Layout) => i.idChatOpen);
const chat = useChat((c: Partial<Chat>) => ({
const { data: chat } = useChat((c: Partial<Chat>) => ({
participant: c?.participant,
chatId: c?.chatId,
public: c?.public,
}), idChatOpen) as Partial<Chat>;
}), idChatOpen) as GraphqlDataHookSubscriptionResponse<Partial<Chat>>;
const currentUser = useCurrentUser((c) => ({
const { data: currentUser } = useCurrentUser((c) => ({
isModerator: c?.isModerator,
locked: c?.locked,
}));
@ -409,7 +410,7 @@ const ChatMessageFormContainer: React.FC = ({
? intl.formatMessage(messages.titlePrivate, { 0: chat?.participant?.name })
: intl.formatMessage(messages.titlePublic);
const meeting = useMeeting((m) => ({
const { data: meeting } = useMeeting((m) => ({
lockSettings: m?.lockSettings,
}));

View File

@ -23,6 +23,7 @@ import { Message } from '/imports/ui/Types/message';
import ChatPopupContainer from '../chat-popup/component';
import { ChatEvents } from '/imports/ui/core/enums/chat';
import { Layout } from '../../../layout/layoutTypes';
import { GraphqlDataHookSubscriptionResponse } from '/imports/ui/Types/hook';
// @ts-ignore - temporary, while meteor exists in the project
const CHAT_CONFIG = Meteor.settings.public.chat;
@ -316,14 +317,14 @@ const ChatMessageListContainer: React.FC = () => {
const idChatOpen = layoutSelect((i: Layout) => i.idChatOpen);
const isPublicChat = idChatOpen === PUBLIC_CHAT_KEY;
const chatId = !isPublicChat ? idChatOpen : PUBLIC_GROUP_CHAT_KEY;
const currentChat = useChat((chat) => {
const { data: currentChat } = useChat((chat) => {
return {
chatId: chat.chatId,
totalMessages: chat.totalMessages,
totalUnread: chat.totalUnread,
lastSeenAt: chat.lastSeenAt,
};
}, chatId) as Partial<Chat>;
}, chatId) as GraphqlDataHookSubscriptionResponse<Partial<Chat>>;
const [setMessageAsSeenMutation] = useMutation(LAST_SEEN_MUTATION);

View File

@ -37,6 +37,14 @@ const intlMessages = defineMessages({
id: 'app.chat.clearPublicChatMessage',
description: 'message of when clear the public chat',
},
userAway: {
id: 'app.chat.away',
description: 'message when user is away',
},
userNotAway: {
id: 'app.chat.notAway',
description: 'message when user is no longer away',
},
});
function isInViewport(el: HTMLDivElement) {
@ -137,6 +145,20 @@ const ChatMesssage: React.FC<ChatMessageProps> = ({
/>
),
};
case ChatMessageType.USER_AWAY_STATUS_MSG: {
const { away } = JSON.parse(message.messageMetadata);
return {
name: message.senderName,
color: '#0F70D7',
isModerator: true,
component: (
<ChatMessageTextContent
emphasizedMessage
text={(away) ? intl.formatMessage(intlMessages.userAway) : intl.formatMessage(intlMessages.userNotAway)}
/>
),
};
}
case ChatMessageType.TEXT:
default:
return {

View File

@ -7,6 +7,7 @@ import {
import { Message } from '/imports/ui/Types/message';
import ChatMessage from './chat-message/component';
import { useCreateUseSubscription } from '/imports/ui/core/hooks/createUseSubscription';
import { GraphqlDataHookSubscriptionResponse } from '/imports/ui/Types/hook';
// @ts-ignore - temporary, while meteor exists in the project
const CHAT_CONFIG = Meteor.settings.public.chat;
@ -76,22 +77,26 @@ const ChatListPageContainer: React.FC<ChatListPageContainerProps> = ({
const variables = isPublicChat
? defaultVariables : { ...defaultVariables, requestedChatId: chatId };
const useChatMessageSubscription = useCreateUseSubscription<Partial<Message>>(chatQuery, variables, true);
const chatMessageData = useChatMessageSubscription((msg) => msg) as Array<Message>;
const useChatMessageSubscription = useCreateUseSubscription<Message>(chatQuery, variables, true);
const {
data: chatMessageData,
} = useChatMessageSubscription((msg) => msg) as GraphqlDataHookSubscriptionResponse<Message[]>;
if (chatMessageData.length > 0) {
setLastSender(page, chatMessageData[chatMessageData.length - 1].user?.userId);
}
if (chatMessageData) {
if (chatMessageData.length > 0 && chatMessageData[chatMessageData.length - 1].user?.userId) {
setLastSender(page, chatMessageData[chatMessageData.length - 1].user?.userId);
}
return (
<ChatListPage
messages={chatMessageData}
lastSenderPreviousPage={lastSenderPreviousPage}
page={page}
markMessageAsSeen={markMessageAsSeen}
scrollRef={scrollRef}
/>
);
return (
<ChatListPage
messages={chatMessageData}
lastSenderPreviousPage={lastSenderPreviousPage}
page={page}
markMessageAsSeen={markMessageAsSeen}
scrollRef={scrollRef}
/>
);
} return (<></>);
};
export default ChatListPageContainer;

View File

@ -18,6 +18,7 @@ import { Layout } from '../../../layout/layoutTypes';
import useChat from '/imports/ui/core/hooks/useChat';
import useMeeting from '/imports/ui/core/hooks/useMeeting';
import { Chat } from '/imports/ui/Types/chat';
import { GraphqlDataHookSubscriptionResponse } from '/imports/ui/Types/hook';
const DEBUG_CONSOLE = false;
@ -111,7 +112,7 @@ const TypingIndicator: React.FC<TypingIndicatorProps> = ({
const TypingIndicatorContainer: React.FC = () => {
const idChatOpen: string = layoutSelect((i: Layout) => i.idChatOpen);
const intl = useIntl();
const currentUser = useCurrentUser((user: Partial<User>) => {
const { data: currentUser } = useCurrentUser((user: Partial<User>) => {
return {
userId: user.userId,
isModerator: user.isModerator,
@ -120,15 +121,15 @@ const TypingIndicatorContainer: React.FC = () => {
});
// eslint-disable-next-line no-unused-expressions, no-console
DEBUG_CONSOLE && console.log('TypingIndicatorContainer:currentUser', currentUser);
const chat = useChat((c) => {
const { data: chat } = useChat((c) => {
return {
participant: c?.participant,
chatId: c?.chatId,
public: c?.public,
};
}, idChatOpen) as Partial<Chat>;
}, idChatOpen) as GraphqlDataHookSubscriptionResponse<Chat>;
const meeting = useMeeting((m) => ({
const { data: meeting } = useMeeting((m) => ({
lockSettings: m?.lockSettings,
}));

View File

@ -13,6 +13,7 @@ import useChat from '/imports/ui/core/hooks/useChat';
import { Chat as ChatType } from '/imports/ui/Types/chat';
import { layoutDispatch } from '/imports/ui/components/layout/context';
import browserInfo from '/imports/utils/browserInfo';
import { GraphqlDataHookSubscriptionResponse } from '/imports/ui/Types/hook';
interface ChatProps {
@ -43,16 +44,16 @@ const ChatContainer: React.FC = () => {
const idChatOpen = layoutSelect((i: Layout) => i.idChatOpen);
const sidebarContent = layoutSelectInput((i: Input) => i.sidebarContent);
const layoutContextDispatch = layoutDispatch();
const chats = useChat((chat) => {
const { data: chats } = useChat((chat) => {
return {
chatId: chat.chatId,
participant: chat.participant,
};
}) as Partial<ChatType>[];
}) as GraphqlDataHookSubscriptionResponse<Partial<ChatType>[]>;
const [pendingChat, setPendingChat] = usePendingChat();
if (pendingChat) {
if (pendingChat && chats) {
const chat = chats.find((c) => {
return c.participant?.userId === pendingChat;
});

View File

@ -251,7 +251,7 @@ class MessageForm extends PureComponent {
stopUserTyping,
} = this.props;
const { message } = this.state;
let msg = message.trim();
const msg = message.trim();
if (msg.length < minMessageLength) return;
@ -291,8 +291,6 @@ class MessageForm extends PureComponent {
<Styled.EmojiPickerWrapper>
<Styled.EmojiPicker
onEmojiSelect={(emojiObject) => this.handleEmojiSelect(emojiObject)}
showPreview={false}
showSkinTones={false}
/>
</Styled.EmojiPickerWrapper>
);

View File

@ -109,21 +109,11 @@ const EmojiButton = styled(Button)`
`;
const EmojiPickerWrapper = styled.div`
.emoji-mart {
max-width: 100% !important;
}
.emoji-mart-anchor {
cursor: pointer;
}
.emoji-mart-emoji {
cursor: pointer !important;
}
.emoji-mart-category-list {
span {
cursor: pointer !important;
display: inline-block !important;
}
em-emoji-picker {
width: 100%;
max-height: 300px;
}
padding-bottom: 5px;
`;
const EmojiPicker = styled(EmojiPickerComponent)``;

View File

@ -1,26 +1,16 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import { Picker } from 'emoji-mart';
import 'emoji-mart/css/emoji-mart.css';
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
const DISABLE_EMOJIS = Meteor.settings.public.chat.disableEmojis;
const FREQUENT_SORT_ON_CLICK = Meteor.settings.public.chat.emojiPicker.frequentEmojiSortOnClick;
const propTypes = {
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
onEmojiSelect: PropTypes.func.isRequired,
style: PropTypes.shape({}),
showPreview: PropTypes.bool,
showSkinTones: PropTypes.bool,
};
const defaultProps = {
style: null,
showPreview: true,
showSkinTones: true,
};
const emojisToExclude = [
@ -31,8 +21,6 @@ const EmojiPicker = (props) => {
const {
intl,
onEmojiSelect,
showPreview,
showSkinTones,
} = props;
const i18n = {
@ -65,23 +53,19 @@ const EmojiPicker = (props) => {
return (
<Picker
emoji=""
onSelect={(emojiObject, event) => onEmojiSelect(emojiObject, event)}
enableFrequentEmojiSort={FREQUENT_SORT_ON_CLICK}
native
title=""
data={data}
onEmojiSelect={(emojiObject, event) => onEmojiSelect(emojiObject, event)}
emojiSize={24}
emojiTooltip
i18n={i18n}
showPreview={showPreview}
showSkinTones={showSkinTones}
useButton
emojisToShowFilter={(emoji) => !emojisToExclude.includes(emoji.unified)}
previewPosition="none"
skinTonePosition="none"
theme="light"
dynamicWidth
exceptEmojis={emojisToExclude}
/>
);
};
EmojiPicker.propTypes = propTypes;
EmojiPicker.defaultProps = defaultProps;
export default injectIntl(EmojiPicker);

View File

@ -1,71 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl } from 'react-intl';
import { Picker } from 'emoji-mart';
import 'emoji-mart/css/emoji-mart.css';
const propTypes = {
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
onEmojiSelect: PropTypes.func.isRequired,
};
const defaultProps = {
};
const emojisToExclude = [
// reactions
'1F605', '1F61C', '1F604', '1F609', '1F602', '1F92A',
'1F634', '1F62C', '1F633', '1F60D', '02665', '1F499',
'1F615', '1F61F', '1F928', '1F644', '1F612', '1F621',
'1F614', '1F625', '1F62D', '1F62F', '1F631', '1F630',
'1F44F', '1F44C', '1F44D', '1F44E', '1F4AA', '1F44A',
'1F64C', '1F64F', '1F440', '1F4A9', '1F921', '1F480',
];
const inlineStyle = {
margin: '.5rem 0',
alignSelf: 'center',
width: '100%',
};
const ReactionsPicker = (props) => {
const {
intl,
onEmojiSelect,
style,
} = props;
const i18n = {
notfound: intl.formatMessage({ id: 'app.emojiPicker.notFound' }),
clear: intl.formatMessage({ id: 'app.emojiPicker.clear' }),
skintext: intl.formatMessage({ id: 'app.emojiPicker.skintext' }),
categories: {
reactions: intl.formatMessage({ id: 'app.emojiPicker.categories.reactions' }),
},
categorieslabel: intl.formatMessage({ id: 'app.emojiPicker.categories.label' }),
};
return (
<Picker
onSelect={(emojiObject, event) => onEmojiSelect(emojiObject, event)}
native
emoji=""
title=""
emojiSize={30}
emojiTooltip
style={Object.assign(inlineStyle, style)}
i18n={i18n}
showPreview={false}
showSkinTones={false}
emojisToShowFilter={(emoji) => emojisToExclude.includes(emoji.unified)}
/>
);
};
ReactionsPicker.propTypes = propTypes;
ReactionsPicker.defaultProps = defaultProps;
export default injectIntl(ReactionsPicker);

View File

@ -0,0 +1,35 @@
import styled, { css } from 'styled-components';
const EmojiWrapper = styled.span`
padding-top: 0.9em;
padding-bottom: 0.1em;
${({ selected }) => !selected && css`
:hover {
border-radius:100%;
outline-color: transparent;
outline-style:solid;
box-shadow: 0 0 0 0.25em #eee;
background-color: #eee;
opacity: 0.75;
}
`}
${({ selected }) => selected && css`
em-emoji {
cursor: not-allowed;
}
`}
${({ selected, emoji }) => selected && selected !== emoji && css`
opacity: 0.75;
`}
${({ selected, emoji }) => selected && selected === emoji && css`
border-radius:100%;
outline-color: transparent;
outline-style:solid;
box-shadow: 0 0 0 0.25em #eee;
background-color: #eee;
`}
`;
export default {
EmojiWrapper,
};

View File

@ -1,27 +0,0 @@
.dropdownContent {
section[class^="emoji-mart-search"] {
display: none !important;
}
section[class^="emoji-mart emoji-mart-light"] {
display: unset;
border: unset;
}
div[class^="emoji-mart-bar"] {
display: none !important;
}
section[aria-label^="Frequently Used"] {
display: none !important;
}
div[class^="emoji-mart-category-label"] {
display: none !important;
}
div[class^="emoji-mart-scroll"] {
overflow-y: unset;
height: unset;
}
ul[class^="emoji-mart-category-list"] {
span {
cursor: pointer !important;
}
}
}

View File

@ -0,0 +1,113 @@
import React, { useRef, useState, useEffect } from 'react';
import Settings from '/imports/ui/services/settings';
import Service from './service';
const EmojiRain = ({ reactions }) => {
const containerRef = useRef(null);
const [isAnimating, setIsAnimating] = useState(false);
const EMOJI_SIZE = Meteor.settings.public.app.emojiRain.emojiSize;
const NUMBER_OF_EMOJIS = Meteor.settings.public.app.emojiRain.numberOfEmojis;
const EMOJI_RAIN_ENABLED = Meteor.settings.public.app.emojiRain.enabled;
const { animations } = Settings.application;
function createEmojiRain(emoji) {
const coord = Service.getInteractionsButtonCoordenates();
const flyingEmojis = [];
for (i = 0; i < NUMBER_OF_EMOJIS; i++) {
const initialPosition = {
x: coord.x + coord.width / 8,
y: coord.y + coord.height / 5,
};
const endPosition = {
x: Math.floor(Math.random() * 100) + coord.x - 100 / 2,
y: Math.floor(Math.random() * 300) + coord.y / 2,
};
const scale = Math.floor(Math.random() * (8 - 4 + 1)) - 40;
const sec = Math.floor(Math.random() * 1700) + 2000;
const shapeElement = document.createElement('svg');
const emojiElement = document.createElement('text');
emojiElement.setAttribute('x', '50%');
emojiElement.setAttribute('y', '50%');
emojiElement.innerHTML = emoji;
shapeElement.style.position = 'absolute';
shapeElement.style.left = `${initialPosition.x}px`;
shapeElement.style.top = `${initialPosition.y}px`;
shapeElement.style.transform = `scaleX(0.${scale}) scaleY(0.${scale})`;
shapeElement.style.transition = `${sec}ms`;
shapeElement.style.fontSize = `${EMOJI_SIZE}em`;
shapeElement.style.pointerEvents = 'none';
shapeElement.appendChild(emojiElement);
containerRef.current.appendChild(shapeElement);
flyingEmojis.push({ shapeElement, endPosition });
}
requestAnimationFrame(() => setTimeout(() => flyingEmojis.forEach((emoji) => {
const { shapeElement, endPosition } = emoji;
shapeElement.style.left = `${endPosition.x}px`;
shapeElement.style.top = `${endPosition.y}px`;
shapeElement.style.transform = 'scaleX(0) scaleY(0)';
}), 0));
setTimeout(() => {
flyingEmojis.forEach((emoji) => emoji.shapeElement.remove());
flyingEmojis.length = 0;
}, 2000);
}
const handleVisibilityChange = () => {
if (document.hidden) {
setIsAnimating(false);
} else if (EMOJI_RAIN_ENABLED && animations) {
setIsAnimating(true);
}
};
useEffect(() => {
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
useEffect(() => {
if (EMOJI_RAIN_ENABLED && animations && !isAnimating && !document.hidden) {
setIsAnimating(true);
} else if (!animations) {
setIsAnimating(false);
}
}, [EMOJI_RAIN_ENABLED, animations, isAnimating]);
useEffect(() => {
if (isAnimating) {
reactions.forEach((reaction) => {
const currentTime = new Date().getTime();
const secondsSinceCreated = (currentTime - reaction.creationDate.getTime()) / 1000;
if (secondsSinceCreated <= 1 && (reaction.reaction !== 'none')) {
createEmojiRain(reaction.reaction);
}
});
}
}, [isAnimating, reactions]);
const containerStyle = {
width: '100vw',
height: '100vh',
position: 'fixed',
top: 0,
left: 0,
overflow: 'hidden',
pointerEvents: 'none',
zIndex: 2,
};
return <div ref={containerRef} style={containerStyle} />;
};
export default EmojiRain;

View File

@ -0,0 +1,11 @@
import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import EmojiRain from './component';
import UserReaction from '/imports/api/user-reaction';
import Auth from '/imports/ui/services/auth';
const EmojiRainContainer = (props) => <EmojiRain {...props} />;
export default withTracker(() => ({
reactions: UserReaction.find({ meetingId: Auth.meetingID }).fetch(),
}))(EmojiRainContainer);

View File

@ -0,0 +1,9 @@
const getInteractionsButtonCoordenates = () => {
const el = document.getElementById('interactionsButton');
const coordenada = el.getBoundingClientRect();
return coordenada;
};
export default {
getInteractionsButtonCoordenates,
};

View File

@ -316,11 +316,11 @@ const ExternalVideoPlayerContainer: React.FC = () => {
// @ts-ignore - temporary while hybrid (meteor+GraphQl)
const isEchoTest = useReactiveVar(audioManager._isEchoTest.value) as boolean;
const currentUser = useCurrentUser((user) => ({
const { data: currentUser } = useCurrentUser((user) => ({
presenter: user.presenter,
}));
const currentMeeting = useMeeting((m) => ({
const { data: currentMeeting } = useMeeting((m) => ({
externalVideo: m.externalVideo,
}));

View File

@ -43,6 +43,7 @@ const propTypes = {
setPushLayout: PropTypes.func,
shouldShowScreenshare: PropTypes.bool,
shouldShowExternalVideo: PropTypes.bool,
enforceLayout: PropTypes.string,
};
class PushLayoutEngine extends React.Component {
@ -63,10 +64,11 @@ class PushLayoutEngine extends React.Component {
meetingPresentationIsOpen,
shouldShowScreenshare,
shouldShowExternalVideo,
enforceLayout,
} = this.props;
const userLayout = LAYOUT_TYPE[getFromUserSettings('bbb_change_layout', false)];
Settings.application.selectedLayout = userLayout || meetingLayout;
Settings.application.selectedLayout = enforceLayout || userLayout || meetingLayout;
let selectedLayout = Settings.application.selectedLayout;
if (isMobile()) {
@ -142,19 +144,22 @@ class PushLayoutEngine extends React.Component {
selectedLayout,
setMeetingLayout,
setPushLayout,
enforceLayout,
} = this.props;
const meetingLayoutDidChange = meetingLayout !== prevProps.meetingLayout;
const pushLayoutMeetingDidChange = pushLayoutMeeting !== prevProps.pushLayoutMeeting;
const enforceLayoutDidChange = enforceLayout !== prevProps.enforceLayout;
const shouldSwitchLayout = isPresenter
? meetingLayoutDidChange
: (meetingLayoutDidChange || pushLayoutMeetingDidChange) && pushLayoutMeeting;
? meetingLayoutDidChange || enforceLayoutDidChange
: ((meetingLayoutDidChange || pushLayoutMeetingDidChange) && pushLayoutMeeting) || enforceLayoutDidChange;
if (shouldSwitchLayout) {
let contextLayout = meetingLayout;
let contextLayout = enforceLayout || meetingLayout;
if (isMobile()) {
contextLayout = meetingLayout === 'custom' ? 'smart' : meetingLayout;
if (contextLayout === 'custom') {
contextLayout = 'smart';
}
}
layoutContextDispatch({
@ -170,7 +175,7 @@ class PushLayoutEngine extends React.Component {
});
}
if (pushLayoutMeetingDidChange) {
if (!enforceLayout && pushLayoutMeetingDidChange) {
updateSettings({
application: {
...Settings.application,
@ -244,6 +249,7 @@ class PushLayoutEngine extends React.Component {
|| cameraIsResizing !== prevProps.cameraIsResizing
|| cameraPosition !== prevProps.cameraPosition
|| focusedCamera !== prevProps.focusedCamera
|| enforceLayout !== prevProps.enforceLayout
|| !equalDouble(presentationVideoRate, prevProps.presentationVideoRate);
if (pushLayout !== prevProps.pushLayout) { // push layout once after presenter toggles / special case where we set pushLayout to false in all viewers

View File

@ -294,7 +294,7 @@ const RecordingIndicatorContainer: React.FC = () => {
error: meetingRecordingError,
} = useSubscription<getMeetingRecordingData>(GET_MEETING_RECORDING_DATA);
const currentUser = useCurrentUser((user: Partial<User>) => ({
const { data: currentUser } = useCurrentUser((user: Partial<User>) => ({
userId: user.userId,
isModerator: user.isModerator,
voice: user.voice
@ -305,7 +305,7 @@ const RecordingIndicatorContainer: React.FC = () => {
: null,
} as Partial<User>));
const currentMeeting = useMeeting((meeting) => ({
const { data: currentMeeting } = useMeeting((meeting) => ({
meetingId: meeting.meetingId,
notifyRecordingIsOn: meeting.notifyRecordingIsOn,
}));

View File

@ -170,7 +170,7 @@ const TalkingIndicator: React.FC<TalkingIndicatorProps> = ({
const TalkingIndicatorContainer: React.FC = (() => {
if (!enableTalkingIndicator) return () => null;
return () => {
const currentUser: Partial<User> = useCurrentUser((u: Partial<User>) => ({
const { data: currentUser } = useCurrentUser((u: Partial<User>) => ({
userId: u?.userId,
isModerator: u?.isModerator,
}));

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