Merge with fill shape fix
This commit is contained in:
commit
4328222c82
@ -85,7 +85,9 @@ object Boot extends App with SystemConfiguration {
|
||||
val redisMessageHandlerActor = system.actorOf(ReceivedJsonMsgHandlerActor.props(bbbMsgBus, incomingJsonMessageBus))
|
||||
incomingJsonMessageBus.subscribe(redisMessageHandlerActor, toAkkaAppsJsonChannel)
|
||||
|
||||
val channelsToSubscribe = Seq(toAkkaAppsRedisChannel, fromVoiceConfRedisChannel)
|
||||
val channelsToSubscribe = Seq(
|
||||
toAkkaAppsRedisChannel, fromVoiceConfRedisChannel, fromSfuRedisChannel,
|
||||
)
|
||||
|
||||
val redisSubscriberActor = system.actorOf(
|
||||
AppsRedisSubscriberActor.props(
|
||||
|
@ -1,9 +1,18 @@
|
||||
package org.bigbluebutton
|
||||
|
||||
import org.bigbluebutton.common2.msgs.{ BbbCommonEnvCoreMsg, BbbCoreEnvelope, BbbCoreHeaderWithMeetingId, MessageTypes, MuteUserInVoiceConfSysMsg, MuteUserInVoiceConfSysMsgBody, Routing }
|
||||
import org.bigbluebutton.core.models.{ Roles, Users2x, VoiceUserState, VoiceUsers }
|
||||
import org.bigbluebutton.core.running.{ LiveMeeting, OutMsgRouter }
|
||||
import org.bigbluebutton.core2.{ MeetingStatus2x }
|
||||
import org.bigbluebutton.core2.message.senders.MsgBuilder
|
||||
import org.bigbluebutton.core.models.{
|
||||
Roles,
|
||||
Users2x,
|
||||
UserState,
|
||||
VoiceUserState,
|
||||
VoiceUsers,
|
||||
Webcams,
|
||||
WebcamStream
|
||||
}
|
||||
|
||||
object LockSettingsUtil {
|
||||
|
||||
@ -65,4 +74,98 @@ object LockSettingsUtil {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private def requestBroadcastedCamEjection(
|
||||
meetingId: String, userId: String, streamId: String, outGW: OutMsgRouter
|
||||
): Unit = {
|
||||
val event = MsgBuilder.buildCamBroadcastStopSysMsg(
|
||||
meetingId, userId, streamId
|
||||
)
|
||||
outGW.send(event)
|
||||
}
|
||||
|
||||
def isCameraBroadcastLocked(user: UserState, liveMeeting: LiveMeeting): Boolean = {
|
||||
val permissions = MeetingStatus2x.getPermissions(liveMeeting.status)
|
||||
|
||||
user.role == Roles.VIEWER_ROLE && user.locked && permissions.disableCam
|
||||
}
|
||||
|
||||
def isCameraSubscribeLocked(
|
||||
user: UserState, stream: WebcamStream, liveMeeting: LiveMeeting
|
||||
): Boolean = {
|
||||
var locked = false
|
||||
val publisherUserId: String = stream.stream.userId
|
||||
|
||||
for {
|
||||
publisher <- Users2x.findWithIntId(liveMeeting.users2x, publisherUserId)
|
||||
} yield {
|
||||
if (MeetingStatus2x.webcamsOnlyForModeratorEnabled(liveMeeting.status)
|
||||
&& publisher.role != Roles.MODERATOR_ROLE
|
||||
&& user.role == Roles.VIEWER_ROLE
|
||||
&& user.locked) {
|
||||
locked = true
|
||||
}
|
||||
}
|
||||
|
||||
locked
|
||||
}
|
||||
|
||||
private def requestCamSubscriptionEjection(
|
||||
meetingId: String, userId: String, streamId: String, outGW: OutMsgRouter
|
||||
): Unit = {
|
||||
val event = MsgBuilder.buildCamStreamUnsubscribeSysMsg(
|
||||
meetingId, userId, streamId
|
||||
)
|
||||
outGW.send(event)
|
||||
}
|
||||
|
||||
private def enforceSeeOtherViewersForUser(
|
||||
user: UserState, liveMeeting: LiveMeeting, outGW: OutMsgRouter
|
||||
): Unit = {
|
||||
if (MeetingStatus2x.webcamsOnlyForModeratorEnabled(liveMeeting.status)) {
|
||||
Webcams.findAll(liveMeeting.webcams) foreach { webcam =>
|
||||
val streamId = webcam.stream.id
|
||||
val userId = user.intId
|
||||
|
||||
if (isCameraSubscribeLocked(user, webcam, liveMeeting)
|
||||
&& Webcams.isViewingWebcam(liveMeeting.webcams, user.intId, webcam.stream.id)) {
|
||||
requestCamSubscriptionEjection(
|
||||
liveMeeting.props.meetingProp.intId,
|
||||
userId,
|
||||
streamId,
|
||||
outGW
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private def enforceDisableCamForUser(
|
||||
user: UserState, liveMeeting: LiveMeeting, outGW: OutMsgRouter
|
||||
): Unit = {
|
||||
if (isCameraBroadcastLocked(user, liveMeeting)) {
|
||||
val broadcastedWebcams = Webcams.findWebcamsForUser(liveMeeting.webcams, user.intId)
|
||||
broadcastedWebcams foreach { webcam =>
|
||||
requestBroadcastedCamEjection(
|
||||
liveMeeting.props.meetingProp.intId,
|
||||
user.intId,
|
||||
webcam.stream.id,
|
||||
outGW
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def enforceCamLockSettingsForUser(
|
||||
user: UserState, liveMeeting: LiveMeeting, outGW: OutMsgRouter
|
||||
): Unit = {
|
||||
enforceDisableCamForUser(user, liveMeeting, outGW)
|
||||
enforceSeeOtherViewersForUser(user, liveMeeting, outGW)
|
||||
}
|
||||
|
||||
def enforceCamLockSettingsForAllUsers(liveMeeting: LiveMeeting, outGW: OutMsgRouter): Unit = {
|
||||
Users2x.findLockedViewers(liveMeeting.users2x).foreach { user =>
|
||||
enforceCamLockSettingsForUser(user, liveMeeting, outGW)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -50,6 +50,8 @@ trait SystemConfiguration {
|
||||
lazy val endMeetingWhenNoMoreAuthedUsers = Try(config.getBoolean("apps.endMeetingWhenNoMoreAuthedUsers")).getOrElse(false)
|
||||
lazy val endMeetingWhenNoMoreAuthedUsersAfterMinutes = Try(config.getInt("apps.endMeetingWhenNoMoreAuthedUsersAfterMinutes")).getOrElse(2)
|
||||
|
||||
lazy val reduceDuplicatedPick = Try(config.getBoolean("apps.reduceDuplicatedPick")).getOrElse(false)
|
||||
|
||||
// Redis server configuration
|
||||
lazy val redisHost = Try(config.getString("redis.host")).getOrElse("127.0.0.1")
|
||||
lazy val redisPort = Try(config.getInt("redis.port")).getOrElse(6379)
|
||||
@ -63,6 +65,9 @@ trait SystemConfiguration {
|
||||
lazy val toVoiceConfRedisChannel = Try(config.getString("redis.toVoiceConfRedisChannel")).getOrElse("to-voice-conf-redis-channel")
|
||||
lazy val fromVoiceConfRedisChannel = Try(config.getString("redis.fromVoiceConfRedisChannel")).getOrElse("from-voice-conf-redis-channel")
|
||||
|
||||
lazy val toSfuRedisChannel = Try(config.getString("redis.toSfuRedisChannel")).getOrElse("to-sfu-redis-channel")
|
||||
lazy val fromSfuRedisChannel = Try(config.getString("redis.fromSfuRedisChannel")).getOrElse("from-sfu-redis-channel")
|
||||
|
||||
lazy val fromAkkaAppsWbRedisChannel = Try(config.getString("redis.fromAkkaAppsWbRedisChannel")).getOrElse("from-akka-apps-wb-redis-channel")
|
||||
lazy val fromAkkaAppsChatRedisChannel = Try(config.getString("redis.fromAkkaAppsChatRedisChannel")).getOrElse("from-akka-apps-chat-redis-channel")
|
||||
lazy val fromAkkaAppsPresRedisChannel = Try(config.getString("redis.fromAkkaAppsPresRedisChannel")).getOrElse("from-akka-apps-pres-redis-channel")
|
||||
|
@ -44,6 +44,10 @@ trait ChangeLockSettingsInMeetingCmdMsgHdlr extends RightsManagementTrait {
|
||||
LockSettingsUtil.enforceLockSettingsForAllVoiceUsers(liveMeeting, outGW)
|
||||
}
|
||||
|
||||
if (!oldPermissions.disableCam && settings.disableCam) {
|
||||
LockSettingsUtil.enforceCamLockSettingsForAllUsers(liveMeeting, outGW)
|
||||
}
|
||||
|
||||
val routing = Routing.addMsgToClientRouting(
|
||||
MessageTypes.BROADCAST_TO_MEETING,
|
||||
props.meetingProp.intId,
|
||||
|
@ -12,25 +12,37 @@ trait ChangeUserEmojiCmdMsgHdlr extends RightsManagementTrait {
|
||||
val outGW: OutMsgRouter
|
||||
|
||||
def handleChangeUserEmojiCmdMsg(msg: ChangeUserEmojiCmdMsg) {
|
||||
// Usually only moderators are allowed to change someone else's emoji status
|
||||
// Exceptional case: Viewers who are presenter are allowed to lower someone else's raised hand:
|
||||
val isViewerProhibitedFromLoweringOthersHand =
|
||||
!(Users2x.findWithIntId(liveMeeting.users2x, msg.body.userId).get.emoji.equals("raiseHand") &&
|
||||
msg.body.emoji.equals("none")) ||
|
||||
permissionFailed(PermissionCheck.VIEWER_LEVEL, PermissionCheck.PRESENTER_LEVEL, liveMeeting.users2x, msg.header.userId)
|
||||
val isUserSettingOwnEmoji = (msg.header.userId == msg.body.userId)
|
||||
|
||||
if (msg.header.userId != msg.body.userId &&
|
||||
permissionFailed(PermissionCheck.MOD_LEVEL, PermissionCheck.VIEWER_LEVEL, liveMeeting.users2x, msg.header.userId) &&
|
||||
isViewerProhibitedFromLoweringOthersHand) {
|
||||
val meetingId = liveMeeting.props.meetingProp.intId
|
||||
val reason = "No permission to clear change user emoji status."
|
||||
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, outGW, liveMeeting)
|
||||
} else {
|
||||
val isUserModerator = !permissionFailed(
|
||||
PermissionCheck.MOD_LEVEL,
|
||||
PermissionCheck.VIEWER_LEVEL,
|
||||
liveMeeting.users2x,
|
||||
msg.header.userId
|
||||
)
|
||||
|
||||
val isUserPresenter = !permissionFailed(
|
||||
PermissionCheck.VIEWER_LEVEL,
|
||||
PermissionCheck.PRESENTER_LEVEL,
|
||||
liveMeeting.users2x,
|
||||
msg.header.userId
|
||||
)
|
||||
|
||||
val initialEmojiState = Users2x.findWithIntId(liveMeeting.users2x, msg.body.userId).get.emoji
|
||||
val nextEmojiState = msg.body.emoji
|
||||
|
||||
if (isUserSettingOwnEmoji
|
||||
|| isUserModerator && nextEmojiState.equals("none")
|
||||
|| isUserPresenter && initialEmojiState.equals("raiseHand") && nextEmojiState.equals("none")) {
|
||||
for {
|
||||
uvo <- Users2x.setEmojiStatus(liveMeeting.users2x, msg.body.userId, msg.body.emoji)
|
||||
} yield {
|
||||
sendUserEmojiChangedEvtMsg(outGW, liveMeeting.props.meetingProp.intId, msg.body.userId, msg.body.emoji)
|
||||
}
|
||||
} else {
|
||||
val meetingId = liveMeeting.props.meetingProp.intId
|
||||
val reason = "No permission to clear change user emoji status."
|
||||
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, outGW, liveMeeting)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,9 +1,10 @@
|
||||
package org.bigbluebutton.core.apps.users
|
||||
|
||||
import org.bigbluebutton.common2.msgs._
|
||||
import org.bigbluebutton.core.models.{ RegisteredUsers, Roles, Users2x }
|
||||
import org.bigbluebutton.core.models.{ RegisteredUsers, Roles, Users2x, UserState }
|
||||
import org.bigbluebutton.core.running.{ LiveMeeting, OutMsgRouter }
|
||||
import org.bigbluebutton.core.apps.{ PermissionCheck, RightsManagementTrait }
|
||||
import org.bigbluebutton.LockSettingsUtil
|
||||
|
||||
trait ChangeUserRoleCmdMsgHdlr extends RightsManagementTrait {
|
||||
this: UsersApp =>
|
||||
@ -36,9 +37,14 @@ trait ChangeUserRoleCmdMsgHdlr extends RightsManagementTrait {
|
||||
msg.body.changedBy, Roles.MODERATOR_ROLE)
|
||||
outGW.send(event)
|
||||
} else if (msg.body.role == Roles.VIEWER_ROLE) {
|
||||
Users2x.changeRole(liveMeeting.users2x, uvo, msg.body.role)
|
||||
val newUvo: UserState = Users2x.changeRole(liveMeeting.users2x, uvo, msg.body.role)
|
||||
val event = buildUserRoleChangedEvtMsg(liveMeeting.props.meetingProp.intId, msg.body.userId,
|
||||
msg.body.changedBy, Roles.VIEWER_ROLE)
|
||||
|
||||
if (newUvo.locked) {
|
||||
LockSettingsUtil.enforceCamLockSettingsForAllUsers(liveMeeting, outGW)
|
||||
}
|
||||
|
||||
outGW.send(event)
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import org.bigbluebutton.common2.msgs._
|
||||
import org.bigbluebutton.core.running.{ MeetingActor, OutMsgRouter }
|
||||
import org.bigbluebutton.core.models.Users2x
|
||||
import org.bigbluebutton.core2.message.senders.MsgBuilder
|
||||
import org.bigbluebutton.LockSettingsUtil
|
||||
|
||||
trait GetCamBroadcastPermissionReqMsgHdlr {
|
||||
this: MeetingActor =>
|
||||
@ -11,13 +12,17 @@ trait GetCamBroadcastPermissionReqMsgHdlr {
|
||||
val outGW: OutMsgRouter
|
||||
|
||||
def handleGetCamBroadcastPermissionReqMsg(msg: GetCamBroadcastPermissionReqMsg) {
|
||||
var camBroadcastLocked: Boolean = false
|
||||
var allowed = false
|
||||
|
||||
for {
|
||||
user <- Users2x.findWithIntId(liveMeeting.users2x, msg.body.userId)
|
||||
} yield {
|
||||
camBroadcastLocked = LockSettingsUtil.isCameraBroadcastLocked(user, liveMeeting)
|
||||
|
||||
if (!user.userLeftFlag.left
|
||||
&& liveMeeting.props.meetingProp.intId == msg.body.meetingId) {
|
||||
&& liveMeeting.props.meetingProp.intId == msg.body.meetingId
|
||||
&& (applyPermissionCheck && !camBroadcastLocked)) {
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import org.bigbluebutton.common2.msgs._
|
||||
import org.bigbluebutton.core.running.{ MeetingActor, OutMsgRouter }
|
||||
import org.bigbluebutton.core.models.{ Users2x, Webcams }
|
||||
import org.bigbluebutton.core2.message.senders.MsgBuilder
|
||||
import org.bigbluebutton.LockSettingsUtil
|
||||
|
||||
trait GetCamSubscribePermissionReqMsgHdlr {
|
||||
this: MeetingActor =>
|
||||
@ -15,17 +16,14 @@ trait GetCamSubscribePermissionReqMsgHdlr {
|
||||
|
||||
for {
|
||||
user <- Users2x.findWithIntId(liveMeeting.users2x, msg.body.userId)
|
||||
stream <- Webcams.findWithStreamId(liveMeeting.webcams, msg.body.streamId)
|
||||
} yield {
|
||||
val camSubscribeLocked = LockSettingsUtil.isCameraSubscribeLocked(user, stream, liveMeeting)
|
||||
|
||||
if (!user.userLeftFlag.left
|
||||
&& liveMeeting.props.meetingProp.intId == msg.body.meetingId) {
|
||||
Webcams.findWithStreamId(liveMeeting.webcams, msg.body.streamId) match {
|
||||
case Some(stream) => {
|
||||
allowed = true
|
||||
}
|
||||
case None => {
|
||||
allowed = false
|
||||
}
|
||||
}
|
||||
&& liveMeeting.props.meetingProp.intId == msg.body.meetingId
|
||||
&& (applyPermissionCheck && !camSubscribeLocked)) {
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -35,6 +35,8 @@ trait LockUserInMeetingCmdMsgHdlr extends RightsManagementTrait {
|
||||
VoiceUsers.findWithIntId(liveMeeting.voiceUsers, uvo.intId).foreach { vu =>
|
||||
LockSettingsUtil.enforceLockSettingsForVoiceUser(vu, liveMeeting, outGW)
|
||||
}
|
||||
|
||||
LockSettingsUtil.enforceCamLockSettingsForUser(uvo, liveMeeting, outGW)
|
||||
}
|
||||
|
||||
log.info("Lock user. meetingId=" + props.meetingProp.intId + " userId=" + uvo.intId + " locked=" + uvo.locked)
|
||||
|
@ -13,11 +13,12 @@ trait RegisterUserReqMsgHdlr {
|
||||
|
||||
def handleRegisterUserReqMsg(msg: RegisterUserReqMsg): Unit = {
|
||||
|
||||
def buildUserRegisteredRespMsg(meetingId: String, userId: String, name: String, role: String, registeredOn: Long): BbbCommonEnvCoreMsg = {
|
||||
def buildUserRegisteredRespMsg(meetingId: String, userId: String, name: String,
|
||||
role: String, excludeFromDashboard: Boolean, registeredOn: Long): BbbCommonEnvCoreMsg = {
|
||||
val routing = collection.immutable.HashMap("sender" -> "bbb-apps-akka")
|
||||
val envelope = BbbCoreEnvelope(UserRegisteredRespMsg.NAME, routing)
|
||||
val header = BbbCoreHeaderWithMeetingId(UserRegisteredRespMsg.NAME, meetingId)
|
||||
val body = UserRegisteredRespMsgBody(meetingId, userId, name, role, registeredOn)
|
||||
val body = UserRegisteredRespMsgBody(meetingId, userId, name, role, excludeFromDashboard, registeredOn)
|
||||
val event = UserRegisteredRespMsg(header, body)
|
||||
BbbCommonEnvCoreMsg(envelope, event)
|
||||
}
|
||||
@ -26,14 +27,15 @@ trait RegisterUserReqMsgHdlr {
|
||||
|
||||
val regUser = RegisteredUsers.create(msg.body.intUserId, msg.body.extUserId,
|
||||
msg.body.name, msg.body.role, msg.body.authToken,
|
||||
msg.body.avatarURL, msg.body.guest, msg.body.authed, guestStatus, false)
|
||||
msg.body.avatarURL, msg.body.guest, msg.body.authed, guestStatus, msg.body.excludeFromDashboard, false)
|
||||
|
||||
RegisteredUsers.add(liveMeeting.registeredUsers, regUser)
|
||||
|
||||
log.info("Register user success. meetingId=" + liveMeeting.props.meetingProp.intId
|
||||
+ " userId=" + msg.body.extUserId + " user=" + regUser)
|
||||
|
||||
val event = buildUserRegisteredRespMsg(liveMeeting.props.meetingProp.intId, regUser.id, regUser.name, regUser.role, regUser.registeredOn)
|
||||
val event = buildUserRegisteredRespMsg(liveMeeting.props.meetingProp.intId, regUser.id, regUser.name,
|
||||
regUser.role, regUser.excludeFromDashboard, regUser.registeredOn)
|
||||
outGW.send(event)
|
||||
|
||||
def notifyModeratorsOfGuestWaiting(guests: Vector[GuestWaiting], users: Users2x, meetingId: String): Unit = {
|
||||
|
@ -5,6 +5,7 @@ import org.bigbluebutton.core.running.{ LiveMeeting, OutMsgRouter }
|
||||
import org.bigbluebutton.core.models.{ UserState, Users2x }
|
||||
import org.bigbluebutton.core.apps.{ PermissionCheck, RightsManagementTrait }
|
||||
import org.bigbluebutton.core2.MeetingStatus2x
|
||||
import org.bigbluebutton.SystemConfiguration
|
||||
import scala.util.Random
|
||||
|
||||
trait SelectRandomViewerReqMsgHdlr extends RightsManagementTrait {
|
||||
@ -15,7 +16,7 @@ trait SelectRandomViewerReqMsgHdlr extends RightsManagementTrait {
|
||||
def handleSelectRandomViewerReqMsg(msg: SelectRandomViewerReqMsg): Unit = {
|
||||
log.debug("Received SelectRandomViewerReqMsg {}", SelectRandomViewerReqMsg)
|
||||
|
||||
def broadcastEvent(msg: SelectRandomViewerReqMsg, users: Vector[String], choice: Integer): Unit = {
|
||||
def broadcastEvent(msg: SelectRandomViewerReqMsg, users: Vector[String], choice: String): Unit = {
|
||||
val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, liveMeeting.props.meetingProp.intId, msg.header.userId)
|
||||
val envelope = BbbCoreEnvelope(SelectRandomViewerRespMsg.NAME, routing)
|
||||
val header = BbbClientMsgHeader(SelectRandomViewerRespMsg.NAME, liveMeeting.props.meetingProp.intId, msg.header.userId)
|
||||
@ -31,11 +32,27 @@ trait SelectRandomViewerReqMsgHdlr extends RightsManagementTrait {
|
||||
val reason = "No permission to select random user."
|
||||
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, outGW, liveMeeting)
|
||||
} else {
|
||||
val users = Users2x.findNotPresentersNorModerators(liveMeeting.users2x)
|
||||
val users = Users2x.getRandomlyPickableUsers(liveMeeting.users2x, false)
|
||||
|
||||
val usersPicked = Users2x.getRandomlyPickableUsers(liveMeeting.users2x, reduceDuplicatedPick)
|
||||
|
||||
val randNum = new scala.util.Random
|
||||
val pickedUser = if (usersPicked.size == 0) "" else usersPicked(randNum.nextInt(usersPicked.size)).intId
|
||||
|
||||
if (reduceDuplicatedPick) {
|
||||
if (usersPicked.size == 1) {
|
||||
// Initialise the exemption
|
||||
val usersToUnexempt = Users2x.findAll(liveMeeting.users2x)
|
||||
usersToUnexempt foreach { u =>
|
||||
Users2x.setUserExempted(liveMeeting.users2x, u.intId, false)
|
||||
}
|
||||
} else if (usersPicked.size > 1) {
|
||||
Users2x.setUserExempted(liveMeeting.users2x, pickedUser, true)
|
||||
}
|
||||
}
|
||||
|
||||
val userIds = users.map { case (v) => v.intId }
|
||||
broadcastEvent(msg, userIds, if (users.size == 0) -1 else randNum.nextInt(users.size))
|
||||
broadcastEvent(msg, userIds, pickedUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import org.bigbluebutton.common2.msgs._
|
||||
import org.bigbluebutton.core.running.{ LiveMeeting, OutMsgRouter }
|
||||
import org.bigbluebutton.core2.MeetingStatus2x
|
||||
import org.bigbluebutton.core.apps.{ PermissionCheck, RightsManagementTrait }
|
||||
import org.bigbluebutton.LockSettingsUtil
|
||||
|
||||
trait UpdateWebcamsOnlyForModeratorCmdMsgHdlr extends RightsManagementTrait {
|
||||
this: UsersApp =>
|
||||
@ -18,14 +19,15 @@ trait UpdateWebcamsOnlyForModeratorCmdMsgHdlr extends RightsManagementTrait {
|
||||
val reason = "No permission to change lock settings"
|
||||
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, outGW, liveMeeting)
|
||||
} else {
|
||||
log.info("Change webcams only for moderator status. meetingId=" + liveMeeting.props.meetingProp.intId + " webcamsOnlyForModeratorrecording=" + msg.body.webcamsOnlyForModerator)
|
||||
if (MeetingStatus2x.webcamsOnlyForModeratorEnabled(liveMeeting.status) != msg.body.webcamsOnlyForModerator) {
|
||||
log.info("Change webcams only for moderator status. meetingId=" + liveMeeting.props.meetingProp.intId + " webcamsOnlyForModeratorrecording=" + msg.body.webcamsOnlyForModerator)
|
||||
MeetingStatus2x.setWebcamsOnlyForModerator(liveMeeting.status, msg.body.webcamsOnlyForModerator)
|
||||
|
||||
LockSettingsUtil.enforceCamLockSettingsForAllUsers(liveMeeting, outGW)
|
||||
val event = buildWebcamsOnlyForModeratorChangedEvtMsg(liveMeeting.props.meetingProp.intId, msg.body.setBy, msg.body.webcamsOnlyForModerator)
|
||||
outGW.send(event)
|
||||
}
|
||||
}
|
||||
|
||||
def buildWebcamsOnlyForModeratorChangedEvtMsg(meetingId: String, userId: String, webcamsOnlyForModerator: Boolean): BbbCommonEnvCoreMsg = {
|
||||
val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, meetingId, userId)
|
||||
val envelope = BbbCoreEnvelope(WebcamsOnlyForModeratorChangedEvtMsg.NAME, routing)
|
||||
|
@ -3,6 +3,9 @@ package org.bigbluebutton.core.apps.users
|
||||
import org.bigbluebutton.common2.msgs._
|
||||
import org.bigbluebutton.core.models.{ MediaStream, WebcamStream, Webcams }
|
||||
import org.bigbluebutton.core.running.{ MeetingActor, OutMsgRouter }
|
||||
import org.bigbluebutton.core.models.Users2x
|
||||
import org.bigbluebutton.core.apps.PermissionCheck
|
||||
import org.bigbluebutton.LockSettingsUtil
|
||||
|
||||
trait UserBroadcastCamStartMsgHdlr {
|
||||
this: MeetingActor =>
|
||||
@ -10,6 +13,7 @@ trait UserBroadcastCamStartMsgHdlr {
|
||||
val outGW: OutMsgRouter
|
||||
|
||||
def handleUserBroadcastCamStartMsg(msg: UserBroadcastCamStartMsg): Unit = {
|
||||
var allowed: Boolean = false
|
||||
|
||||
def broadcastEvent(msg: UserBroadcastCamStartMsg): Unit = {
|
||||
val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, props.meetingProp.intId, msg.header.userId)
|
||||
@ -22,13 +26,32 @@ trait UserBroadcastCamStartMsgHdlr {
|
||||
outGW.send(msgEvent)
|
||||
}
|
||||
|
||||
val stream = new MediaStream(msg.body.stream, msg.body.stream, msg.header.userId, Map.empty, Set.empty)
|
||||
val webcamStream = new WebcamStream(msg.body.stream, stream)
|
||||
|
||||
for {
|
||||
uvo <- Webcams.addWebcamBroadcastStream(liveMeeting.webcams, webcamStream)
|
||||
user <- Users2x.findWithIntId(liveMeeting.users2x, msg.header.userId)
|
||||
} yield {
|
||||
broadcastEvent(msg)
|
||||
val meetingId = props.meetingProp.intId
|
||||
val camBroadcastLocked = LockSettingsUtil.isCameraBroadcastLocked(user, liveMeeting)
|
||||
|
||||
if (!user.userLeftFlag.left
|
||||
&& meetingId == msg.header.meetingId
|
||||
&& msg.body.stream.startsWith(msg.header.userId)
|
||||
&& (applyPermissionCheck && !camBroadcastLocked)) {
|
||||
allowed = true
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
val reason = "No permission to share camera."
|
||||
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, outGW, liveMeeting)
|
||||
} else {
|
||||
val stream = new MediaStream(msg.body.stream, msg.body.stream, msg.header.userId, Map.empty, Set.empty)
|
||||
val webcamStream = new WebcamStream(msg.body.stream, stream)
|
||||
|
||||
for {
|
||||
uvo <- Webcams.addWebcamBroadcastStream(liveMeeting.webcams, webcamStream)
|
||||
} yield {
|
||||
broadcastEvent(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,8 @@ package org.bigbluebutton.core.apps.users
|
||||
import org.bigbluebutton.common2.msgs._
|
||||
import org.bigbluebutton.core.models.Webcams
|
||||
import org.bigbluebutton.core.running.{ MeetingActor, OutMsgRouter }
|
||||
import org.bigbluebutton.core.apps.PermissionCheck
|
||||
import org.bigbluebutton.core2.message.senders.MsgBuilder
|
||||
|
||||
trait UserBroadcastCamStopMsgHdlr {
|
||||
this: MeetingActor =>
|
||||
@ -11,21 +13,24 @@ trait UserBroadcastCamStopMsgHdlr {
|
||||
|
||||
def handleUserBroadcastCamStopMsg(msg: UserBroadcastCamStopMsg): Unit = {
|
||||
for {
|
||||
_ <- Webcams.removeWebcamBroadcastStream(liveMeeting.webcams, msg.body.stream)
|
||||
publisherStream <- Webcams.findWithStreamId(liveMeeting.webcams, msg.body.stream)
|
||||
} yield {
|
||||
broadcastUserBroadcastCamStoppedEvtMsg(msg.body.stream, msg.header.userId)
|
||||
|
||||
if (publisherStream.stream.userId != msg.header.userId
|
||||
|| !msg.body.stream.startsWith(msg.header.userId)) {
|
||||
val reason = "User does not own camera stream"
|
||||
PermissionCheck.ejectUserForFailedPermission(
|
||||
props.meetingProp.intId, msg.header.userId, reason, outGW, liveMeeting
|
||||
)
|
||||
} else {
|
||||
for {
|
||||
_ <- Webcams.removeWebcamBroadcastStream(liveMeeting.webcams, msg.body.stream)
|
||||
} yield {
|
||||
val event = MsgBuilder.buildUserBroadcastCamStoppedEvtMsg(
|
||||
props.meetingProp.intId, msg.header.userId, msg.body.stream
|
||||
)
|
||||
outGW.send(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def broadcastUserBroadcastCamStoppedEvtMsg(streamId: String, userId: String): Unit = {
|
||||
val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, props.meetingProp.intId, userId)
|
||||
val envelope = BbbCoreEnvelope(UserBroadcastCamStoppedEvtMsg.NAME, routing)
|
||||
val header = BbbClientMsgHeader(UserBroadcastCamStoppedEvtMsg.NAME, props.meetingProp.intId, userId)
|
||||
|
||||
val body = UserBroadcastCamStoppedEvtMsgBody(userId, streamId)
|
||||
val event = UserBroadcastCamStoppedEvtMsg(header, body)
|
||||
val msgEvent = BbbCommonEnvCoreMsg(envelope, event)
|
||||
outGW.send(msgEvent)
|
||||
}
|
||||
}
|
||||
|
@ -103,18 +103,30 @@ object UsersApp {
|
||||
outGW.send(ejectFromVoiceEvent)
|
||||
}
|
||||
|
||||
def sendEjectUserFromSfuSysMsg(
|
||||
outGW: OutMsgRouter,
|
||||
meetingId: String,
|
||||
userId: String
|
||||
): Unit = {
|
||||
val event = MsgBuilder.buildEjectUserFromSfuSysMsg(
|
||||
meetingId,
|
||||
userId,
|
||||
)
|
||||
outGW.send(event)
|
||||
}
|
||||
|
||||
def ejectUserFromMeeting(outGW: OutMsgRouter, liveMeeting: LiveMeeting,
|
||||
userId: String, ejectedBy: String, reason: String,
|
||||
reasonCode: String, ban: Boolean): Unit = {
|
||||
|
||||
val meetingId = liveMeeting.props.meetingProp.intId
|
||||
|
||||
RegisteredUsers.eject(userId, liveMeeting.registeredUsers, ban)
|
||||
for {
|
||||
user <- Users2x.ejectFromMeeting(liveMeeting.users2x, userId)
|
||||
reguser <- RegisteredUsers.eject(userId, liveMeeting.registeredUsers, ban)
|
||||
} yield {
|
||||
sendUserEjectedMessageToClient(outGW, meetingId, userId, ejectedBy, reason, reasonCode)
|
||||
sendUserLeftMeetingToAllClients(outGW, meetingId, userId)
|
||||
sendEjectUserFromSfuSysMsg(outGW, meetingId, userId)
|
||||
if (user.presenter) {
|
||||
// println(s"ejectUserFromMeeting will cause a automaticallyAssignPresenter for user=${user}")
|
||||
automaticallyAssignPresenter(outGW, liveMeeting)
|
||||
|
@ -66,22 +66,20 @@ trait SendWhiteboardAnnotationPubMsgHdlr extends RightsManagementTrait {
|
||||
}
|
||||
|
||||
def excludedWbMsg(annotation: AnnotationVO): Boolean = {
|
||||
WhiteboardKeyUtil.PENCIL_TYPE == annotation.annotationType &&
|
||||
(WhiteboardKeyUtil.DRAW_END_STATUS == annotation.status ||
|
||||
WhiteboardKeyUtil.DRAW_UPDATE_STATUS == annotation.status)
|
||||
WhiteboardKeyUtil.DRAW_END_STATUS == annotation.status ||
|
||||
WhiteboardKeyUtil.DRAW_UPDATE_STATUS == annotation.status
|
||||
}
|
||||
|
||||
if (!excludedWbMsg(msg.body.annotation) && filterWhiteboardMessage(msg.body.annotation.wbId, msg.header.userId, liveMeeting) && permissionFailed(
|
||||
val isMessageOfAllowedType = excludedWbMsg(msg.body.annotation)
|
||||
|
||||
val isUserOneOfPermited = !filterWhiteboardMessage(msg.body.annotation.wbId, msg.header.userId, liveMeeting)
|
||||
|
||||
val isUserAmongPresenters = !permissionFailed(
|
||||
PermissionCheck.GUEST_LEVEL,
|
||||
PermissionCheck.PRESENTER_LEVEL, liveMeeting.users2x, msg.header.userId
|
||||
)) {
|
||||
//val meetingId = liveMeeting.props.meetingProp.intId
|
||||
//val reason = "No permission to send a whiteboard annotation."
|
||||
)
|
||||
|
||||
// Just drop messages as these might be delayed messages from multi-user whiteboard. Don't want to
|
||||
// eject user unnecessarily when switching from multi-user to single user. (ralam feb 7, 2018)
|
||||
// PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, bus.outGW, liveMeeting)
|
||||
} else {
|
||||
if (isMessageOfAllowedType && (isUserOneOfPermited || isUserAmongPresenters)) {
|
||||
//val dirtyAnn = testInsertSomeNoneValues(msg.body.annotation)
|
||||
//println(">>>>>>>>>>>>> Printing Dirty annotation >>>>>>>>>>>>>>")
|
||||
//val dirtyAnn2 = printAnnotationInfo(dirtyAnn)
|
||||
@ -97,6 +95,13 @@ trait SendWhiteboardAnnotationPubMsgHdlr extends RightsManagementTrait {
|
||||
//println("============= Printed Sanitized annotation ============")
|
||||
val annotation = sendWhiteboardAnnotation(sanitizedShape, msg.body.drawEndOnly, liveMeeting)
|
||||
broadcastEvent(msg, annotation)
|
||||
} else {
|
||||
//val meetingId = liveMeeting.props.meetingProp.intId
|
||||
//val reason = "No permission to send a whiteboard annotation."
|
||||
|
||||
// Just drop messages as these might be delayed messages from multi-user whiteboard. Don't want to
|
||||
// eject user unnecessarily when switching from multi-user to single user. (ralam feb 7, 2018)
|
||||
// PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, bus.outGW, liveMeeting)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ import org.bigbluebutton.core.domain.BreakoutRoom2x
|
||||
object RegisteredUsers {
|
||||
def create(userId: String, extId: String, name: String, roles: String,
|
||||
token: String, avatar: String, guest: Boolean, authenticated: Boolean,
|
||||
guestStatus: String, loggedOut: Boolean): RegisteredUser = {
|
||||
guestStatus: String, excludeFromDashboard: Boolean, loggedOut: Boolean): RegisteredUser = {
|
||||
new RegisteredUser(
|
||||
userId,
|
||||
extId,
|
||||
@ -17,12 +17,13 @@ object RegisteredUsers {
|
||||
guest,
|
||||
authenticated,
|
||||
guestStatus,
|
||||
excludeFromDashboard,
|
||||
System.currentTimeMillis(),
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
loggedOut
|
||||
loggedOut,
|
||||
)
|
||||
}
|
||||
|
||||
@ -192,6 +193,7 @@ case class RegisteredUser(
|
||||
guest: Boolean,
|
||||
authed: Boolean,
|
||||
guestStatus: String,
|
||||
excludeFromDashboard: Boolean,
|
||||
registeredOn: Long,
|
||||
lastAuthTokenValidatedOn: Long,
|
||||
joined: Boolean,
|
||||
|
@ -50,7 +50,7 @@ object Users2x {
|
||||
def findAllExpiredUserLeftFlags(users: Users2x, meetingExpireWhenLastUserLeftInMs: Long): Vector[UserState] = {
|
||||
if (meetingExpireWhenLastUserLeftInMs > 0) {
|
||||
users.toVector filter (u => u.userLeftFlag.left && u.userLeftFlag.leftOn != 0 &&
|
||||
System.currentTimeMillis() - u.userLeftFlag.leftOn > 30000)
|
||||
System.currentTimeMillis() - u.userLeftFlag.leftOn > 10000)
|
||||
} else {
|
||||
// When meetingExpireWhenLastUserLeftInMs is set zero we need to
|
||||
// remove user right away to end the meeting as soon as possible.
|
||||
@ -71,14 +71,23 @@ object Users2x {
|
||||
users.toVector.filter(u => !u.presenter)
|
||||
}
|
||||
|
||||
def findNotPresentersNorModerators(users: Users2x): Vector[UserState] = {
|
||||
users.toVector.filter(u => !u.presenter && u.role != Roles.MODERATOR_ROLE)
|
||||
def getRandomlyPickableUsers(users: Users2x, reduceDup: Boolean): Vector[UserState] = {
|
||||
|
||||
if (reduceDup) {
|
||||
users.toVector.filter(u => !u.presenter && u.role != Roles.MODERATOR_ROLE && !u.userLeftFlag.left && !u.pickExempted)
|
||||
} else {
|
||||
users.toVector.filter(u => !u.presenter && u.role != Roles.MODERATOR_ROLE && !u.userLeftFlag.left)
|
||||
}
|
||||
}
|
||||
|
||||
def findViewers(users: Users2x): Vector[UserState] = {
|
||||
users.toVector.filter(u => u.role == Roles.VIEWER_ROLE)
|
||||
}
|
||||
|
||||
def findLockedViewers(users: Users2x): Vector[UserState] = {
|
||||
users.toVector.filter(u => u.role == Roles.VIEWER_ROLE && u.locked)
|
||||
}
|
||||
|
||||
def updateLastUserActivity(users: Users2x, u: UserState): UserState = {
|
||||
val newUserState = modify(u)(_.lastActivityTime).setTo(System.currentTimeMillis())
|
||||
users.save(newUserState)
|
||||
@ -146,6 +155,16 @@ object Users2x {
|
||||
}
|
||||
}
|
||||
|
||||
def setUserExempted(users: Users2x, intId: String, exempted: Boolean): Option[UserState] = {
|
||||
for {
|
||||
u <- findWithIntId(users, intId)
|
||||
} yield {
|
||||
val newUser = u.modify(_.pickExempted).setTo(exempted)
|
||||
users.save(newUser)
|
||||
newUser
|
||||
}
|
||||
}
|
||||
|
||||
def hasPresenter(users: Users2x): Boolean = {
|
||||
findPresenter(users) match {
|
||||
case Some(p) => true
|
||||
@ -290,6 +309,7 @@ case class UserState(
|
||||
lastActivityTime: Long = System.currentTimeMillis(),
|
||||
lastInactivityInspect: Long = 0,
|
||||
clientType: String,
|
||||
pickExempted: Boolean,
|
||||
userLeftFlag: UserLeftFlag
|
||||
)
|
||||
|
||||
|
@ -1,5 +1,7 @@
|
||||
package org.bigbluebutton.core.models
|
||||
|
||||
import com.softwaremill.quicklens._
|
||||
|
||||
object Webcams {
|
||||
def findWithStreamId(webcams: Webcams, streamId: String): Option[WebcamStream] = {
|
||||
webcams.toVector.find(w => w.stream.id == streamId)
|
||||
@ -31,17 +33,38 @@ object Webcams {
|
||||
} yield removedStream
|
||||
}
|
||||
|
||||
def updateWebcamStream(webcams: Webcams, streamId: String, userId: String): Option[WebcamStream] = {
|
||||
def addViewer(webcams: Webcams, streamId: String, subscriberId: String): Unit = {
|
||||
for {
|
||||
webcamStream <- findWithStreamId(webcams, streamId)
|
||||
} yield {
|
||||
val mediaStream = webcamStream.stream
|
||||
if (!mediaStream.viewers.contains(subscriberId)) {
|
||||
val newViewers = mediaStream.viewers + subscriberId
|
||||
webcams.updateViewers(webcamStream, newViewers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def removeViewer(webcams: Webcams, streamId: String, subscriberId: String): Unit = {
|
||||
for {
|
||||
webcamStream <- findWithStreamId(webcams, streamId)
|
||||
} yield {
|
||||
val mediaStream = webcamStream.stream
|
||||
if (mediaStream.viewers.contains(subscriberId)) {
|
||||
val newViewers = mediaStream.viewers - subscriberId
|
||||
webcams.updateViewers(webcamStream, newViewers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def isViewingWebcam(webcams: Webcams, userId: String, streamId: String): Boolean = {
|
||||
findWithStreamId(webcams, streamId) match {
|
||||
case Some(value) => {
|
||||
val mediaStream: MediaStream = MediaStream(value.stream.id, value.stream.url, userId, value.stream.attributes,
|
||||
value.stream.viewers)
|
||||
val webcamStream: WebcamStream = WebcamStream(streamId, mediaStream)
|
||||
webcams.update(streamId, webcamStream)
|
||||
Some(webcamStream)
|
||||
case Some(webcam) => {
|
||||
val viewing = webcam.stream.viewers contains userId
|
||||
viewing
|
||||
}
|
||||
case None => {
|
||||
None
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -63,10 +86,11 @@ class Webcams {
|
||||
webcam
|
||||
}
|
||||
|
||||
private def update(streamId: String, webcamStream: WebcamStream): WebcamStream = {
|
||||
val webcam = remove(streamId)
|
||||
|
||||
save(webcamStream)
|
||||
private def updateViewers(webcamStream: WebcamStream, viewers: Set[String]): WebcamStream = {
|
||||
val mediaStream: MediaStream = webcamStream.stream
|
||||
val newMediaStream = mediaStream.modify(_.viewers).setTo(viewers)
|
||||
val newWebcamStream = webcamStream.modify(_.stream).setTo(newMediaStream)
|
||||
save(newWebcamStream)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -137,6 +137,12 @@ class ReceivedJsonMsgHandlerActor(
|
||||
routeGenericMsg[GetCamBroadcastPermissionReqMsg](envelope, jsonNode)
|
||||
case GetCamSubscribePermissionReqMsg.NAME =>
|
||||
routeGenericMsg[GetCamSubscribePermissionReqMsg](envelope, jsonNode)
|
||||
case CamStreamSubscribedInSfuEvtMsg.NAME =>
|
||||
routeGenericMsg[CamStreamSubscribedInSfuEvtMsg](envelope, jsonNode)
|
||||
case CamStreamUnsubscribedInSfuEvtMsg.NAME =>
|
||||
routeGenericMsg[CamStreamUnsubscribedInSfuEvtMsg](envelope, jsonNode)
|
||||
case CamBroadcastStoppedInSfuEvtMsg.NAME =>
|
||||
routeGenericMsg[CamBroadcastStoppedInSfuEvtMsg](envelope, jsonNode)
|
||||
|
||||
// Voice
|
||||
case RecordingStartedVoiceConfEvtMsg.NAME =>
|
||||
|
@ -64,6 +64,7 @@ trait HandlerHelpers extends SystemConfiguration {
|
||||
locked = MeetingStatus2x.getPermissions(liveMeeting.status).lockOnJoin,
|
||||
avatar = regUser.avatarURL,
|
||||
clientType = clientType,
|
||||
pickExempted = false,
|
||||
userLeftFlag = UserLeftFlag(false, 0)
|
||||
)
|
||||
}
|
||||
|
@ -40,7 +40,7 @@ import org.bigbluebutton.core.apps.layout.LayoutApp2x
|
||||
import org.bigbluebutton.core.apps.meeting.{ SyncGetMeetingInfoRespMsgHdlr, ValidateConnAuthTokenSysMsgHdlr }
|
||||
import org.bigbluebutton.core.apps.users.ChangeLockSettingsInMeetingCmdMsgHdlr
|
||||
import org.bigbluebutton.core.models.VoiceUsers.{ findAllFreeswitchCallers, findAllListenOnlyVoiceUsers }
|
||||
import org.bigbluebutton.core.models.Webcams.{ findAll, updateWebcamStream }
|
||||
import org.bigbluebutton.core.models.Webcams.{ findAll }
|
||||
import org.bigbluebutton.core2.MeetingStatus2x.{ hasAuthedUserJoined, isVoiceRecording }
|
||||
import org.bigbluebutton.core2.message.senders.{ MsgBuilder, Sender }
|
||||
|
||||
@ -85,6 +85,9 @@ class MeetingActor(
|
||||
with GetScreenSubscribePermissionReqMsgHdlr
|
||||
with GetCamBroadcastPermissionReqMsgHdlr
|
||||
with GetCamSubscribePermissionReqMsgHdlr
|
||||
with CamStreamSubscribedInSfuEvtMsgHdlr
|
||||
with CamStreamUnsubscribedInSfuEvtMsgHdlr
|
||||
with CamBroadcastStoppedInSfuEvtMsgHdlr
|
||||
|
||||
with EjectUserFromVoiceCmdMsgHdlr
|
||||
with EndMeetingSysCmdMsgHdlr
|
||||
@ -242,8 +245,6 @@ class MeetingActor(
|
||||
handleMeetingInfoAnalyticsLogging()
|
||||
case MeetingInfoAnalyticsMsg =>
|
||||
handleMeetingInfoAnalyticsService()
|
||||
case msg: CamStreamSubscribeSysMsg =>
|
||||
handleCamStreamSubscribeSysMsg(msg)
|
||||
case msg: ScreenStreamSubscribeSysMsg =>
|
||||
handleScreenStreamSubscribeSysMsg(msg)
|
||||
//=============================
|
||||
@ -381,13 +382,16 @@ class MeetingActor(
|
||||
case m: UserLeaveReqMsg =>
|
||||
state = handleUserLeaveReqMsg(m, state)
|
||||
updateModeratorsPresence()
|
||||
case m: UserBroadcastCamStartMsg => handleUserBroadcastCamStartMsg(m)
|
||||
case m: UserBroadcastCamStopMsg => handleUserBroadcastCamStopMsg(m)
|
||||
case m: GetCamBroadcastPermissionReqMsg => handleGetCamBroadcastPermissionReqMsg(m)
|
||||
case m: GetCamSubscribePermissionReqMsg => handleGetCamSubscribePermissionReqMsg(m)
|
||||
case m: UserBroadcastCamStartMsg => handleUserBroadcastCamStartMsg(m)
|
||||
case m: UserBroadcastCamStopMsg => handleUserBroadcastCamStopMsg(m)
|
||||
case m: GetCamBroadcastPermissionReqMsg => handleGetCamBroadcastPermissionReqMsg(m)
|
||||
case m: GetCamSubscribePermissionReqMsg => handleGetCamSubscribePermissionReqMsg(m)
|
||||
case m: CamStreamSubscribedInSfuEvtMsg => handleCamStreamSubscribedInSfuEvtMsg(m)
|
||||
case m: CamStreamUnsubscribedInSfuEvtMsg => handleCamStreamUnsubscribedInSfuEvtMsg(m)
|
||||
case m: CamBroadcastStoppedInSfuEvtMsg => handleCamBroadcastStoppedInSfuEvtMsg(m)
|
||||
|
||||
case m: UserJoinedVoiceConfEvtMsg => handleUserJoinedVoiceConfEvtMsg(m)
|
||||
case m: LogoutAndEndMeetingCmdMsg => usersApp.handleLogoutAndEndMeetingCmdMsg(m, state)
|
||||
case m: UserJoinedVoiceConfEvtMsg => handleUserJoinedVoiceConfEvtMsg(m)
|
||||
case m: LogoutAndEndMeetingCmdMsg => usersApp.handleLogoutAndEndMeetingCmdMsg(m, state)
|
||||
case m: SetRecordingStatusCmdMsg =>
|
||||
state = usersApp.handleSetRecordingStatusCmdMsg(m, state)
|
||||
updateUserLastActivity(m.body.setBy)
|
||||
@ -575,10 +579,6 @@ class MeetingActor(
|
||||
}
|
||||
}
|
||||
|
||||
private def handleCamStreamSubscribeSysMsg(msg: CamStreamSubscribeSysMsg): Unit = {
|
||||
updateWebcamStream(liveMeeting.webcams, msg.body.streamId, msg.body.userId)
|
||||
}
|
||||
|
||||
private def handleScreenStreamSubscribeSysMsg(msg: ScreenStreamSubscribeSysMsg): Unit = ???
|
||||
|
||||
private def handleMeetingInfoAnalyticsLogging(): Unit = {
|
||||
|
@ -64,6 +64,14 @@ class FromAkkaAppsMsgSenderActor(msgSender: MessageSender)
|
||||
case GetUsersStatusToVoiceConfSysMsg.NAME =>
|
||||
msgSender.send(toVoiceConfRedisChannel, json)
|
||||
|
||||
// Sent to SFU
|
||||
case EjectUserFromSfuSysMsg.NAME =>
|
||||
msgSender.send(toSfuRedisChannel, json)
|
||||
case CamBroadcastStopSysMsg.NAME =>
|
||||
msgSender.send(toSfuRedisChannel, json)
|
||||
case CamStreamUnsubscribeSysMsg.NAME =>
|
||||
msgSender.send(toSfuRedisChannel, json)
|
||||
|
||||
//==================================================================
|
||||
// Send chat, presentation, and whiteboard in different channels so as not to
|
||||
// flood other applications (e.g. bbb-web) with unnecessary messages
|
||||
|
@ -0,0 +1,36 @@
|
||||
package org.bigbluebutton.core.apps.users
|
||||
|
||||
import org.bigbluebutton.common2.msgs._
|
||||
import org.bigbluebutton.core.models.Webcams
|
||||
import org.bigbluebutton.core.running.{ MeetingActor, OutMsgRouter }
|
||||
import org.bigbluebutton.core.apps.PermissionCheck
|
||||
import org.bigbluebutton.core2.message.senders.MsgBuilder
|
||||
|
||||
trait CamBroadcastStoppedInSfuEvtMsgHdlr {
|
||||
this: MeetingActor =>
|
||||
|
||||
val outGW: OutMsgRouter
|
||||
|
||||
def handleCamBroadcastStoppedInSfuEvtMsg(msg: CamBroadcastStoppedInSfuEvtMsg): Unit = {
|
||||
for {
|
||||
publisherStream <- Webcams.findWithStreamId(liveMeeting.webcams, msg.body.streamId)
|
||||
} yield {
|
||||
if (publisherStream.stream.userId != msg.header.userId
|
||||
|| !msg.body.streamId.startsWith(msg.header.userId)) {
|
||||
val reason = "User does not own camera stream"
|
||||
PermissionCheck.ejectUserForFailedPermission(
|
||||
props.meetingProp.intId, msg.header.userId, reason, outGW, liveMeeting
|
||||
)
|
||||
} else {
|
||||
for {
|
||||
_ <- Webcams.removeWebcamBroadcastStream(liveMeeting.webcams, msg.body.streamId)
|
||||
} yield {
|
||||
val event = MsgBuilder.buildUserBroadcastCamStoppedEvtMsg(
|
||||
props.meetingProp.intId, msg.header.userId, msg.body.streamId
|
||||
)
|
||||
outGW.send(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
package org.bigbluebutton.core2.message.handlers
|
||||
|
||||
import org.bigbluebutton.common2.msgs._
|
||||
import org.bigbluebutton.core.running.{ MeetingActor, OutMsgRouter }
|
||||
import org.bigbluebutton.core.models.{ Users2x }
|
||||
import org.bigbluebutton.core.models.Webcams.{ findWithStreamId, addViewer }
|
||||
import org.bigbluebutton.core2.message.senders.MsgBuilder
|
||||
import org.bigbluebutton.LockSettingsUtil
|
||||
|
||||
trait CamStreamSubscribedInSfuEvtMsgHdlr {
|
||||
this: MeetingActor =>
|
||||
|
||||
val outGW: OutMsgRouter
|
||||
|
||||
def isAllowedToSubscribeToCam(userId: String, streamId: String): Boolean = {
|
||||
var allowed = false
|
||||
|
||||
for {
|
||||
user <- Users2x.findWithIntId(liveMeeting.users2x, userId)
|
||||
stream <- findWithStreamId(liveMeeting.webcams, streamId)
|
||||
} yield {
|
||||
val camSubscribeLocked = LockSettingsUtil.isCameraSubscribeLocked(user, stream, liveMeeting)
|
||||
if (!user.userLeftFlag.left
|
||||
&& (applyPermissionCheck && !camSubscribeLocked)) {
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
|
||||
allowed
|
||||
}
|
||||
|
||||
def handleCamStreamSubscribedInSfuEvtMsg(msg: CamStreamSubscribedInSfuEvtMsg) {
|
||||
// Subscriber's user ID
|
||||
val userId = msg.header.userId
|
||||
// Publisher's stream ID
|
||||
val streamId = msg.body.streamId
|
||||
val allowed = isAllowedToSubscribeToCam(userId, streamId)
|
||||
|
||||
if (allowed) {
|
||||
addViewer(liveMeeting.webcams, streamId, userId)
|
||||
} else {
|
||||
val event = MsgBuilder.buildCamStreamUnsubscribeSysMsg(
|
||||
liveMeeting.props.meetingProp.intId,
|
||||
userId,
|
||||
streamId
|
||||
)
|
||||
outGW.send(event)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
package org.bigbluebutton.core2.message.handlers
|
||||
|
||||
import org.bigbluebutton.common2.msgs._
|
||||
import org.bigbluebutton.core.running.{ MeetingActor, OutMsgRouter }
|
||||
import org.bigbluebutton.core.models.Webcams.{ removeViewer }
|
||||
|
||||
trait CamStreamUnsubscribedInSfuEvtMsgHdlr {
|
||||
this: MeetingActor =>
|
||||
|
||||
val outGW: OutMsgRouter
|
||||
|
||||
def handleCamStreamUnsubscribedInSfuEvtMsg(msg: CamStreamUnsubscribedInSfuEvtMsg) {
|
||||
// Subscriber's user ID
|
||||
val userId = msg.header.userId
|
||||
// Publisher's stream ID
|
||||
val streamId = msg.body.streamId
|
||||
|
||||
removeViewer(liveMeeting.webcams, streamId, userId)
|
||||
}
|
||||
}
|
@ -214,16 +214,6 @@ object MsgBuilder {
|
||||
BbbCommonEnvCoreMsg(envelope, event)
|
||||
}
|
||||
|
||||
def buildCamStreamSubscribeSysMsg(meetingId: String, userId: String, streamId: String, sfuSessionId: String): BbbCommonEnvCoreMsg = {
|
||||
val routing = collection.immutable.HashMap("sender" -> "bbb-apps-akka")
|
||||
val envelope = BbbCoreEnvelope(CamStreamSubscribeSysMsg.NAME, routing)
|
||||
val header = BbbCoreBaseHeader(CamStreamSubscribeSysMsg.NAME)
|
||||
val body = CamStreamSubscribeSysMsgBody(meetingId, userId, streamId, sfuSessionId)
|
||||
val event = CamStreamSubscribeSysMsg(header, body)
|
||||
|
||||
BbbCommonEnvCoreMsg(envelope, event)
|
||||
}
|
||||
|
||||
def buildMeetingDestroyedEvtMsg(meetingId: String): BbbCommonEnvCoreMsg = {
|
||||
val routing = collection.immutable.HashMap("sender" -> "bbb-apps-akka")
|
||||
val envelope = BbbCoreEnvelope(MeetingDestroyedEvtMsg.NAME, routing)
|
||||
@ -560,14 +550,64 @@ object MsgBuilder {
|
||||
BbbCommonEnvCoreMsg(envelope, event)
|
||||
}
|
||||
|
||||
def buildLearningDashboardEvtMsg(meetingId: String, activityJson: String): BbbCommonEnvCoreMsg = {
|
||||
def buildLearningDashboardEvtMsg(meetingId: String, learningDashboardAccessToken: String, activityJson: String): BbbCommonEnvCoreMsg = {
|
||||
val routing = collection.immutable.HashMap("sender" -> "bbb-apps-akka")
|
||||
val envelope = BbbCoreEnvelope(LearningDashboardEvtMsg.NAME, routing)
|
||||
val body = LearningDashboardEvtMsgBody(activityJson)
|
||||
val body = LearningDashboardEvtMsgBody(learningDashboardAccessToken, activityJson)
|
||||
val header = BbbCoreHeaderWithMeetingId(LearningDashboardEvtMsg.NAME, meetingId)
|
||||
val event = LearningDashboardEvtMsg(header, body)
|
||||
|
||||
BbbCommonEnvCoreMsg(envelope, event)
|
||||
}
|
||||
|
||||
def buildEjectUserFromSfuSysMsg(
|
||||
meetingId: String,
|
||||
userId: String
|
||||
): BbbCommonEnvCoreMsg = {
|
||||
val routing = collection.immutable.HashMap("sender" -> "bbb-apps-akka")
|
||||
val envelope = BbbCoreEnvelope(EjectUserFromSfuSysMsg.NAME, routing)
|
||||
val body = EjectUserFromSfuSysMsgBody(userId)
|
||||
val header = BbbCoreHeaderWithMeetingId(EjectUserFromSfuSysMsg.NAME, meetingId)
|
||||
val event = EjectUserFromSfuSysMsg(header, body)
|
||||
|
||||
BbbCommonEnvCoreMsg(envelope, event)
|
||||
}
|
||||
|
||||
def buildCamBroadcastStopSysMsg(
|
||||
meetingId: String,
|
||||
userId: String,
|
||||
streamId: String
|
||||
): BbbCommonEnvCoreMsg = {
|
||||
val routing = collection.immutable.HashMap("sender" -> "bbb-apps-akka")
|
||||
val envelope = BbbCoreEnvelope(CamBroadcastStopSysMsg.NAME, routing)
|
||||
val body = CamBroadcastStopSysMsgBody(meetingId, userId, streamId)
|
||||
val header = BbbCoreBaseHeader(CamBroadcastStopSysMsg.NAME)
|
||||
val event = CamBroadcastStopSysMsg(header, body)
|
||||
|
||||
BbbCommonEnvCoreMsg(envelope, event)
|
||||
}
|
||||
|
||||
def buildCamStreamUnsubscribeSysMsg(
|
||||
meetingId: String, userId: String, streamId: String
|
||||
): BbbCommonEnvCoreMsg = {
|
||||
val routing = collection.immutable.HashMap("sender" -> "bbb-apps-akka")
|
||||
val envelope = BbbCoreEnvelope(CamStreamUnsubscribeSysMsg.NAME, routing)
|
||||
val body = CamStreamUnsubscribeSysMsgBody(meetingId, userId, streamId)
|
||||
val header = BbbCoreBaseHeader(CamStreamUnsubscribeSysMsg.NAME)
|
||||
val event = CamStreamUnsubscribeSysMsg(header, body)
|
||||
|
||||
BbbCommonEnvCoreMsg(envelope, event)
|
||||
}
|
||||
|
||||
def buildUserBroadcastCamStoppedEvtMsg(
|
||||
meetingId: String, userId: String, streamId: String
|
||||
): BbbCommonEnvCoreMsg = {
|
||||
val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, meetingId, userId)
|
||||
val envelope = BbbCoreEnvelope(UserBroadcastCamStoppedEvtMsg.NAME, routing)
|
||||
val header = BbbClientMsgHeader(UserBroadcastCamStoppedEvtMsg.NAME, meetingId, userId)
|
||||
val body = UserBroadcastCamStoppedEvtMsgBody(userId, streamId)
|
||||
val event = UserBroadcastCamStoppedEvtMsg(header, body)
|
||||
|
||||
BbbCommonEnvCoreMsg(envelope, event)
|
||||
}
|
||||
}
|
||||
|
@ -69,7 +69,7 @@ trait FakeTestData {
|
||||
UserState(intId = regUser.id, extId = regUser.externId, name = regUser.name, role = regUser.role,
|
||||
guest = regUser.guest, authed = regUser.authed, guestStatus = regUser.guestStatus,
|
||||
emoji = "none", locked = false, presenter = false, avatar = regUser.avatarURL, clientType = "unknown",
|
||||
userLeftFlag = UserLeftFlag(false, 0))
|
||||
pickExempted = false, userLeftFlag = UserLeftFlag(false, 0))
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -54,7 +54,7 @@ object FakeUserGenerator {
|
||||
RandomStringGenerator.randomAlphanumericString(10) + ".png"
|
||||
|
||||
val ru = RegisteredUsers.create(userId = id, extId, name, role,
|
||||
authToken, avatarURL, guest, authed, guestStatus = GuestStatus.ALLOW, false)
|
||||
authToken, avatarURL, guest, authed, guestStatus = GuestStatus.ALLOW, false, false)
|
||||
RegisteredUsers.add(users, ru)
|
||||
ru
|
||||
}
|
||||
|
@ -20,7 +20,6 @@ case class Meeting(
|
||||
intId: String,
|
||||
extId: String,
|
||||
name: String,
|
||||
learningDashboardAccessToken: String,
|
||||
users: Map[String, User] = Map(),
|
||||
polls: Map[String, Poll] = Map(),
|
||||
screenshares: Vector[Screenshare] = Vector(),
|
||||
@ -30,24 +29,32 @@ case class Meeting(
|
||||
)
|
||||
|
||||
case class User(
|
||||
intId: String,
|
||||
userKey: String,
|
||||
extId: String,
|
||||
intIds: Map[String,UserId] = Map(),
|
||||
name: String,
|
||||
isModerator: Boolean,
|
||||
isDialIn: Boolean = false,
|
||||
answers: Map[String,String] = Map(),
|
||||
currentIntId: String = null,
|
||||
answers: Map[String,Vector[String]] = Map(),
|
||||
talk: Talk = Talk(),
|
||||
emojis: Vector[Emoji] = Vector(),
|
||||
emojis: Vector[Emoji] = Vector(),
|
||||
webcams: Vector[Webcam] = Vector(),
|
||||
totalOfMessages: Long = 0,
|
||||
registeredOn: Long = System.currentTimeMillis(),
|
||||
leftOn: Long = 0,
|
||||
)
|
||||
|
||||
case class UserId(
|
||||
intId: String,
|
||||
registeredOn: Long = System.currentTimeMillis(),
|
||||
leftOn: Long = 0,
|
||||
userLeftFlag: Boolean = false,
|
||||
)
|
||||
|
||||
case class Poll(
|
||||
pollId: String,
|
||||
pollType: String,
|
||||
anonymous: Boolean,
|
||||
anonymous: Boolean,
|
||||
multiple: Boolean,
|
||||
question: String,
|
||||
options: Vector[String] = Vector(),
|
||||
anonymousAnswers: Vector[String] = Vector(),
|
||||
@ -99,8 +106,10 @@ class LearningDashboardActor(
|
||||
) extends Actor with ActorLogging {
|
||||
|
||||
private var meetings: Map[String, Meeting] = Map()
|
||||
private var meetingAccessTokens: Map[String,String] = Map()
|
||||
private var meetingsLastJsonHash : Map[String,String] = Map()
|
||||
private var meetingPresentations : Map[String,Map[String,PresentationVO]] = Map()
|
||||
private var meetingExcludedUserIds : Map[String,Vector[String]] = Map()
|
||||
|
||||
system.scheduler.schedule(10.seconds, 10.seconds, self, SendPeriodicReport)
|
||||
|
||||
@ -124,8 +133,11 @@ class LearningDashboardActor(
|
||||
case m: SetCurrentPresentationEvtMsg => handleSetCurrentPresentationEvtMsg(m)
|
||||
|
||||
// User
|
||||
case m: UserJoinedMeetingEvtMsg => handleUserJoinedMeetingEvtMsg(m)
|
||||
case m: UserLeftMeetingEvtMsg => handleUserLeftMeetingEvtMsg(m)
|
||||
case m: UserRegisteredRespMsg => handleUserRegisteredRespMsg(m)
|
||||
case m: UserJoinedMeetingEvtMsg => handleUserJoinedMeetingEvtMsg(m)
|
||||
case m: UserJoinMeetingReqMsg => handleUserJoinMeetingReqMsg(m)
|
||||
case m: UserLeaveReqMsg => handleUserLeaveReqMsg(m)
|
||||
case m: UserLeftMeetingEvtMsg => handleUserLeftMeetingEvtMsg(m)
|
||||
case m: UserEmojiChangedEvtMsg => handleUserEmojiChangedEvtMsg(m)
|
||||
case m: UserRoleChangedEvtMsg => handleUserRoleChangedEvtMsg(m)
|
||||
case m: UserBroadcastCamStartedEvtMsg => handleUserBroadcastCamStartedEvtMsg(m)
|
||||
@ -157,10 +169,10 @@ class LearningDashboardActor(
|
||||
if (msg.body.chatId == GroupChatApp.MAIN_PUBLIC_CHAT) {
|
||||
for {
|
||||
meeting <- meetings.values.find(m => m.intId == msg.header.meetingId)
|
||||
user <- meeting.users.values.find(u => u.intId == msg.header.userId)
|
||||
user <- findUserByIntId(meeting, msg.header.userId)
|
||||
} yield {
|
||||
val updatedUser = user.copy(totalOfMessages = user.totalOfMessages + 1)
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.intId -> updatedUser))
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.userKey -> updatedUser))
|
||||
meetings += (updatedMeeting.intId -> updatedMeeting)
|
||||
}
|
||||
}
|
||||
@ -237,40 +249,107 @@ class LearningDashboardActor(
|
||||
}
|
||||
}
|
||||
|
||||
private def handleUserJoinedMeetingEvtMsg(msg: UserJoinedMeetingEvtMsg): Unit = {
|
||||
private def handleUserRegisteredRespMsg(msg: UserRegisteredRespMsg): Unit = {
|
||||
for {
|
||||
meeting <- meetings.values.find(m => m.intId == msg.header.meetingId)
|
||||
} yield {
|
||||
val user: User = meeting.users.values.find(u => u.intId == msg.body.intId).getOrElse({
|
||||
User(
|
||||
msg.body.intId, msg.body.extId, msg.body.name, (msg.body.role == Roles.MODERATOR_ROLE)
|
||||
)
|
||||
})
|
||||
if(msg.body.excludeFromDashboard == true) {
|
||||
meetingExcludedUserIds += (meeting.intId -> {
|
||||
meetingExcludedUserIds.get(meeting.intId).getOrElse(Vector()) :+ msg.body.userId
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
meetings += (meeting.intId -> meeting.copy(users = meeting.users + (user.intId -> user.copy(leftOn = 0))))
|
||||
private def findUserByIntId(meeting: Meeting, intId: String): Option[User] = {
|
||||
meeting.users.values.find(u => u.currentIntId == intId || (u.currentIntId == null && u.intIds.exists(uId => uId._2.intId == intId && uId._2.leftOn == 0)))
|
||||
}
|
||||
|
||||
private def findUserByExtId(meeting: Meeting, extId: String, filterUserLeft: Boolean = false): Option[User] = {
|
||||
meeting.users.values.find(u => {
|
||||
u.extId == extId && (filterUserLeft == false || !u.intIds.exists(uId => uId._2.leftOn == 0 && uId._2.userLeftFlag == false))
|
||||
})
|
||||
}
|
||||
|
||||
private def getNextKey(meeting: Meeting, extId: String): String = {
|
||||
extId + "-" + (meeting.users.values.filter(u => u.extId == extId).toVector.size + 1).toString
|
||||
}
|
||||
|
||||
private def handleUserJoinMeetingReqMsg(msg: UserJoinMeetingReqMsg): Unit = {
|
||||
for {
|
||||
meeting <- meetings.values.find(m => m.intId == msg.header.meetingId)
|
||||
} yield {
|
||||
val user = findUserByIntId(meeting, msg.body.userId).getOrElse(null)
|
||||
|
||||
if(user != null) {
|
||||
for {
|
||||
userId <- user.intIds.get(msg.body.userId)
|
||||
} yield {
|
||||
val updatedUser = user.copy(currentIntId = userId.intId, intIds = user.intIds + (userId.intId -> userId.copy(leftOn = 0, userLeftFlag = false)))
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.userKey -> updatedUser))
|
||||
|
||||
meetings += (updatedMeeting.intId -> updatedMeeting)
|
||||
}
|
||||
} else {
|
||||
val userLeftFlagged = meeting.users.values.filter(u => u.intIds.exists(uId => {
|
||||
uId._2.intId == msg.body.userId && uId._2.userLeftFlag == true && uId._2.leftOn == 0
|
||||
}))
|
||||
|
||||
//Flagged user must be reactivated, once UserJoinedMeetingEvtMsg won't be sent
|
||||
if(userLeftFlagged.size > 0) {
|
||||
this.addUserToMeeting(
|
||||
msg.header.meetingId,
|
||||
msg.body.userId,
|
||||
userLeftFlagged.last.extId,
|
||||
userLeftFlagged.last.name,
|
||||
userLeftFlagged.last.isModerator,
|
||||
userLeftFlagged.last.isDialIn)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private def handleUserJoinedMeetingEvtMsg(msg: UserJoinedMeetingEvtMsg): Unit = {
|
||||
this.addUserToMeeting(msg.header.meetingId, msg.body.intId, msg.body.extId, msg.body.name, (msg.body.role == Roles.MODERATOR_ROLE), false)
|
||||
}
|
||||
|
||||
private def handleUserLeaveReqMsg(msg: UserLeaveReqMsg): Unit = {
|
||||
for {
|
||||
meeting <- meetings.values.find(m => m.intId == msg.header.meetingId)
|
||||
user <- findUserByIntId(meeting, msg.body.userId)
|
||||
userId <- user.intIds.get(msg.body.userId)
|
||||
} yield {
|
||||
val updatedUser = user.copy(currentIntId = null, intIds = user.intIds + (userId.intId -> userId.copy(userLeftFlag = true)))
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.userKey -> updatedUser))
|
||||
|
||||
meetings += (updatedMeeting.intId -> updatedMeeting)
|
||||
}
|
||||
}
|
||||
|
||||
private def handleUserLeftMeetingEvtMsg(msg: UserLeftMeetingEvtMsg): Unit = {
|
||||
for {
|
||||
meeting <- meetings.values.find(m => m.intId == msg.header.meetingId)
|
||||
user <- meeting.users.values.find(u => u.intId == msg.body.intId)
|
||||
user <- meeting.users.values.find(u => u.intIds.exists(uId => uId._2.intId == msg.body.intId && uId._2.leftOn == 0))
|
||||
userId <- user.intIds.get(msg.body.intId)
|
||||
} yield {
|
||||
val updatedUser = user.copy(leftOn = System.currentTimeMillis())
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.intId -> updatedUser))
|
||||
val updatedUser = user.copy(
|
||||
currentIntId = if(user.currentIntId == userId.intId) null else user.currentIntId,
|
||||
intIds = user.intIds + (userId.intId -> userId.copy(leftOn = System.currentTimeMillis()))
|
||||
)
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.userKey -> updatedUser))
|
||||
|
||||
meetings += (updatedMeeting.intId -> updatedMeeting)
|
||||
}
|
||||
}
|
||||
|
||||
private def handleUserEmojiChangedEvtMsg(msg: UserEmojiChangedEvtMsg) {
|
||||
private def handleUserEmojiChangedEvtMsg(msg: UserEmojiChangedEvtMsg): Unit = {
|
||||
for {
|
||||
meeting <- meetings.values.find(m => m.intId == msg.header.meetingId)
|
||||
user <- meeting.users.values.find(u => u.intId == msg.body.userId)
|
||||
user <- findUserByIntId(meeting, msg.body.userId)
|
||||
} yield {
|
||||
if (msg.body.emoji != "none") {
|
||||
val updatedUser = user.copy(emojis = user.emojis :+ Emoji(msg.body.emoji))
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.intId -> updatedUser))
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.userKey -> updatedUser))
|
||||
|
||||
meetings += (updatedMeeting.intId -> updatedMeeting)
|
||||
}
|
||||
@ -281,10 +360,10 @@ class LearningDashboardActor(
|
||||
if(msg.body.role == Roles.MODERATOR_ROLE) {
|
||||
for {
|
||||
meeting <- meetings.values.find(m => m.intId == msg.header.meetingId)
|
||||
user <- meeting.users.values.find(u => u.intId == msg.body.userId)
|
||||
user <- findUserByIntId(meeting, msg.body.userId)
|
||||
} yield {
|
||||
val updatedUser = user.copy(isModerator = true)
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.intId -> updatedUser))
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.userKey -> updatedUser))
|
||||
|
||||
meetings += (updatedMeeting.intId -> updatedMeeting)
|
||||
}
|
||||
@ -294,11 +373,11 @@ class LearningDashboardActor(
|
||||
private def handleUserBroadcastCamStartedEvtMsg(msg: UserBroadcastCamStartedEvtMsg) {
|
||||
for {
|
||||
meeting <- meetings.values.find(m => m.intId == msg.header.meetingId)
|
||||
user <- meeting.users.values.find(u => u.intId == msg.body.userId)
|
||||
user <- findUserByIntId(meeting, msg.body.userId)
|
||||
} yield {
|
||||
|
||||
val updatedUser = user.copy(webcams = user.webcams :+ Webcam())
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.intId -> updatedUser))
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.userKey -> updatedUser))
|
||||
meetings += (updatedMeeting.intId -> updatedMeeting)
|
||||
}
|
||||
}
|
||||
@ -306,11 +385,11 @@ class LearningDashboardActor(
|
||||
private def handleUserBroadcastCamStoppedEvtMsg(msg: UserBroadcastCamStoppedEvtMsg) {
|
||||
for {
|
||||
meeting <- meetings.values.find(m => m.intId == msg.header.meetingId)
|
||||
user <- meeting.users.values.find(u => u.intId == msg.body.userId)
|
||||
user <- findUserByIntId(meeting, msg.body.userId)
|
||||
} yield {
|
||||
val lastWebcam: Webcam = user.webcams.last.copy(stoppedOn = System.currentTimeMillis())
|
||||
val updatedUser = user.copy(webcams = user.webcams.dropRight(1) :+ lastWebcam)
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.intId -> updatedUser))
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.userKey -> updatedUser))
|
||||
meetings += (updatedMeeting.intId -> updatedMeeting)
|
||||
}
|
||||
}
|
||||
@ -318,30 +397,21 @@ class LearningDashboardActor(
|
||||
private def handleUserJoinedVoiceConfToClientEvtMsg(msg: UserJoinedVoiceConfToClientEvtMsg): Unit = {
|
||||
//Create users for Dial-in connections
|
||||
if(msg.body.intId.startsWith("v_")) {
|
||||
for {
|
||||
meeting <- meetings.values.find(m => m.intId == msg.header.meetingId)
|
||||
} yield {
|
||||
val user: User = meeting.users.values.find(u => u.intId == msg.body.intId).getOrElse({
|
||||
User(
|
||||
msg.body.intId, msg.body.callerNum, msg.body.callerName, false, true
|
||||
)
|
||||
})
|
||||
|
||||
meetings += (meeting.intId -> meeting.copy(users = meeting.users + (user.intId -> user.copy(leftOn = 0))))
|
||||
}
|
||||
this.addUserToMeeting(msg.header.meetingId, msg.body.intId, msg.body.callerName, msg.body.callerName, false, true)
|
||||
}
|
||||
}
|
||||
|
||||
private def handleUserLeftVoiceConfToClientEvtMsg(msg: UserLeftVoiceConfToClientEvtMsg) {
|
||||
for {
|
||||
meeting <- meetings.values.find(m => m.intId == msg.header.meetingId)
|
||||
user <- meeting.users.values.find(u => u.intId == msg.body.intId)
|
||||
user <- findUserByIntId(meeting, msg.body.intId)
|
||||
userId <- user.intIds.get(msg.body.intId)
|
||||
} yield {
|
||||
endUserTalk(meeting, user)
|
||||
|
||||
if(user.isDialIn) {
|
||||
val updatedUser = user.copy(leftOn = System.currentTimeMillis())
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.intId -> updatedUser))
|
||||
val updatedUser = user.copy(intIds = user.intIds + (userId.intId -> userId.copy(leftOn = System.currentTimeMillis())))
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.userKey -> updatedUser))
|
||||
|
||||
meetings += (updatedMeeting.intId -> updatedMeeting)
|
||||
}
|
||||
@ -351,7 +421,7 @@ class LearningDashboardActor(
|
||||
private def handleUserMutedVoiceEvtMsg(msg: UserMutedVoiceEvtMsg) {
|
||||
for {
|
||||
meeting <- meetings.values.find(m => m.intId == msg.header.meetingId)
|
||||
user <- meeting.users.values.find(u => u.intId == msg.body.intId)
|
||||
user <- findUserByIntId(meeting, msg.body.intId)
|
||||
} yield {
|
||||
endUserTalk(meeting, user)
|
||||
}
|
||||
@ -360,11 +430,11 @@ class LearningDashboardActor(
|
||||
private def handleUserTalkingVoiceEvtMsg(msg: UserTalkingVoiceEvtMsg) {
|
||||
for {
|
||||
meeting <- meetings.values.find(m => m.intId == msg.header.meetingId)
|
||||
user <- meeting.users.values.find(u => u.intId == msg.body.intId)
|
||||
user <- findUserByIntId(meeting, msg.body.intId)
|
||||
} yield {
|
||||
if(msg.body.talking) {
|
||||
val updatedUser = user.copy(talk = user.talk.copy(lastTalkStartedOn = System.currentTimeMillis()))
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.intId -> updatedUser))
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.userKey -> updatedUser))
|
||||
meetings += (updatedMeeting.intId -> updatedMeeting)
|
||||
} else {
|
||||
endUserTalk(meeting, user)
|
||||
@ -380,7 +450,7 @@ class LearningDashboardActor(
|
||||
totalTime = user.talk.totalTime + (System.currentTimeMillis() - user.talk.lastTalkStartedOn)
|
||||
)
|
||||
)
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.intId -> updatedUser))
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.userKey -> updatedUser))
|
||||
meetings += (updatedMeeting.intId -> updatedMeeting)
|
||||
}
|
||||
}
|
||||
@ -390,7 +460,7 @@ class LearningDashboardActor(
|
||||
meeting <- meetings.values.find(m => m.intId == msg.header.meetingId)
|
||||
} yield {
|
||||
val options = msg.body.poll.answers.map(answer => answer.key)
|
||||
val newPoll = Poll(msg.body.pollId, msg.body.pollType, msg.body.secretPoll, msg.body.question, options.toVector)
|
||||
val newPoll = Poll(msg.body.pollId, msg.body.pollType, msg.body.secretPoll, msg.body.poll.isMultipleResponse, msg.body.question, options.toVector)
|
||||
|
||||
val updatedMeeting = meeting.copy(polls = meeting.polls + (newPoll.pollId -> newPoll))
|
||||
meetings += (updatedMeeting.intId -> updatedMeeting)
|
||||
@ -400,7 +470,7 @@ class LearningDashboardActor(
|
||||
private def handleUserRespondedToPollRecordMsg(msg: UserRespondedToPollRecordMsg): Unit = {
|
||||
for {
|
||||
meeting <- meetings.values.find(m => m.intId == msg.header.meetingId)
|
||||
user <- meeting.users.values.find(u => u.intId == msg.header.userId)
|
||||
user <- findUserByIntId(meeting, msg.header.userId)
|
||||
} yield {
|
||||
if(msg.body.isSecret) {
|
||||
//Store Anonymous Poll in `poll.anonymousAnswers`
|
||||
@ -413,8 +483,8 @@ class LearningDashboardActor(
|
||||
}
|
||||
} else {
|
||||
//Store Public Poll in `user.answers`
|
||||
val updatedUser = user.copy(answers = user.answers + (msg.body.pollId -> msg.body.answer))
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.intId -> updatedUser))
|
||||
val updatedUser = user.copy(answers = user.answers + (msg.body.pollId -> (user.answers.get(msg.body.pollId).getOrElse(Vector()) :+ msg.body.answer)))
|
||||
val updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.userKey -> updatedUser))
|
||||
meetings += (updatedMeeting.intId -> updatedMeeting)
|
||||
}
|
||||
}
|
||||
@ -445,10 +515,10 @@ class LearningDashboardActor(
|
||||
msg.body.props.meetingProp.intId,
|
||||
msg.body.props.meetingProp.extId,
|
||||
msg.body.props.meetingProp.name,
|
||||
msg.body.props.password.learningDashboardAccessToken,
|
||||
)
|
||||
|
||||
meetings += (newMeeting.intId -> newMeeting)
|
||||
meetingAccessTokens += (newMeeting.intId -> msg.body.props.password.learningDashboardAccessToken)
|
||||
|
||||
log.info(" created for meeting {}.",msg.body.props.meetingProp.intId)
|
||||
} else {
|
||||
@ -469,18 +539,7 @@ class LearningDashboardActor(
|
||||
else screenshare.copy(stoppedOn = endedOn)
|
||||
}),
|
||||
users = meeting.users.map(user => {
|
||||
(user._1 ->
|
||||
user._2.copy(
|
||||
leftOn = if(user._2.leftOn > 0) user._2.leftOn else endedOn,
|
||||
talk = user._2.talk.copy(
|
||||
totalTime = user._2.talk.totalTime + (if (user._2.talk.lastTalkStartedOn > 0) (endedOn - user._2.talk.lastTalkStartedOn) else 0),
|
||||
lastTalkStartedOn = 0
|
||||
),
|
||||
webcams = user._2.webcams.map(webcam => {
|
||||
if(webcam.stoppedOn > 0) webcam
|
||||
else webcam.copy(stoppedOn = endedOn)
|
||||
})
|
||||
))
|
||||
(user._1 -> userWithLeftProps(user._2, endedOn))
|
||||
})
|
||||
)
|
||||
|
||||
@ -489,12 +548,79 @@ class LearningDashboardActor(
|
||||
//Send report one last time
|
||||
sendReport(updatedMeeting)
|
||||
|
||||
meetingPresentations = meetingPresentations.-(updatedMeeting.intId)
|
||||
meetings = meetings.-(updatedMeeting.intId)
|
||||
meetingPresentations = meetingPresentations.-(updatedMeeting.intId)
|
||||
meetingAccessTokens = meetingAccessTokens.-(updatedMeeting.intId)
|
||||
meetingExcludedUserIds = meetingExcludedUserIds.-(updatedMeeting.intId)
|
||||
meetingsLastJsonHash = meetingsLastJsonHash.-(updatedMeeting.intId)
|
||||
log.info(" removed for meeting {}.",updatedMeeting.intId)
|
||||
}
|
||||
}
|
||||
|
||||
private def userWithLeftProps(user: User, endedOn: Long, forceFlaggedIdsToLeft: Boolean = true): User = {
|
||||
user.copy(
|
||||
currentIntId = null,
|
||||
intIds = user.intIds.map(uId => {
|
||||
if(uId._2.leftOn > 0) (uId._1 -> uId._2)
|
||||
else if(forceFlaggedIdsToLeft == false && uId._2.userLeftFlag == true) (uId._1 -> uId._2)
|
||||
else (uId._1 -> uId._2.copy(leftOn = endedOn))
|
||||
}),
|
||||
talk = user.talk.copy(
|
||||
totalTime = user.talk.totalTime + (if (user.talk.lastTalkStartedOn > 0) (endedOn - user.talk.lastTalkStartedOn) else 0),
|
||||
lastTalkStartedOn = 0
|
||||
),
|
||||
webcams = user.webcams.map(webcam => {
|
||||
if(webcam.stoppedOn > 0) webcam
|
||||
else webcam.copy(stoppedOn = endedOn)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private def addUserToMeeting(meetingIntId: String, intId: String, extId: String, name: String, isModerator: Boolean, isDialIn: Boolean): Unit = {
|
||||
for {
|
||||
meeting <- meetings.values.find(m => m.intId == meetingIntId)
|
||||
} yield {
|
||||
if(!meetingExcludedUserIds.get(meeting.intId).getOrElse(Vector()).contains(extId)) {
|
||||
val currentTime = System.currentTimeMillis();
|
||||
|
||||
val user: User = userWithLeftProps(
|
||||
findUserByIntId(meeting, intId).getOrElse(
|
||||
findUserByExtId(meeting, extId, true).getOrElse({
|
||||
User(
|
||||
getNextKey(meeting, extId),
|
||||
extId,
|
||||
Map(),
|
||||
name,
|
||||
isModerator,
|
||||
isDialIn,
|
||||
currentIntId = intId,
|
||||
)
|
||||
})
|
||||
)
|
||||
, currentTime, false)
|
||||
|
||||
meetings += (meeting.intId -> meeting.copy(
|
||||
//Set leftOn to same intId in past user records
|
||||
users = meeting.users.map(u => {
|
||||
(u._1 -> u._2.copy(
|
||||
intIds = u._2.intIds.map(uId => {
|
||||
(uId._1 -> {
|
||||
if (uId._2.intId == intId && uId._2.leftOn == 0) uId._2.copy(leftOn = currentTime)
|
||||
else uId._2
|
||||
})
|
||||
})))
|
||||
}) + (user.userKey -> user.copy(
|
||||
currentIntId = intId,
|
||||
intIds = user.intIds + (intId -> user.intIds.get(intId).getOrElse(UserId(intId, currentTime)).copy(
|
||||
leftOn = 0,
|
||||
userLeftFlag = false
|
||||
))
|
||||
))
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private def sendPeriodicReport(): Unit = {
|
||||
meetings.map(meeting => {
|
||||
sendReport(meeting._2)
|
||||
@ -507,13 +633,15 @@ class LearningDashboardActor(
|
||||
//Avoid send repeated activity jsons
|
||||
val activityJsonHash : String = MessageDigest.getInstance("MD5").digest(activityJson.getBytes).mkString
|
||||
if(!meetingsLastJsonHash.contains(meeting.intId) || meetingsLastJsonHash.get(meeting.intId).getOrElse("") != activityJsonHash) {
|
||||
for {
|
||||
learningDashboardAccessToken <- meetingAccessTokens.get(meeting.intId)
|
||||
} yield {
|
||||
val event = MsgBuilder.buildLearningDashboardEvtMsg(meeting.intId, learningDashboardAccessToken, activityJson)
|
||||
outGW.send(event)
|
||||
meetingsLastJsonHash += (meeting.intId -> activityJsonHash)
|
||||
|
||||
val event = MsgBuilder.buildLearningDashboardEvtMsg(meeting.intId, activityJson)
|
||||
outGW.send(event)
|
||||
|
||||
meetingsLastJsonHash += (meeting.intId -> activityJsonHash)
|
||||
|
||||
log.info("Learning Dashboard data sent for meeting {}",meeting.intId)
|
||||
log.info("Learning Dashboard data sent for meeting {}", meeting.intId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -76,6 +76,7 @@ apps {
|
||||
ejectOnViolation = false
|
||||
endMeetingWhenNoMoreAuthedUsers = false
|
||||
endMeetingWhenNoMoreAuthedUsersAfterMinutes = 2
|
||||
reduceDuplicatedPick = false
|
||||
}
|
||||
|
||||
analytics {
|
||||
|
@ -232,4 +232,4 @@ case class LearningDashboardEvtMsg(
|
||||
header: BbbCoreHeaderWithMeetingId,
|
||||
body: LearningDashboardEvtMsgBody
|
||||
) extends BbbCoreMsg
|
||||
case class LearningDashboardEvtMsgBody(activityJson: String)
|
||||
case class LearningDashboardEvtMsgBody(learningDashboardAccessToken: String, activityJson: String)
|
||||
|
@ -15,14 +15,15 @@ case class RegisterUserReqMsg(
|
||||
) extends BbbCoreMsg
|
||||
case class RegisterUserReqMsgBody(meetingId: String, intUserId: String, name: String, role: String,
|
||||
extUserId: String, authToken: String, avatarURL: String,
|
||||
guest: Boolean, authed: Boolean, guestStatus: String)
|
||||
guest: Boolean, authed: Boolean, guestStatus: String, excludeFromDashboard: Boolean)
|
||||
|
||||
object UserRegisteredRespMsg { val NAME = "UserRegisteredRespMsg" }
|
||||
case class UserRegisteredRespMsg(
|
||||
header: BbbCoreHeaderWithMeetingId,
|
||||
body: UserRegisteredRespMsgBody
|
||||
) extends BbbCoreMsg
|
||||
case class UserRegisteredRespMsgBody(meetingId: String, userId: String, name: String, role: String, registeredOn: Long)
|
||||
case class UserRegisteredRespMsgBody(meetingId: String, userId: String, name: String,
|
||||
role: String, excludeFromDashboard: Boolean, registeredOn: Long)
|
||||
|
||||
object RegisteredUserJoinTimeoutMsg { val NAME = "RegisteredUserJoinTimeoutMsg" }
|
||||
case class RegisteredUserJoinTimeoutMsg(
|
||||
@ -408,4 +409,4 @@ case class SelectRandomViewerReqMsgBody(requestedBy: String)
|
||||
*/
|
||||
object SelectRandomViewerRespMsg { val NAME = "SelectRandomViewerRespMsg" }
|
||||
case class SelectRandomViewerRespMsg(header: BbbClientMsgHeader, body: SelectRandomViewerRespMsgBody) extends StandardMsg
|
||||
case class SelectRandomViewerRespMsgBody(requestedBy: String, userIds: Vector[String], choice: Integer)
|
||||
case class SelectRandomViewerRespMsgBody(requestedBy: String, userIds: Vector[String], choice: String)
|
||||
|
@ -1,5 +1,6 @@
|
||||
package org.bigbluebutton.common2.msgs
|
||||
|
||||
// Broadcasting messages
|
||||
object UserBroadcastCamStartedEvtMsg { val NAME = "UserBroadcastCamStartedEvtMsg" }
|
||||
case class UserBroadcastCamStartedEvtMsg(
|
||||
header: BbbClientMsgHeader,
|
||||
@ -106,3 +107,79 @@ case class GetCamSubscribePermissionRespMsgBody(
|
||||
sfuSessionId: String,
|
||||
allowed: Boolean
|
||||
)
|
||||
|
||||
/**
|
||||
* Sent to bbb-webrtc-sfu to eject all media streams from #userId
|
||||
*/
|
||||
object EjectUserFromSfuSysMsg { val NAME = "EjectUserFromSfuSysMsg" }
|
||||
case class EjectUserFromSfuSysMsg(
|
||||
header: BbbCoreHeaderWithMeetingId,
|
||||
body: EjectUserFromSfuSysMsgBody
|
||||
) extends BbbCoreMsg
|
||||
case class EjectUserFromSfuSysMsgBody(userId: String)
|
||||
|
||||
/**
|
||||
* Sent to bbb-webrtc-sfu to tear down broadcaster stream #streamId
|
||||
*/
|
||||
object CamBroadcastStopSysMsg { val NAME = "CamBroadcastStopSysMsg" }
|
||||
case class CamBroadcastStopSysMsg(
|
||||
header: BbbCoreBaseHeader,
|
||||
body: CamBroadcastStopSysMsgBody
|
||||
) extends BbbCoreMsg
|
||||
case class CamBroadcastStopSysMsgBody(
|
||||
meetingId: String,
|
||||
userId: String,
|
||||
streamId: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Sent from bbb-webrtc-sfu to indicate that #userId unsubscribed from #streamId
|
||||
*/
|
||||
object CamBroadcastStoppedInSfuEvtMsg { val NAME = "CamBroadcastStoppedInSfuEvtMsg" }
|
||||
case class CamBroadcastStoppedInSfuEvtMsg(
|
||||
header: BbbClientMsgHeader,
|
||||
body: CamBroadcastStoppedInSfuEvtMsgBody
|
||||
) extends StandardMsg
|
||||
case class CamBroadcastStoppedInSfuEvtMsgBody(streamId: String)
|
||||
|
||||
/**
|
||||
* Sent to bbb-webrtc-sfu to detach #userId's subscribers from #streamId
|
||||
*/
|
||||
object CamStreamUnsubscribeSysMsg { val NAME = "CamStreamUnsubscribeSysMsg" }
|
||||
case class CamStreamUnsubscribeSysMsg(
|
||||
header: BbbCoreBaseHeader,
|
||||
body: CamStreamUnsubscribeSysMsgBody
|
||||
) extends BbbCoreMsg
|
||||
case class CamStreamUnsubscribeSysMsgBody(
|
||||
meetingId: String,
|
||||
userId: String,
|
||||
streamId: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Sent from bbb-webrtc-sfu to indicate that #userId unsubscribed from #streamId
|
||||
*/
|
||||
object CamStreamUnsubscribedInSfuEvtMsg { val NAME = "CamStreamUnsubscribedInSfuEvtMsg" }
|
||||
case class CamStreamUnsubscribedInSfuEvtMsg(
|
||||
header: BbbClientMsgHeader,
|
||||
body: CamStreamUnsubscribedInSfuEvtMsgBody
|
||||
) extends StandardMsg
|
||||
case class CamStreamUnsubscribedInSfuEvtMsgBody(
|
||||
streamId: String, // Publisher's internal stream ID
|
||||
subscriberStreamId: String,
|
||||
sfuSessionId: String // Subscriber's SFU session ID
|
||||
)
|
||||
|
||||
/**
|
||||
* Sent from bbb-webrtc-sfu to indicate that #userId subscribed to #streamId
|
||||
*/
|
||||
object CamStreamSubscribedInSfuEvtMsg { val NAME = "CamStreamSubscribedInSfuEvtMsg" }
|
||||
case class CamStreamSubscribedInSfuEvtMsg(
|
||||
header: BbbClientMsgHeader,
|
||||
body: CamStreamSubscribedInSfuEvtMsgBody
|
||||
) extends StandardMsg
|
||||
case class CamStreamSubscribedInSfuEvtMsgBody(
|
||||
streamId: String, // Publisher's internal stream ID
|
||||
subscriberStreamId: String,
|
||||
sfuSessionId: String // Subscriber's SFU session ID
|
||||
)
|
||||
|
@ -139,13 +139,13 @@ public class MeetingService implements MessageListener {
|
||||
public void registerUser(String meetingID, String internalUserId,
|
||||
String fullname, String role, String externUserID,
|
||||
String authToken, String avatarURL, Boolean guest,
|
||||
Boolean authed, String guestStatus) {
|
||||
Boolean authed, String guestStatus, Boolean excludeFromDashboard) {
|
||||
handle(new RegisterUser(meetingID, internalUserId, fullname, role,
|
||||
externUserID, authToken, avatarURL, guest, authed, guestStatus));
|
||||
externUserID, authToken, avatarURL, guest, authed, guestStatus, excludeFromDashboard));
|
||||
|
||||
Meeting m = getMeeting(meetingID);
|
||||
if (m != null) {
|
||||
RegisteredUser ruser = new RegisteredUser(authToken, internalUserId, guestStatus);
|
||||
RegisteredUser ruser = new RegisteredUser(authToken, internalUserId, guestStatus, excludeFromDashboard);
|
||||
m.userRegistered(ruser);
|
||||
}
|
||||
}
|
||||
@ -460,7 +460,7 @@ public class MeetingService implements MessageListener {
|
||||
gw.registerUser(message.meetingID,
|
||||
message.internalUserId, message.fullname, message.role,
|
||||
message.externUserID, message.authToken, message.avatarURL, message.guest,
|
||||
message.authed, message.guestStatus);
|
||||
message.authed, message.guestStatus, message.excludeFromDashboard);
|
||||
}
|
||||
|
||||
public Meeting getMeeting(String meetingId) {
|
||||
@ -965,7 +965,6 @@ public class MeetingService implements MessageListener {
|
||||
public void processLearningDashboard(LearningDashboard message) {
|
||||
//Get all data from Json instead of getMeeting(message.meetingId), to process messages received even after meeting ended
|
||||
JsonObject activityJsonObject = new Gson().fromJson(message.activityJson, JsonObject.class).getAsJsonObject();
|
||||
String learningDashboardAccessToken = activityJsonObject.get("learningDashboardAccessToken").getAsString();
|
||||
|
||||
Map<String, Object> logData = new HashMap<String, Object>();
|
||||
logData.put("meetingId", activityJsonObject.get("intId").getAsString());
|
||||
@ -979,7 +978,7 @@ public class MeetingService implements MessageListener {
|
||||
|
||||
log.info(" --analytics-- data={}", logStr);
|
||||
|
||||
learningDashboardService.writeJsonDataFile(message.meetingId, learningDashboardAccessToken, message.activityJson);
|
||||
learningDashboardService.writeJsonDataFile(message.meetingId, message.learningDashboardAccessToken, message.activityJson);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -6,12 +6,14 @@ public class RegisteredUser {
|
||||
public final Long registeredOn;
|
||||
|
||||
private String guestStatus;
|
||||
private Boolean excludeFromDashboard;
|
||||
private Long guestWaitedOn;
|
||||
|
||||
public RegisteredUser(String authToken, String userId, String guestStatus) {
|
||||
public RegisteredUser(String authToken, String userId, String guestStatus, Boolean excludeFromDashboard) {
|
||||
this.authToken = authToken;
|
||||
this.userId = userId;
|
||||
this.guestStatus = guestStatus;
|
||||
this.excludeFromDashboard = excludeFromDashboard;
|
||||
|
||||
Long currentTimeMillis = System.currentTimeMillis();
|
||||
this.registeredOn = currentTimeMillis;
|
||||
@ -26,6 +28,14 @@ public class RegisteredUser {
|
||||
return guestStatus;
|
||||
}
|
||||
|
||||
public void setExcludeFromDashboard(Boolean excludeFromDashboard) {
|
||||
this.excludeFromDashboard = excludeFromDashboard;
|
||||
}
|
||||
|
||||
public Boolean getExcludeFromDashboard() {
|
||||
return excludeFromDashboard;
|
||||
}
|
||||
|
||||
public void updateGuestWaitedOn() {
|
||||
this.guestWaitedOn = System.currentTimeMillis();
|
||||
}
|
||||
|
@ -44,6 +44,7 @@ public class UserSession {
|
||||
public String avatarURL;
|
||||
public String guestStatus = GuestPolicy.ALLOW;
|
||||
public String clientUrl = null;
|
||||
public Boolean excludeFromDashboard = false;
|
||||
|
||||
private AtomicInteger connections = new AtomicInteger(0);
|
||||
|
||||
|
@ -3,9 +3,11 @@ package org.bigbluebutton.api.messaging.messages;
|
||||
public class LearningDashboard implements IMessage {
|
||||
public final String meetingId;
|
||||
public final String activityJson;
|
||||
public final String learningDashboardAccessToken;
|
||||
|
||||
public LearningDashboard(String meetingId, String activityJson) {
|
||||
public LearningDashboard(String meetingId, String learningDashboardAccessToken, String activityJson) {
|
||||
this.meetingId = meetingId;
|
||||
this.activityJson = activityJson;
|
||||
this.learningDashboardAccessToken = learningDashboardAccessToken;
|
||||
}
|
||||
}
|
||||
|
@ -13,10 +13,11 @@ public class RegisterUser implements IMessage {
|
||||
public final Boolean guest;
|
||||
public final Boolean authed;
|
||||
public final String guestStatus;
|
||||
public final Boolean excludeFromDashboard;
|
||||
|
||||
public RegisterUser(String meetingID, String internalUserId, String fullname, String role, String externUserID,
|
||||
String authToken, String avatarURL, Boolean guest,
|
||||
Boolean authed, String guestStatus) {
|
||||
Boolean authed, String guestStatus, Boolean excludeFromDashboard) {
|
||||
this.meetingID = meetingID;
|
||||
this.internalUserId = internalUserId;
|
||||
this.fullname = fullname;
|
||||
@ -27,5 +28,6 @@ public class RegisterUser implements IMessage {
|
||||
this.guest = guest;
|
||||
this.authed = authed;
|
||||
this.guestStatus = guestStatus;
|
||||
this.excludeFromDashboard = excludeFromDashboard;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,22 @@
|
||||
package org.bigbluebutton.api.model.constraint;
|
||||
|
||||
import org.bigbluebutton.api.model.validator.JoinPasswordValidator;
|
||||
|
||||
import javax.validation.Constraint;
|
||||
import javax.validation.Payload;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import static java.lang.annotation.ElementType.TYPE;
|
||||
import static java.lang.annotation.RetentionPolicy.RUNTIME;
|
||||
|
||||
@Constraint(validatedBy = JoinPasswordValidator.class)
|
||||
@Target(TYPE)
|
||||
@Retention(RUNTIME)
|
||||
public @interface JoinPasswordConstraint {
|
||||
|
||||
String key() default "invalidPassword";
|
||||
String message() default "The provided password is neither a moderator or attendee password";
|
||||
Class<?>[] groups() default {};
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
}
|
@ -6,6 +6,7 @@ import org.bigbluebutton.api.model.constraint.NotEmpty;
|
||||
import org.bigbluebutton.api.model.constraint.PasswordConstraint;
|
||||
import org.bigbluebutton.api.model.shared.Checksum;
|
||||
import org.bigbluebutton.api.model.shared.ModeratorPassword;
|
||||
import org.bigbluebutton.api.model.shared.Password;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.Map;
|
||||
@ -32,7 +33,7 @@ public class EndMeeting extends RequestWithChecksum<EndMeeting.Params> {
|
||||
private String password;
|
||||
|
||||
@Valid
|
||||
private ModeratorPassword moderatorPassword;
|
||||
private Password moderatorPassword;
|
||||
|
||||
public EndMeeting(Checksum checksum) {
|
||||
super(checksum);
|
||||
|
@ -2,7 +2,10 @@ package org.bigbluebutton.api.model.request;
|
||||
|
||||
import org.bigbluebutton.api.model.constraint.*;
|
||||
import org.bigbluebutton.api.model.shared.Checksum;
|
||||
import org.bigbluebutton.api.model.shared.JoinPassword;
|
||||
import org.bigbluebutton.api.model.shared.Password;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.Map;
|
||||
|
||||
public class JoinMeeting extends RequestWithChecksum<JoinMeeting.Params> {
|
||||
@ -52,8 +55,12 @@ public class JoinMeeting extends RequestWithChecksum<JoinMeeting.Params> {
|
||||
|
||||
private String role;
|
||||
|
||||
@Valid
|
||||
private Password joinPassword;
|
||||
|
||||
public JoinMeeting(Checksum checksum) {
|
||||
super(checksum);
|
||||
joinPassword = new JoinPassword();
|
||||
}
|
||||
|
||||
public String getMeetingID() {
|
||||
@ -130,11 +137,18 @@ public class JoinMeeting extends RequestWithChecksum<JoinMeeting.Params> {
|
||||
public void populateFromParamsMap(Map<String, String[]> params) {
|
||||
if(params.containsKey(Params.MEETING_ID.getValue())) {
|
||||
setMeetingID(params.get(Params.MEETING_ID.getValue())[0]);
|
||||
joinPassword.setMeetingID(meetingID);
|
||||
}
|
||||
|
||||
if(params.containsKey(Params.USER_ID.getValue())) setUserID(params.get(Params.USER_ID.getValue())[0]);
|
||||
if(params.containsKey(Params.FULL_NAME.getValue())) setFullName(params.get(Params.FULL_NAME.getValue())[0]);
|
||||
if(params.containsKey(Params.PASSWORD.getValue())) setPassword(params.get(Params.PASSWORD.getValue())[0]);
|
||||
|
||||
if(params.containsKey(Params.PASSWORD.getValue())) {
|
||||
setPassword(params.get(Params.PASSWORD.getValue())[0]);
|
||||
joinPassword.setPassword(password);
|
||||
}
|
||||
|
||||
|
||||
if(params.containsKey(Params.GUEST.getValue())) setGuestString(params.get(Params.GUEST.getValue())[0]);
|
||||
if(params.containsKey(Params.AUTH.getValue())) setAuthString(params.get(Params.AUTH.getValue())[0]);
|
||||
if(params.containsKey(Params.CREATE_TIME.getValue())) setCreateTimeString(params.get(Params.CREATE_TIME.getValue())[0]);
|
||||
|
@ -0,0 +1,6 @@
|
||||
package org.bigbluebutton.api.model.shared;
|
||||
|
||||
import org.bigbluebutton.api.model.constraint.JoinPasswordConstraint;
|
||||
|
||||
@JoinPasswordConstraint
|
||||
public class JoinPassword extends Password {}
|
@ -2,30 +2,5 @@ package org.bigbluebutton.api.model.shared;
|
||||
|
||||
import org.bigbluebutton.api.model.constraint.ModeratorPasswordConstraint;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
|
||||
@ModeratorPasswordConstraint(message = "Provided moderator password is incorrect")
|
||||
public class ModeratorPassword {
|
||||
|
||||
@NotEmpty(message = "You must provide the meeting ID")
|
||||
private String meetingID;
|
||||
|
||||
@NotEmpty(message = "You must provide the password for the call")
|
||||
private String password;
|
||||
|
||||
public String getMeetingID() {
|
||||
return meetingID;
|
||||
}
|
||||
|
||||
public void setMeetingID(String meetingID) {
|
||||
this.meetingID = meetingID;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
@ModeratorPasswordConstraint
|
||||
public class ModeratorPassword extends Password {}
|
||||
|
@ -0,0 +1,28 @@
|
||||
package org.bigbluebutton.api.model.shared;
|
||||
|
||||
import javax.validation.constraints.NotEmpty;
|
||||
|
||||
public abstract class Password {
|
||||
|
||||
@NotEmpty(message = "You must provide the meeting ID")
|
||||
protected String meetingID;
|
||||
|
||||
@NotEmpty(message = "You must provide the password for the call")
|
||||
protected String password;
|
||||
|
||||
public String getMeetingID() {
|
||||
return meetingID;
|
||||
}
|
||||
|
||||
public void setMeetingID(String meetingID) {
|
||||
this.meetingID = meetingID;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
package org.bigbluebutton.api.model.validator;
|
||||
|
||||
import org.bigbluebutton.api.domain.Meeting;
|
||||
import org.bigbluebutton.api.model.constraint.JoinPasswordConstraint;
|
||||
import org.bigbluebutton.api.model.shared.JoinPassword;
|
||||
import org.bigbluebutton.api.service.ServiceUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
|
||||
public class JoinPasswordValidator implements ConstraintValidator<JoinPasswordConstraint, JoinPassword> {
|
||||
|
||||
private static Logger log = LoggerFactory.getLogger(JoinPasswordValidator.class);
|
||||
|
||||
@Override
|
||||
public void initialize(JoinPasswordConstraint constraintAnnotation) {}
|
||||
|
||||
@Override
|
||||
public boolean isValid(JoinPassword joinPassword, ConstraintValidatorContext constraintValidatorContext) {
|
||||
log.info("Validating password {} for meeting with ID {}",
|
||||
joinPassword.getPassword(), joinPassword.getMeetingID());
|
||||
|
||||
if(joinPassword.getMeetingID() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Meeting meeting = ServiceUtils.findMeetingFromMeetingID(joinPassword.getMeetingID());
|
||||
|
||||
if(meeting == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String moderatorPassword = meeting.getModeratorPassword();
|
||||
String attendeePassword = meeting.getViewerPassword();
|
||||
String providedPassword = joinPassword.getPassword();
|
||||
|
||||
if(providedPassword == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
log.info("Moderator password: {}", moderatorPassword);
|
||||
log.info("Attendee password: {}", attendeePassword);
|
||||
log.info("Provided password: {}", providedPassword);
|
||||
|
||||
if(!providedPassword.equals(moderatorPassword) && !providedPassword.equals(attendeePassword)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
@ -12,7 +12,7 @@ import javax.validation.ConstraintValidatorContext;
|
||||
|
||||
public class ModeratorPasswordValidator implements ConstraintValidator<ModeratorPasswordConstraint, ModeratorPassword> {
|
||||
|
||||
private static Logger log = LoggerFactory.getLogger(MeetingExistsValidator.class);
|
||||
private static Logger log = LoggerFactory.getLogger(ModeratorPasswordValidator.class);
|
||||
|
||||
|
||||
@Override
|
||||
|
@ -18,7 +18,7 @@ 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 authed);
|
||||
String authToken, String avatarURL, Boolean guest, Boolean excludeFromDashboard, Boolean authed);
|
||||
void sendKeepAlive(String system, Long bbbWebTimestamp, Long akkaAppsTimestamp);
|
||||
void sendStunTurnInfo(String meetingId, String internalUserId, Set<StunServer> stuns, Set<TurnEntry> turns);
|
||||
}
|
||||
|
@ -57,6 +57,7 @@ public class ResponseBuilder {
|
||||
|
||||
Map<String, Object> data = new HashMap<String, Object>();
|
||||
data.put("returnCode", returnCode);
|
||||
data.put("version", apiVersion);
|
||||
data.put("apiVersion", apiVersion);
|
||||
data.put("bbbVersion", bbbVersion);
|
||||
|
||||
|
@ -40,7 +40,7 @@ public interface IBbbWebApiGWApp {
|
||||
|
||||
void registerUser(String meetingID, String internalUserId, String fullname, String role,
|
||||
String externUserID, String authToken, String avatarURL,
|
||||
Boolean guest, Boolean authed, String guestStatus);
|
||||
Boolean guest, Boolean authed, String guestStatus, Boolean excludeFromDashboard);
|
||||
void ejectDuplicateUser(String meetingID, String internalUserId, String fullname,
|
||||
String externUserID);
|
||||
void guestWaitingLeft(String meetingID, String internalUserId);
|
||||
|
@ -231,7 +231,7 @@ class BbbWebApiGWApp(
|
||||
def registerUser(meetingId: String, intUserId: String, name: String,
|
||||
role: String, extUserId: String, authToken: String, avatarURL: String,
|
||||
guest: java.lang.Boolean, authed: java.lang.Boolean,
|
||||
guestStatus: String): Unit = {
|
||||
guestStatus: String, excludeFromDashboard: java.lang.Boolean): Unit = {
|
||||
|
||||
// meetingManagerActorRef ! new RegisterUser(meetingId = meetingId, intUserId = intUserId, name = name,
|
||||
// role = role, extUserId = extUserId, authToken = authToken, avatarURL = avatarURL,
|
||||
@ -239,7 +239,8 @@ class BbbWebApiGWApp(
|
||||
|
||||
val regUser = new RegisterUser(meetingId = meetingId, intUserId = intUserId, name = name,
|
||||
role = role, extUserId = extUserId, authToken = authToken, avatarURL = avatarURL,
|
||||
guest = guest.booleanValue(), authed = authed.booleanValue(), guestStatus = guestStatus)
|
||||
guest = guest.booleanValue(), authed = authed.booleanValue(), guestStatus = guestStatus,
|
||||
excludeFromDashboard = excludeFromDashboard)
|
||||
|
||||
val event = MsgBuilder.buildRegisterUserRequestToAkkaApps(regUser)
|
||||
msgToAkkaAppsEventBus.publish(MsgToAkkaApps(toAkkaAppsChannel, event))
|
||||
|
@ -50,7 +50,8 @@ object MsgBuilder {
|
||||
val header = BbbCoreHeaderWithMeetingId(RegisterUserReqMsg.NAME, msg.meetingId)
|
||||
val body = RegisterUserReqMsgBody(meetingId = msg.meetingId, intUserId = msg.intUserId,
|
||||
name = msg.name, role = msg.role, extUserId = msg.extUserId, authToken = msg.authToken,
|
||||
avatarURL = msg.avatarURL, guest = msg.guest, authed = msg.authed, guestStatus = msg.guestStatus)
|
||||
avatarURL = msg.avatarURL, guest = msg.guest, authed = msg.authed, guestStatus = msg.guestStatus,
|
||||
excludeFromDashboard = msg.excludeFromDashboard)
|
||||
val req = RegisterUserReqMsg(header, body)
|
||||
BbbCommonEnvCoreMsg(envelope, req)
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ case class CreateBreakoutRoomMsg(meetingId: String, parentMeetingId: String,
|
||||
case class AddUserSession(token: String, session: UserSession)
|
||||
case class RegisterUser(meetingId: String, intUserId: String, name: String, role: String,
|
||||
extUserId: String, authToken: String, avatarURL: String,
|
||||
guest: Boolean, authed: Boolean, guestStatus: String)
|
||||
guest: Boolean, authed: Boolean, guestStatus: String, excludeFromDashboard: Boolean)
|
||||
|
||||
case class CreateMeetingMsg(defaultProps: DefaultProps)
|
||||
|
||||
|
@ -182,7 +182,7 @@ class OldMeetingMsgHdlrActor(val olgMsgGW: OldMessageReceivedGW)
|
||||
}
|
||||
|
||||
def handleLearningDashboardEvtMsg(msg: LearningDashboardEvtMsg): Unit = {
|
||||
olgMsgGW.handle(new LearningDashboard(msg.header.meetingId, msg.body.activityJson))
|
||||
olgMsgGW.handle(new LearningDashboard(msg.header.meetingId, msg.body.learningDashboardAccessToken, msg.body.activityJson))
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -28,7 +28,8 @@ trait ToAkkaAppsSendersTrait extends SystemConfiguration {
|
||||
val header = BbbCoreHeaderWithMeetingId(RegisterUserReqMsg.NAME, msg.meetingId)
|
||||
val body = RegisterUserReqMsgBody(meetingId = msg.meetingId, intUserId = msg.intUserId,
|
||||
name = msg.name, role = msg.role, extUserId = msg.extUserId, authToken = msg.authToken,
|
||||
avatarURL = msg.avatarURL, guest = msg.guest, authed = msg.authed, guestStatus = msg.guestStatus)
|
||||
avatarURL = msg.avatarURL, guest = msg.guest, authed = msg.authed, guestStatus = msg.guestStatus,
|
||||
excludeFromDashboard = msg.excludeFromDashboard)
|
||||
val req = RegisterUserReqMsg(header, body)
|
||||
val message = BbbCommonEnvCoreMsg(envelope, req)
|
||||
sendToBus(message)
|
||||
|
46
bbb-learning-dashboard/package-lock.json
generated
46
bbb-learning-dashboard/package-lock.json
generated
@ -4750,6 +4750,17 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-escapes/node_modules/type-fest": {
|
||||
"version": "0.21.3",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
|
||||
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-html": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/ansi-html/-/ansi-html-0.0.7.tgz",
|
||||
@ -16195,9 +16206,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.0.tgz",
|
||||
"integrity": "sha512-i4sc/Kj8htBrAiH1viZ0TgU8Y5XqCaV/FziYK6TBczxmeKm3AEFWqqF3195yKudrarqy7Zu80Ra5dobFjn9X/Q==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.1.tgz",
|
||||
"integrity": "sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
@ -22189,9 +22200,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "0.21.3",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
|
||||
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
|
||||
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@ -28076,6 +28089,13 @@
|
||||
"integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
|
||||
"requires": {
|
||||
"type-fest": "^0.21.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"type-fest": {
|
||||
"version": "0.21.3",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
|
||||
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="
|
||||
}
|
||||
}
|
||||
},
|
||||
"ansi-html": {
|
||||
@ -36817,9 +36837,9 @@
|
||||
}
|
||||
},
|
||||
"nth-check": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.0.tgz",
|
||||
"integrity": "sha512-i4sc/Kj8htBrAiH1viZ0TgU8Y5XqCaV/FziYK6TBczxmeKm3AEFWqqF3195yKudrarqy7Zu80Ra5dobFjn9X/Q==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.1.tgz",
|
||||
"integrity": "sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w==",
|
||||
"requires": {
|
||||
"boolbase": "^1.0.0"
|
||||
}
|
||||
@ -41551,9 +41571,11 @@
|
||||
"integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="
|
||||
},
|
||||
"type-fest": {
|
||||
"version": "0.21.3",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
|
||||
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
|
||||
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"type-is": {
|
||||
"version": "1.6.18",
|
||||
|
@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Dashboard with BigBlueButton meeting activities" />
|
||||
|
@ -17,6 +17,7 @@ class App extends React.Component {
|
||||
super(props);
|
||||
this.state = {
|
||||
loading: true,
|
||||
invalidSessionCount: 0,
|
||||
activitiesJson: {},
|
||||
tab: 'overview',
|
||||
meetingId: '',
|
||||
@ -28,10 +29,9 @@ class App extends React.Component {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setDashboardParams();
|
||||
setInterval(() => {
|
||||
this.setDashboardParams(() => {
|
||||
this.fetchActivitiesJson();
|
||||
}, 10000);
|
||||
});
|
||||
}
|
||||
|
||||
handleSaveSessionData(e) {
|
||||
@ -62,7 +62,7 @@ class App extends React.Component {
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
setDashboardParams() {
|
||||
setDashboardParams(callback) {
|
||||
let learningDashboardAccessToken = '';
|
||||
let meetingId = '';
|
||||
let sessionToken = '';
|
||||
@ -81,23 +81,24 @@ class App extends React.Component {
|
||||
if (typeof params.report !== 'undefined') {
|
||||
learningDashboardAccessToken = params.report;
|
||||
} else {
|
||||
const cookieName = `learningDashboardAccessToken-${params.meeting}`;
|
||||
const cookieName = `ld-${params.meeting}`;
|
||||
const cDecoded = decodeURIComponent(document.cookie);
|
||||
const cArr = cDecoded.split('; ');
|
||||
cArr.forEach((val) => {
|
||||
if (val.indexOf(`${cookieName}=`) === 0) learningDashboardAccessToken = val.substring((`${cookieName}=`).length);
|
||||
});
|
||||
|
||||
// Extend AccessToken lifetime by 30d (in each access)
|
||||
// Extend AccessToken lifetime by 7d (in each access)
|
||||
if (learningDashboardAccessToken !== '') {
|
||||
const cookieExpiresDate = new Date();
|
||||
cookieExpiresDate.setTime(cookieExpiresDate.getTime() + (3600000 * 24 * 30));
|
||||
document.cookie = `learningDashboardAccessToken-${meetingId}=${learningDashboardAccessToken}; expires=${cookieExpiresDate.toGMTString()}; path=/;SameSite=None;Secure`;
|
||||
cookieExpiresDate.setTime(cookieExpiresDate.getTime() + (3600000 * 24 * 7));
|
||||
document.cookie = `ld-${meetingId}=${learningDashboardAccessToken}; expires=${cookieExpiresDate.toGMTString()}; path=/;SameSite=None;Secure`;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({ learningDashboardAccessToken, meetingId, sessionToken },
|
||||
this.fetchActivitiesJson);
|
||||
this.setState({ learningDashboardAccessToken, meetingId, sessionToken }, () => {
|
||||
if (typeof callback === 'function') callback();
|
||||
});
|
||||
}
|
||||
|
||||
fetchMostUsedEmojis() {
|
||||
@ -134,16 +135,23 @@ class App extends React.Component {
|
||||
}
|
||||
|
||||
fetchActivitiesJson() {
|
||||
const { learningDashboardAccessToken, meetingId, sessionToken } = this.state;
|
||||
const {
|
||||
learningDashboardAccessToken, meetingId, sessionToken, invalidSessionCount,
|
||||
} = this.state;
|
||||
|
||||
if (learningDashboardAccessToken !== '') {
|
||||
fetch(`${meetingId}/${learningDashboardAccessToken}/learning_dashboard_data.json`)
|
||||
.then((response) => response.json())
|
||||
.then((json) => {
|
||||
this.setState({ activitiesJson: json, loading: false, lastUpdated: Date.now() });
|
||||
this.setState({
|
||||
activitiesJson: json,
|
||||
loading: false,
|
||||
invalidSessionCount: 0,
|
||||
lastUpdated: Date.now(),
|
||||
});
|
||||
document.title = `Learning Dashboard - ${json.name}`;
|
||||
}).catch(() => {
|
||||
this.setState({ loading: false });
|
||||
this.setState({ loading: false, invalidSessionCount: invalidSessionCount + 1 });
|
||||
});
|
||||
} else if (sessionToken !== '') {
|
||||
const url = new URL('/bigbluebutton/api/learningDashboard', window.location);
|
||||
@ -152,35 +160,29 @@ class App extends React.Component {
|
||||
.then((json) => {
|
||||
if (json.response.returncode === 'SUCCESS') {
|
||||
const jsonData = JSON.parse(json.response.data);
|
||||
this.setState({ activitiesJson: jsonData, loading: false, lastUpdated: Date.now() });
|
||||
this.setState({
|
||||
activitiesJson: jsonData,
|
||||
loading: false,
|
||||
invalidSessionCount: 0,
|
||||
lastUpdated: Date.now(),
|
||||
});
|
||||
document.title = `Learning Dashboard - ${jsonData.name}`;
|
||||
} else {
|
||||
// When meeting is ended the sessionToken stop working, check for new cookies
|
||||
this.setDashboardParams();
|
||||
this.setState({ loading: false });
|
||||
this.setState({ loading: false, invalidSessionCount: invalidSessionCount + 1 });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.setState({ loading: false });
|
||||
this.setState({ loading: false, invalidSessionCount: invalidSessionCount + 1 });
|
||||
});
|
||||
} else {
|
||||
this.setState({ loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
copyPublicLink() {
|
||||
const { learningDashboardAccessToken, meetingId, ldAccessTokenCopied } = this.state;
|
||||
const { intl } = this.props;
|
||||
|
||||
let url = window.location.href.split('?')[0];
|
||||
url += `?meeting=${meetingId}&report=${learningDashboardAccessToken}&lang=${intl.locale}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
if (ldAccessTokenCopied === false) {
|
||||
this.setState({ ldAccessTokenCopied: true });
|
||||
setTimeout(() => {
|
||||
this.setState({ ldAccessTokenCopied: false });
|
||||
}, 3000);
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.fetchActivitiesJson();
|
||||
}, 10000 * (2 ** invalidSessionCount));
|
||||
}
|
||||
|
||||
render() {
|
||||
@ -201,12 +203,17 @@ class App extends React.Component {
|
||||
}
|
||||
|
||||
function totalOfActivity() {
|
||||
const minTime = Object.values(activitiesJson.users || {}).reduce((prevVal, elem) => {
|
||||
const usersTimes = Object.values(activitiesJson.users || {}).reduce((prev, user) => ([
|
||||
...prev,
|
||||
...Object.values(user.intIds),
|
||||
]), []);
|
||||
|
||||
const minTime = Object.values(usersTimes || {}).reduce((prevVal, elem) => {
|
||||
if (prevVal === 0 || elem.registeredOn < prevVal) return elem.registeredOn;
|
||||
return prevVal;
|
||||
}, 0);
|
||||
|
||||
const maxTime = Object.values(activitiesJson.users || {}).reduce((prevVal, elem) => {
|
||||
const maxTime = Object.values(usersTimes || {}).reduce((prevVal, elem) => {
|
||||
if (elem.leftOn === 0) return (new Date()).getTime();
|
||||
if (elem.leftOn > prevVal) return elem.leftOn;
|
||||
return prevVal;
|
||||
@ -284,33 +291,6 @@ class App extends React.Component {
|
||||
<div className="flex items-start justify-between pb-3">
|
||||
<h1 className="mt-3 text-2xl font-semibold whitespace-nowrap inline-block">
|
||||
<FormattedMessage id="app.learningDashboard.dashboardTitle" defaultMessage="Learning Dashboard" />
|
||||
|
||||
{
|
||||
learningDashboardAccessToken !== ''
|
||||
? (
|
||||
<button type="button" onClick={() => { this.copyPublicLink(); }} className="text-sm font-medium text-blue-500 ease-out">
|
||||
(
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<FormattedMessage id="app.learningDashboard.shareButton" defaultMessage="Share with others" />
|
||||
)
|
||||
</button>
|
||||
)
|
||||
: null
|
||||
}
|
||||
{
|
||||
ldAccessTokenCopied === true
|
||||
? (
|
||||
@ -369,8 +349,11 @@ class App extends React.Component {
|
||||
? intl.formatMessage({ id: 'app.learningDashboard.indicators.usersOnline', defaultMessage: 'Active Users' })
|
||||
: intl.formatMessage({ id: 'app.learningDashboard.indicators.usersTotal', defaultMessage: 'Total Number Of Users' })
|
||||
}
|
||||
number={Object.values(activitiesJson.users || {})
|
||||
.filter((u) => activitiesJson.endedOn > 0 || u.leftOn === 0).length}
|
||||
number={Object
|
||||
.values(activitiesJson.users || {})
|
||||
.filter((u) => activitiesJson.endedOn > 0
|
||||
|| Object.values(u.intIds)[Object.values(u.intIds).length - 1].leftOn === 0)
|
||||
.length}
|
||||
cardClass={tab === 'overview' ? 'border-pink-500' : 'hover:border-pink-500'}
|
||||
iconClass="bg-pink-50 text-pink-500"
|
||||
onClick={() => {
|
||||
@ -483,7 +466,13 @@ class App extends React.Component {
|
||||
)
|
||||
: null }
|
||||
{ (tab === 'status_timeline')
|
||||
? <StatusTable allUsers={activitiesJson.users} />
|
||||
? (
|
||||
<StatusTable
|
||||
allUsers={activitiesJson.users}
|
||||
slides={activitiesJson.presentationSlides}
|
||||
meetingId={activitiesJson.intId}
|
||||
/>
|
||||
)
|
||||
: null }
|
||||
{ tab === 'polling'
|
||||
? <PollsTable polls={activitiesJson.polls} allUsers={activitiesJson.users} />
|
||||
|
@ -11,7 +11,7 @@ class PollsTable extends React.Component {
|
||||
if (typeof user.answers[poll.pollId] !== 'undefined') {
|
||||
return user.answers[poll.pollId];
|
||||
}
|
||||
return '';
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof polls === 'object' && Object.values(polls).length === 0) {
|
||||
@ -50,10 +50,10 @@ class PollsTable extends React.Component {
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="w-full whitespace-nowrap">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-xs font-semibold tracking-wide col-text-left text-gray-500 uppercase border-b bg-gray-100">
|
||||
<th className="px-4 py-3">
|
||||
<th className="px-3.5 2xl:px-4 py-3">
|
||||
<FormattedMessage id="app.learningDashboard.user" defaultMessage="User" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@ -66,11 +66,11 @@ class PollsTable extends React.Component {
|
||||
</svg>
|
||||
</th>
|
||||
{typeof polls === 'object' && Object.values(polls || {}).length > 0 ? (
|
||||
Object.values(polls || {}).map((poll, index) => <th className="px-4 py-3 text-center">{poll.question || `Poll ${index + 1}`}</th>)
|
||||
Object.values(polls || {}).map((poll, index) => <th className="px-3.5 2xl:px-4 py-3 text-center">{poll.question || `Poll ${index + 1}`}</th>)
|
||||
) : null }
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y">
|
||||
<tbody className="bg-white divide-y whitespace-nowrap">
|
||||
{ typeof allUsers === 'object' && Object.values(allUsers || {}).length > 0 ? (
|
||||
Object.values(allUsers || {})
|
||||
.filter((user) => Object.values(user.answers).length > 0)
|
||||
@ -83,92 +83,99 @@ class PollsTable extends React.Component {
|
||||
})
|
||||
.map((user) => (
|
||||
<tr className="text-gray-700">
|
||||
<td className="px-4 py-3">
|
||||
<td className="px-3.5 2xl:px-4 py-3">
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="relative hidden w-8 h-8 rounded-full md:block">
|
||||
<UserAvatar user={user} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold">{user.name}</p>
|
||||
<p className="font-semibold truncate xl:max-w-sm max-w-xs">{user.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{typeof polls === 'object' && Object.values(polls || {}).length > 0 ? (
|
||||
Object.values(polls || {}).map((poll) => (
|
||||
<td className="px-4 py-3 text-sm text-center">
|
||||
{ getUserAnswer(user, poll) }
|
||||
{ poll.anonymous
|
||||
? (
|
||||
<span title={intl.formatMessage({
|
||||
id: 'app.learningDashboard.pollsTable.anonymousAnswer',
|
||||
defaultMessage: 'Anonymous Poll (answers in the last row)',
|
||||
})}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
Object.values(polls || {})
|
||||
.sort((a, b) => ((a.createdOn > b.createdOn) ? 1 : -1))
|
||||
.map((poll) => (
|
||||
<td className="px-4 py-3 text-sm text-center">
|
||||
{ getUserAnswer(user, poll).map((answer) => <p>{answer}</p>) }
|
||||
{ poll.anonymous
|
||||
? (
|
||||
<span title={intl.formatMessage({
|
||||
id: 'app.learningDashboard.pollsTable.anonymousAnswer',
|
||||
defaultMessage: 'Anonymous Poll (answers in the last row)',
|
||||
})}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)
|
||||
: null }
|
||||
</td>
|
||||
))
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)
|
||||
: null }
|
||||
</td>
|
||||
))
|
||||
) : null }
|
||||
</tr>
|
||||
))) : null }
|
||||
<tr className="text-gray-700">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="relative hidden w-8 h-8 mr-3 rounded-full md:block">
|
||||
{/* <img className="object-cover w-full h-full rounded-full" */}
|
||||
{/* src="" */}
|
||||
{/* alt="" loading="lazy" /> */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="relative hidden w-8 h-8 mr-3 rounded-full md:block"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
className="absolute inset-0 rounded-full shadow-inner"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">
|
||||
<FormattedMessage id="app.learningDashboard.pollsTable.anonymousRowName" defaultMessage="Anonymous" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{typeof polls === 'object' && Object.values(polls || {}).length > 0 ? (
|
||||
Object.values(polls || {}).map((poll) => (
|
||||
<td className="px-4 py-3 text-sm text-center">
|
||||
{ poll.anonymousAnswers.map((answer) => <p>{answer}</p>) }
|
||||
{typeof polls === 'object'
|
||||
&& Object.values(polls || {}).length > 0
|
||||
&& Object.values(polls).reduce((prev, poll) => ([
|
||||
...prev,
|
||||
...poll.anonymousAnswers,
|
||||
]), []).length > 0 ? (
|
||||
<tr className="text-gray-700">
|
||||
<td className="px-3.5 2xl:px-4 py-3">
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="relative hidden w-8 h-8 mr-3 rounded-full md:block">
|
||||
{/* <img className="object-cover w-full h-full rounded-full" */}
|
||||
{/* src="" */}
|
||||
{/* alt="" loading="lazy" /> */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="relative hidden w-8 h-8 mr-3 rounded-full md:block"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
className="absolute inset-0 rounded-full shadow-inner"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">
|
||||
<FormattedMessage id="app.learningDashboard.pollsTable.anonymousRowName" defaultMessage="Anonymous" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
))
|
||||
) : null }
|
||||
</tr>
|
||||
{Object.values(polls || {}).map((poll) => (
|
||||
<td className="px-3.5 2xl:px-4 py-3 text-sm text-center">
|
||||
{ poll.anonymousAnswers.map((answer) => <p>{answer}</p>) }
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
@ -5,66 +5,175 @@ import UserAvatar from './UserAvatar';
|
||||
|
||||
class StatusTable extends React.Component {
|
||||
componentDidMount() {
|
||||
// This code is needed to prevent the emoji in the first cell
|
||||
// after the username from overflowing
|
||||
const emojis = document.getElementsByClassName('emojiOnFirstCell');
|
||||
// This code is needed to prevent emojis from overflowing.
|
||||
const emojis = document.getElementsByClassName('timeline-emoji');
|
||||
for (let i = 0; i < emojis.length; i += 1) {
|
||||
const emojiStyle = window.getComputedStyle(emojis[i]);
|
||||
let offsetLeft = emojiStyle
|
||||
const offsetLeft = Number(emojiStyle
|
||||
.left
|
||||
.replace(/px/g, '')
|
||||
.trim();
|
||||
offsetLeft = Number(offsetLeft);
|
||||
.trim());
|
||||
if (offsetLeft < 0) {
|
||||
emojis[i].style.offsetLeft = '0px';
|
||||
emojis[i].style.left = '0';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
// This code is needed to prevent emojis from overflowing.
|
||||
const emojis = document.getElementsByClassName('timeline-emoji');
|
||||
for (let i = 0; i < emojis.length; i += 1) {
|
||||
const emojiStyle = window.getComputedStyle(emojis[i]);
|
||||
const offsetLeft = Number(emojiStyle
|
||||
.left
|
||||
.replace(/px/g, '')
|
||||
.trim());
|
||||
if (offsetLeft < 0) {
|
||||
emojis[i].style.left = '0';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const spanMinutes = 10 * 60000; // 10 minutes default
|
||||
const { allUsers, intl } = this.props;
|
||||
const {
|
||||
allUsers, slides, meetingId, intl,
|
||||
} = this.props;
|
||||
|
||||
function tsToHHmmss(ts) {
|
||||
return (new Date(ts).toISOString().substr(11, 8));
|
||||
}
|
||||
|
||||
const usersRegisteredTimes = Object.values(allUsers || {}).map((user) => user.registeredOn);
|
||||
const usersLeftTimes = Object.values(allUsers || {}).map((user) => {
|
||||
if (user.leftOn === 0) return (new Date()).getTime();
|
||||
return user.leftOn;
|
||||
const usersPeriods = {};
|
||||
Object.values(allUsers || {}).forEach((user) => {
|
||||
usersPeriods[user.userKey] = [];
|
||||
Object.values(user.intIds || {}).forEach((intId, index, intIdsArray) => {
|
||||
let { leftOn } = intId;
|
||||
const nextPeriod = intIdsArray[index + 1];
|
||||
if (nextPeriod && Math.abs(leftOn - nextPeriod.registeredOn) <= 30000) {
|
||||
leftOn = nextPeriod.leftOn;
|
||||
intIdsArray.splice(index + 1, 1);
|
||||
}
|
||||
usersPeriods[user.userKey].push({
|
||||
registeredOn: intId.registeredOn,
|
||||
leftOn,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const usersRegisteredTimes = Object
|
||||
.values(allUsers || {})
|
||||
.map((user) => Object.values(user.intIds).map((intId) => intId.registeredOn))
|
||||
.flat();
|
||||
const usersLeftTimes = Object
|
||||
.values(allUsers || {})
|
||||
.map((user) => Object.values(user.intIds).map((intId) => {
|
||||
if (intId.leftOn === 0) return (new Date()).getTime();
|
||||
return intId.leftOn;
|
||||
}))
|
||||
.flat();
|
||||
|
||||
const firstRegisteredOnTime = Math.min(...usersRegisteredTimes);
|
||||
const lastLeftOnTime = Math.max(...usersLeftTimes);
|
||||
|
||||
const periods = [];
|
||||
let currPeriod = firstRegisteredOnTime;
|
||||
while (currPeriod < lastLeftOnTime) {
|
||||
periods.push(currPeriod);
|
||||
currPeriod += spanMinutes;
|
||||
let hasSlides = false;
|
||||
if (slides && Array.isArray(slides) && slides.length > 0) {
|
||||
const filteredSlides = slides.filter((slide) => slide.presentationId !== '');
|
||||
if (filteredSlides.length > 0) {
|
||||
hasSlides = true;
|
||||
if (firstRegisteredOnTime < filteredSlides[0].setOn) {
|
||||
periods.push({
|
||||
start: firstRegisteredOnTime,
|
||||
end: filteredSlides[0].setOn - 1,
|
||||
});
|
||||
}
|
||||
filteredSlides.forEach((slide, index, slidesArray) => {
|
||||
periods.push({
|
||||
slide,
|
||||
start: slide.setOn,
|
||||
end: slidesArray[index + 1]?.setOn - 1 || lastLeftOnTime,
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
periods.push({
|
||||
start: firstRegisteredOnTime,
|
||||
end: lastLeftOnTime,
|
||||
});
|
||||
}
|
||||
|
||||
const isRTL = document.dir === 'rtl';
|
||||
|
||||
return (
|
||||
<table className="w-full whitespace-nowrap">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-xs font-semibold tracking-wide text-gray-500 uppercase border-b bg-gray-100">
|
||||
<th className="px-4 py-3 col-text-left sticky left-0 z-30 bg-inherit">
|
||||
<th className={`z-30 bg-inherit px-4 py-3 col-text-left sticky ${isRTL ? 'right-0' : 'left-0'}`}>
|
||||
<FormattedMessage id="app.learningDashboard.user" defaultMessage="User" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 13l-5 5m0 0l-5-5m5 5V6" />
|
||||
</svg>
|
||||
</th>
|
||||
{ periods.map((period) => <th className="px-4 py-3 col-text-left">{ `${tsToHHmmss(period - firstRegisteredOnTime)}` }</th>) }
|
||||
<th
|
||||
className="bg-inherit"
|
||||
colSpan={periods.length}
|
||||
>
|
||||
<span
|
||||
className="invisible"
|
||||
aria-label={intl.formatMessage({
|
||||
id: 'app.learningDashboard.indicators.timeline',
|
||||
defaultMessage: 'Timeline',
|
||||
})}
|
||||
>
|
||||
<FormattedMessage id="app.learningDashboard.indicators.timeline" defaultMessage="Timeline" />
|
||||
</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y">
|
||||
<tbody className="bg-white divide-y whitespace-nowrap">
|
||||
{ hasSlides ? (
|
||||
<tr className="bg-inherit">
|
||||
<td className={`bg-inherit z-30 sticky ${isRTL ? 'right-0' : 'left-0'}`} />
|
||||
{ periods.map((period) => {
|
||||
const { slide, start, end } = period;
|
||||
const padding = isRTL ? 'paddingLeft' : 'paddingRight';
|
||||
return (
|
||||
<td
|
||||
style={{
|
||||
[padding]: `${(end - start) / 1000}px`,
|
||||
}}
|
||||
>
|
||||
{ slide && (
|
||||
<div className="flex">
|
||||
<div
|
||||
className="my-4"
|
||||
aria-label={tsToHHmmss(start - periods[0].start)}
|
||||
>
|
||||
<a
|
||||
href={`/bigbluebutton/presentation/${meetingId}/${meetingId}/${slide.presentationId}/svg/${slide.pageNum}`}
|
||||
className="block border-2 border-gray-300"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<img
|
||||
src={`/bigbluebutton/presentation/${meetingId}/${meetingId}/${slide.presentationId}/thumbnail/${slide.pageNum}`}
|
||||
alt={intl.formatMessage({
|
||||
id: 'app.learningDashboard.statusTimelineTable.thumbnail',
|
||||
defaultMessage: 'Presentation thumbnail',
|
||||
})}
|
||||
style={{
|
||||
maxWidth: '150px',
|
||||
width: '150px',
|
||||
height: 'auto',
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
<div className="text-xs text-center mt-1 text-gray-500">{tsToHHmmss(slide.setOn - periods[0].start)}</div>
|
||||
</div>
|
||||
</div>
|
||||
) }
|
||||
</td>
|
||||
);
|
||||
}) }
|
||||
</tr>
|
||||
) : null }
|
||||
{ typeof allUsers === 'object' && Object.values(allUsers || {}).length > 0 ? (
|
||||
Object.values(allUsers || {})
|
||||
.sort((a, b) => {
|
||||
@ -76,114 +185,119 @@ class StatusTable extends React.Component {
|
||||
})
|
||||
.map((user) => (
|
||||
<tr className="text-gray-700 bg-inherit">
|
||||
<td className="bg-inherit sticky left-0 z-30 px-4 py-3">
|
||||
<td className={`z-30 px-4 py-3 bg-inherit sticky ${isRTL ? 'right-0' : 'left-0'}`}>
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="relative hidden w-8 h-8 rounded-full md:block">
|
||||
<UserAvatar user={user} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold">{user.name}</p>
|
||||
<p className="font-semibold truncate xl:max-w-sm max-w-xs">{user.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{ periods.map((period) => {
|
||||
const userEmojisInPeriod = filterUserEmojis(user,
|
||||
null,
|
||||
period,
|
||||
period + spanMinutes);
|
||||
const { registeredOn, leftOn } = user;
|
||||
const boundaryLeft = period;
|
||||
const boundaryRight = period + spanMinutes - 1;
|
||||
const boundaryLeft = period.start;
|
||||
const boundaryRight = period.end;
|
||||
const interval = period.end - period.start;
|
||||
return (
|
||||
<td className="relative px-4 py-3 text-sm col-text-left">
|
||||
{
|
||||
(registeredOn >= boundaryLeft && registeredOn <= boundaryRight)
|
||||
|| (leftOn >= boundaryLeft && leftOn <= boundaryRight)
|
||||
|| (boundaryLeft > registeredOn && boundaryRight < leftOn)
|
||||
|| (boundaryLeft >= registeredOn && leftOn === 0)
|
||||
? (
|
||||
(function makeLineThrough() {
|
||||
let roundedLeft = registeredOn >= boundaryLeft
|
||||
&& registeredOn <= boundaryRight ? 'rounded-l' : '';
|
||||
let roundedRight = leftOn > boundaryLeft
|
||||
&& leftOn < boundaryRight ? 'rounded-r' : '';
|
||||
let offsetLeft = 0;
|
||||
let offsetRight = 0;
|
||||
if (registeredOn >= boundaryLeft && registeredOn <= boundaryRight) {
|
||||
offsetLeft = ((registeredOn - boundaryLeft) * 100) / spanMinutes;
|
||||
}
|
||||
if (leftOn >= boundaryLeft && leftOn <= boundaryRight) {
|
||||
offsetRight = ((boundaryRight - leftOn) * 100) / spanMinutes;
|
||||
}
|
||||
let width = '';
|
||||
if (offsetLeft === 0 && offsetRight >= 99) {
|
||||
width = 'w-1.5';
|
||||
}
|
||||
if (offsetRight === 0 && offsetLeft >= 99) {
|
||||
width = 'w-1.5';
|
||||
}
|
||||
if (offsetLeft && offsetRight) {
|
||||
const variation = offsetLeft - offsetRight;
|
||||
if (
|
||||
variation > -1 && variation < 1
|
||||
) {
|
||||
width = 'w-1.5';
|
||||
}
|
||||
}
|
||||
const isRTL = document.dir === 'rtl';
|
||||
if (isRTL) {
|
||||
const aux = roundedRight;
|
||||
<td className="relative px-3.5 2xl:px-4 py-3 text-sm col-text-left">
|
||||
{ usersPeriods[user.userKey].length > 0 ? (
|
||||
usersPeriods[user.userKey].map((userPeriod) => {
|
||||
const { registeredOn, leftOn } = userPeriod;
|
||||
const userEmojisInPeriod = filterUserEmojis(user,
|
||||
null,
|
||||
registeredOn >= boundaryLeft && registeredOn <= boundaryRight
|
||||
? registeredOn : boundaryLeft,
|
||||
leftOn >= boundaryLeft && leftOn <= boundaryRight
|
||||
? leftOn : boundaryRight);
|
||||
return (
|
||||
<>
|
||||
{ (registeredOn >= boundaryLeft && registeredOn <= boundaryRight)
|
||||
|| (leftOn >= boundaryLeft && leftOn <= boundaryRight)
|
||||
|| (boundaryLeft > registeredOn && boundaryRight < leftOn)
|
||||
|| (boundaryLeft >= registeredOn && leftOn === 0) ? (
|
||||
(function makeLineThrough() {
|
||||
let roundedLeft = registeredOn >= boundaryLeft
|
||||
&& registeredOn <= boundaryRight ? 'rounded-l' : '';
|
||||
let roundedRight = leftOn >= boundaryLeft
|
||||
&& leftOn <= boundaryRight ? 'rounded-r' : '';
|
||||
let offsetLeft = 0;
|
||||
let offsetRight = 0;
|
||||
if (registeredOn >= boundaryLeft
|
||||
&& registeredOn <= boundaryRight) {
|
||||
offsetLeft = ((registeredOn - boundaryLeft) * 100)
|
||||
/ interval;
|
||||
}
|
||||
if (leftOn >= boundaryLeft && leftOn <= boundaryRight) {
|
||||
offsetRight = ((boundaryRight - leftOn) * 100) / interval;
|
||||
}
|
||||
let width = '';
|
||||
if (offsetLeft === 0 && offsetRight >= 99) {
|
||||
width = 'w-1.5';
|
||||
}
|
||||
if (offsetRight === 0 && offsetLeft >= 99) {
|
||||
width = 'w-1.5';
|
||||
}
|
||||
if (offsetLeft && offsetRight) {
|
||||
const variation = offsetLeft - offsetRight;
|
||||
if (variation > -1 && variation < 1) {
|
||||
width = 'w-1.5';
|
||||
}
|
||||
}
|
||||
if (isRTL) {
|
||||
const aux = roundedRight;
|
||||
|
||||
if (roundedLeft !== '') roundedRight = 'rounded-r';
|
||||
else roundedRight = '';
|
||||
if (roundedLeft !== '') roundedRight = 'rounded-r';
|
||||
else roundedRight = '';
|
||||
|
||||
if (aux !== '') roundedLeft = 'rounded-l';
|
||||
else roundedLeft = '';
|
||||
}
|
||||
// height / 2
|
||||
const redress = '(0.375rem / 2)';
|
||||
return (
|
||||
<div
|
||||
className={`h-1.5 ${width} bg-gray-200 absolute inset-x-0 z-10 ${roundedLeft} ${roundedRight}`}
|
||||
style={{
|
||||
top: `calc(50% - ${redress})`,
|
||||
left: `${isRTL ? offsetRight : offsetLeft}%`,
|
||||
right: `${isRTL ? offsetLeft : offsetRight}%`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
) : null
|
||||
}
|
||||
{ userEmojisInPeriod.map((emoji) => {
|
||||
const offset = ((emoji.sentOn - period) * 100) / spanMinutes;
|
||||
const origin = document.dir === 'rtl' ? 'right' : 'left';
|
||||
const onFirstCell = period === firstRegisteredOnTime;
|
||||
// font-size / 2 + padding right/left + border-width
|
||||
const redress = '(0.875rem / 2 + 0.25rem + 2px)';
|
||||
return (
|
||||
<div
|
||||
className={`flex absolute p-1 border-white border-2 rounded-full text-sm z-20 bg-purple-500 text-purple-200 ${onFirstCell ? 'emojiOnFirstCell' : ''}`}
|
||||
role="status"
|
||||
style={{
|
||||
top: `calc(50% - ${redress})`,
|
||||
[origin]: `calc(${offset}% - ${redress})`,
|
||||
}}
|
||||
title={intl.formatMessage({
|
||||
id: emojiConfigs[emoji.name].intlId,
|
||||
defaultMessage: emojiConfigs[emoji.name].defaultMessage,
|
||||
})}
|
||||
>
|
||||
<i className={`${emojiConfigs[emoji.name].icon} text-sm bbb-icon-timeline`} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
if (aux !== '') roundedLeft = 'rounded-l';
|
||||
else roundedLeft = '';
|
||||
}
|
||||
const redress = '(0.375rem / 2)';
|
||||
return (
|
||||
<div
|
||||
className={`h-1.5 ${width} bg-gray-200 absolute inset-x-0 z-10 ${roundedLeft} ${roundedRight}`}
|
||||
style={{
|
||||
top: `calc(50% - ${redress})`,
|
||||
left: `${isRTL ? offsetRight : offsetLeft}%`,
|
||||
right: `${isRTL ? offsetLeft : offsetRight}%`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
) : null }
|
||||
{ userEmojisInPeriod.map((emoji) => {
|
||||
const offset = ((emoji.sentOn - period.start) * 100)
|
||||
/ (interval);
|
||||
const origin = isRTL ? 'right' : 'left';
|
||||
const redress = '(0.875rem / 2 + 0.25rem + 2px)';
|
||||
return (
|
||||
<div
|
||||
className="flex absolute p-1 border-white border-2 rounded-full text-sm z-20 bg-purple-500 text-purple-200 timeline-emoji"
|
||||
role="status"
|
||||
style={{
|
||||
top: `calc(50% - ${redress})`,
|
||||
[origin]: `calc(${offset}% - ${redress})`,
|
||||
}}
|
||||
title={intl.formatMessage({
|
||||
id: emojiConfigs[emoji.name].intlId,
|
||||
defaultMessage: emojiConfigs[emoji.name].defaultMessage,
|
||||
})}
|
||||
>
|
||||
<i className={`${emojiConfigs[emoji.name].icon} text-sm bbb-icon-timeline`} />
|
||||
</div>
|
||||
);
|
||||
}) }
|
||||
</>
|
||||
);
|
||||
})
|
||||
) : null }
|
||||
</td>
|
||||
);
|
||||
}) }
|
||||
</tr>
|
||||
))) : null }
|
||||
)).flat()) : null }
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
@ -33,7 +33,7 @@ class UsersTable extends React.Component {
|
||||
|
||||
const usersEmojisSummary = {};
|
||||
Object.values(allUsers || {}).forEach((user) => {
|
||||
usersEmojisSummary[user.intId] = getUserEmojisSummary(user, 'raiseHand');
|
||||
usersEmojisSummary[user.userKey] = getUserEmojisSummary(user, 'raiseHand');
|
||||
});
|
||||
|
||||
function getOnlinePercentage(registeredOn, leftOn) {
|
||||
@ -43,14 +43,14 @@ class UsersTable extends React.Component {
|
||||
|
||||
const usersActivityScore = {};
|
||||
Object.values(allUsers || {}).forEach((user) => {
|
||||
usersActivityScore[user.intId] = getActivityScore(user, allUsers, totalOfPolls);
|
||||
usersActivityScore[user.userKey] = getActivityScore(user, allUsers, totalOfPolls);
|
||||
});
|
||||
|
||||
return (
|
||||
<table className="w-full whitespace-nowrap">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b bg-gray-100">
|
||||
<th className="px-4 py-3 col-text-left">
|
||||
<th className="px-3.5 2xl:px-4 py-3 col-text-left">
|
||||
<FormattedMessage id="app.learningDashboard.user" defaultMessage="User" />
|
||||
{
|
||||
tab === 'overview'
|
||||
@ -68,26 +68,26 @@ class UsersTable extends React.Component {
|
||||
: null
|
||||
}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center">
|
||||
<th className="px-3.5 2xl:px-4 py-3 text-center">
|
||||
<FormattedMessage id="app.learningDashboard.usersTable.colOnline" defaultMessage="Online time" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center">
|
||||
<th className="px-3.5 2xl:px-4 py-3 text-center">
|
||||
<FormattedMessage id="app.learningDashboard.usersTable.colTalk" defaultMessage="Talk time" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center">
|
||||
<th className="px-3.5 2xl:px-4 py-3 text-center">
|
||||
<FormattedMessage id="app.learningDashboard.usersTable.colWebcam" defaultMessage="Webcam Time" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center">
|
||||
<th className="px-3.5 2xl:px-4 py-3 text-center">
|
||||
<FormattedMessage id="app.learningDashboard.usersTable.colMessages" defaultMessage="Messages" />
|
||||
</th>
|
||||
<th className="px-4 py-3 col-text-left">
|
||||
<th className="px-3.5 2xl:px-4 py-3 col-text-left">
|
||||
<FormattedMessage id="app.learningDashboard.usersTable.colEmojis" defaultMessage="Emojis" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center">
|
||||
<th className="px-3.5 2xl:px-4 py-3 text-center">
|
||||
<FormattedMessage id="app.learningDashboard.usersTable.colRaiseHands" defaultMessage="Raise Hand" />
|
||||
</th>
|
||||
<th
|
||||
className={`px-4 py-3 text-center ${tab === 'overview_activityscore' ? 'cursor-pointer' : ''}`}
|
||||
className={`px-3.5 2xl:px-4 py-3 text-center ${tab === 'overview_activityscore' ? 'cursor-pointer' : ''}`}
|
||||
onClick={() => { if (tab === 'overview_activityscore') this.toggleActivityScoreOrder(); }}
|
||||
>
|
||||
<FormattedMessage id="app.learningDashboard.usersTable.colActivityScore" defaultMessage="Activity Score" />
|
||||
@ -112,19 +112,19 @@ class UsersTable extends React.Component {
|
||||
: null
|
||||
}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center">
|
||||
<th className="px-3.5 2xl:px-4 py-3 text-center">
|
||||
<FormattedMessage id="app.learningDashboard.usersTable.colStatus" defaultMessage="Status" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y">
|
||||
<tbody className="bg-white divide-y whitespace-nowrap">
|
||||
{ typeof allUsers === 'object' && Object.values(allUsers || {}).length > 0 ? (
|
||||
Object.values(allUsers || {})
|
||||
.sort((a, b) => {
|
||||
if (tab === 'overview_activityscore' && usersActivityScore[a.intId] < usersActivityScore[b.intId]) {
|
||||
if (tab === 'overview_activityscore' && usersActivityScore[a.userKey] < usersActivityScore[b.userKey]) {
|
||||
return activityscoreOrder === 'desc' ? 1 : -1;
|
||||
}
|
||||
if (tab === 'overview_activityscore' && usersActivityScore[a.intId] > usersActivityScore[b.intId]) {
|
||||
if (tab === 'overview_activityscore' && usersActivityScore[a.userKey] > usersActivityScore[b.userKey]) {
|
||||
return activityscoreOrder === 'desc' ? -1 : 1;
|
||||
}
|
||||
if (a.isModerator === false && b.isModerator === true) return 1;
|
||||
@ -137,7 +137,7 @@ class UsersTable extends React.Component {
|
||||
const opacity = user.leftOn > 0 ? 'opacity-75' : '';
|
||||
return (
|
||||
<tr key={user} className="text-gray-700">
|
||||
<td className={`px-4 py-3 col-text-left text-sm ${opacity}`}>
|
||||
<td className={`flex items-center px-4 py-3 col-text-left text-sm ${opacity}`}>
|
||||
<div className="inline-block relative w-8 h-8 rounded-full">
|
||||
<UserAvatar user={user} />
|
||||
<div
|
||||
@ -147,64 +147,71 @@ class UsersTable extends React.Component {
|
||||
</div>
|
||||
|
||||
<div className="inline-block">
|
||||
<p className="font-semibold">
|
||||
<p className="font-semibold truncate xl:max-w-sm max-w-xs">
|
||||
{user.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedDate
|
||||
value={user.registeredOn}
|
||||
month="short"
|
||||
day="numeric"
|
||||
hour="2-digit"
|
||||
minute="2-digit"
|
||||
second="2-digit"
|
||||
/>
|
||||
</p>
|
||||
{
|
||||
user.leftOn > 0
|
||||
? (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<FormattedDate
|
||||
value={user.leftOn}
|
||||
month="short"
|
||||
day="numeric"
|
||||
hour="2-digit"
|
||||
minute="2-digit"
|
||||
second="2-digit"
|
||||
{ Object.values(user.intIds || {}).map((intId, index) => (
|
||||
<>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
|
||||
/>
|
||||
</p>
|
||||
)
|
||||
: null
|
||||
}
|
||||
</svg>
|
||||
<FormattedDate
|
||||
value={intId.registeredOn}
|
||||
month="short"
|
||||
day="numeric"
|
||||
hour="2-digit"
|
||||
minute="2-digit"
|
||||
second="2-digit"
|
||||
/>
|
||||
</p>
|
||||
{ intId.leftOn > 0
|
||||
? (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<FormattedDate
|
||||
value={intId.leftOn}
|
||||
month="short"
|
||||
day="numeric"
|
||||
hour="2-digit"
|
||||
minute="2-digit"
|
||||
second="2-digit"
|
||||
/>
|
||||
</p>
|
||||
)
|
||||
: null }
|
||||
{ index === Object.values(user.intIds).length - 1
|
||||
? null
|
||||
: (
|
||||
<hr className="my-1" />
|
||||
) }
|
||||
</>
|
||||
)) }
|
||||
</div>
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-sm text-center items-center ${opacity}`}>
|
||||
@ -223,23 +230,34 @@ class UsersTable extends React.Component {
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{ tsToHHmmss(
|
||||
(user.leftOn > 0
|
||||
? user.leftOn
|
||||
: (new Date()).getTime()) - user.registeredOn,
|
||||
) }
|
||||
{ tsToHHmmss(Object.values(user.intIds).reduce((prev, intId) => (
|
||||
prev + ((intId.leftOn > 0
|
||||
? intId.leftOn
|
||||
: (new Date()).getTime()) - intId.registeredOn)
|
||||
), 0)) }
|
||||
<br />
|
||||
<div
|
||||
className="bg-gray-200 transition-colors duration-500 rounded-full overflow-hidden"
|
||||
title={`${getOnlinePercentage(user.registeredOn, user.leftOn).toString()}%`}
|
||||
>
|
||||
<div
|
||||
aria-label=" "
|
||||
className="bg-gradient-to-br from-green-100 to-green-600 transition-colors duration-900 h-1.5"
|
||||
style={{ width: `${getOnlinePercentage(user.registeredOn, user.leftOn).toString()}%` }}
|
||||
role="progressbar"
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
(function getPercentage() {
|
||||
const { intIds } = user;
|
||||
const percentage = Object.values(intIds || {}).reduce((prev, intId) => (
|
||||
prev + getOnlinePercentage(intId.registeredOn, intId.leftOn)
|
||||
), 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-gray-200 transition-colors duration-500 rounded-full overflow-hidden"
|
||||
title={`${percentage.toString()}%`}
|
||||
>
|
||||
<div
|
||||
aria-label=" "
|
||||
className="bg-gradient-to-br from-green-100 to-green-600 transition-colors duration-900 h-1.5"
|
||||
style={{ width: `${percentage.toString()}%` }}
|
||||
role="progressbar"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}())
|
||||
}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-sm text-center items-center ${opacity}`}>
|
||||
{ user.talk.totalTime > 0
|
||||
@ -312,11 +330,11 @@ class UsersTable extends React.Component {
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-sm col-text-left ${opacity}`}>
|
||||
{
|
||||
Object.keys(usersEmojisSummary[user.intId] || {}).map((emoji) => (
|
||||
Object.keys(usersEmojisSummary[user.userKey] || {}).map((emoji) => (
|
||||
<div className="text-xs whitespace-nowrap">
|
||||
<i className={`${emojiConfigs[emoji].icon} text-sm`} />
|
||||
|
||||
{ usersEmojisSummary[user.intId][emoji] }
|
||||
{ usersEmojisSummary[user.userKey][emoji] }
|
||||
|
||||
<FormattedMessage
|
||||
id={emojiConfigs[emoji].intlId}
|
||||
@ -353,23 +371,23 @@ class UsersTable extends React.Component {
|
||||
!user.isModerator ? (
|
||||
<td className={`px-4 py-3 text-sm text-center items ${opacity}`}>
|
||||
<svg viewBox="0 0 82 12" width="82" height="12" className="flex-none m-auto inline">
|
||||
<rect width="12" height="12" fill={usersActivityScore[user.intId] > 0 ? '#A7F3D0' : '#e4e4e7'} />
|
||||
<rect width="12" height="12" x="14" fill={usersActivityScore[user.intId] > 2 ? '#6EE7B7' : '#e4e4e7'} />
|
||||
<rect width="12" height="12" x="28" fill={usersActivityScore[user.intId] > 4 ? '#34D399' : '#e4e4e7'} />
|
||||
<rect width="12" height="12" x="42" fill={usersActivityScore[user.intId] > 6 ? '#10B981' : '#e4e4e7'} />
|
||||
<rect width="12" height="12" x="56" fill={usersActivityScore[user.intId] > 8 ? '#059669' : '#e4e4e7'} />
|
||||
<rect width="12" height="12" x="70" fill={usersActivityScore[user.intId] === 10 ? '#047857' : '#e4e4e7'} />
|
||||
<rect width="12" height="12" fill={usersActivityScore[user.userKey] > 0 ? '#A7F3D0' : '#e4e4e7'} />
|
||||
<rect width="12" height="12" x="14" fill={usersActivityScore[user.userKey] > 2 ? '#6EE7B7' : '#e4e4e7'} />
|
||||
<rect width="12" height="12" x="28" fill={usersActivityScore[user.userKey] > 4 ? '#34D399' : '#e4e4e7'} />
|
||||
<rect width="12" height="12" x="42" fill={usersActivityScore[user.userKey] > 6 ? '#10B981' : '#e4e4e7'} />
|
||||
<rect width="12" height="12" x="56" fill={usersActivityScore[user.userKey] > 8 ? '#059669' : '#e4e4e7'} />
|
||||
<rect width="12" height="12" x="70" fill={usersActivityScore[user.userKey] === 10 ? '#047857' : '#e4e4e7'} />
|
||||
</svg>
|
||||
|
||||
<span className="text-xs bg-gray-200 rounded-full px-2">
|
||||
<FormattedNumber value={usersActivityScore[user.intId]} minimumFractionDigits="0" maximumFractionDigits="1" />
|
||||
<FormattedNumber value={usersActivityScore[user.userKey]} minimumFractionDigits="0" maximumFractionDigits="1" />
|
||||
</span>
|
||||
</td>
|
||||
) : <td />
|
||||
}
|
||||
<td className="px-4 py-3 text-xs text-center">
|
||||
<td className="px-3.5 2xl:px-4 py-3 text-xs text-center">
|
||||
{
|
||||
user.leftOn > 0
|
||||
Object.values(user.intIds)[Object.values(user.intIds).length - 1].leftOn > 0
|
||||
? (
|
||||
<span className="px-2 py-1 font-semibold leading-tight text-red-700 bg-red-100 rounded-full">
|
||||
<FormattedMessage id="app.learningDashboard.usersTable.userStatusOffline" defaultMessage="Offline" />
|
||||
@ -387,7 +405,7 @@ class UsersTable extends React.Component {
|
||||
})
|
||||
) : (
|
||||
<tr className="text-gray-700">
|
||||
<td colSpan="8" className="px-4 py-3 text-sm text-center">
|
||||
<td colSpan="8" className="px-3.5 2xl:px-4 py-3 text-sm text-center">
|
||||
<FormattedMessage id="app.learningDashboard.usersTable.noUsers" defaultMessage="No users" />
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -165,7 +165,7 @@ export function makeUserCSVData(users, polls, intl) {
|
||||
.values(userData)
|
||||
.map((data) => `"${data}"`);
|
||||
|
||||
userRecords[user.intId] = userFields.join(',');
|
||||
userRecords[user.userKey] = userFields.join(',');
|
||||
}
|
||||
|
||||
const tableHeaderFieldsTranslated = tableHeaderFields
|
||||
|
@ -48,6 +48,8 @@ FILE_SUDOERS_CHECK=`[ -f /etc/sudoers.d/zzz-bbb-docker-libreoffice ] && echo 1 |
|
||||
if [ "$FILE_SUDOERS_CHECK" = "0" ]; then
|
||||
echo "Sudoers file doesn't exists, installing"
|
||||
cp assets/zzz-bbb-docker-libreoffice /etc/sudoers.d/zzz-bbb-docker-libreoffice
|
||||
chmod 0440 /etc/sudoers.d/zzz-bbb-docker-libreoffice
|
||||
chown root:root /etc/sudoers.d/zzz-bbb-docker-libreoffice
|
||||
else
|
||||
echo "Sudoers file already exists"
|
||||
fi;
|
||||
|
@ -1 +1 @@
|
||||
git clone --branch v2.6.0-beta.10 --depth 1 https://github.com/bigbluebutton/bbb-webrtc-sfu bbb-webrtc-sfu
|
||||
git clone --branch v2.6.4 --depth 1 https://github.com/bigbluebutton/bbb-webrtc-sfu bbb-webrtc-sfu
|
||||
|
@ -1 +1,2 @@
|
||||
BIGBLUEBUTTON_RELEASE=2.5-alpha-1
|
||||
|
||||
|
@ -1663,22 +1663,14 @@ if [ -n "$HOST" ]; then
|
||||
for metadata in $(find /var/bigbluebutton/published /var/bigbluebutton/unpublished -name metadata.xml); do
|
||||
echo -n "."
|
||||
# Ensure we update both types of URLs
|
||||
sed -i "/<link>/{s/http:\/\/\([^\"\/]*\)\/playback\/$type\([^<]\)/http:\/\/$HOST\/playback\/$type\2/g}" $metadata
|
||||
sed -i "/<link>/{s/https:\/\/\([^\"\/]*\)\/playback\/$type\([^<]\)/https:\/\/$HOST\/playback\/$type\2/g}" $metadata
|
||||
|
||||
sed -i "/<link>/{s/http:\/\/\([^\"\/]*\)\/playback\/$type\([^<]\)/http:\/\/$HOST\/playback\/$type\2/g}" $metadata
|
||||
sed -i "/<link>/{s/https:\/\/\([^\"\/]*\)\/playback\/$type\([^<]\)/https:\/\/$HOST\/playback\/$type\2/g}" $metadata
|
||||
sed -i "/<link>/{s/http:\/\/\([^\"\/]*\)\/podcast\/$type\([^<]\)/http:\/\/$HOST\/podcast\/$type\2/g}" $metadata
|
||||
sed -i "/<link>/{s/https:\/\/\([^\"\/]*\)\/podcast\/$type\([^<]\)/https:\/\/$HOST\/podcast\/$type\2/g}" $metadata
|
||||
sed -i "/<link>/{s/http:\/\/\([^\"\/]*\)\/notes\/$type\([^<]\)/http:\/\/$HOST\/notes\/$type\2/g}" $metadata
|
||||
sed -i "/<link>/{s/https:\/\/\([^\"\/]*\)\/notes\/$type\([^<]\)/https:\/\/$HOST\/notes\/$type\2/g}" $metadata
|
||||
sed -i "/<link>/{s/http:\/\/\([^\"\/]*\)\/recording\/$type\([^<]\)/htts:\/\/$HOST\/recording\/$type\2/g}" $metadata
|
||||
sed -i "/<link>/{s/https:\/\/\([^\"\/]*\)\/recording\/$type\([^<]\)/https:\/\/$HOST\/recording\/$type\2/g}" $metadata
|
||||
xmlstarlet edit --inplace --update '//link[starts-with(normalize-space(), "https://")]' --expr "concat(\"https://\", \"$HOST/\", substring-after(substring-after(., \"https://\"),\"/\"))" $metadata
|
||||
xmlstarlet edit --inplace --update '//link[starts-with(normalize-space(), "http://")]' --expr "concat(\"http://\", \"$HOST/\", substring-after(substring-after(., \"http://\"),\"/\"))" $metadata
|
||||
|
||||
#
|
||||
# Update thumbnail links
|
||||
#
|
||||
sed -i "s/<image width=\"\([0-9]*\)\" height=\"\([0-9]*\)\" alt=\"\([^\"]*\)\">\(http[s]*\):\/\/[^\/]*\/\(.*\)/<image width=\"\1\" height=\"\2\" alt=\"\3\">\4:\/\/$HOST\/\5/g" $metadata
|
||||
xmlstarlet edit --inplace --update '//images/image[starts-with(normalize-space(), "https://")]' --expr "concat(\"https://\", \"$HOST/\", substring-after(substring-after(., \"https://\"),\"/\"))" $metadata
|
||||
xmlstarlet edit --inplace --update '//images/image[starts-with(normalize-space(), "http://")]' --expr "concat(\"http://\", \"$HOST/\", substring-after(substring-after(., \"http://\"),\"/\"))" $metadata
|
||||
done
|
||||
echo
|
||||
|
||||
|
@ -35,6 +35,10 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>.
|
||||
background-color: #06172A;
|
||||
}
|
||||
|
||||
:-webkit-full-screen {
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
@ -37,8 +37,10 @@ import collectionMirrorInitializer from './collection-mirror-initializer';
|
||||
import('/imports/api/audio/client/bridge/bridge-whitelist').catch(() => {
|
||||
// bridge loading
|
||||
});
|
||||
|
||||
collectionMirrorInitializer();
|
||||
liveDataEventBrokerInitializer();
|
||||
|
||||
Meteor.startup(() => {
|
||||
// Logs all uncaught exceptions to the client logger
|
||||
window.addEventListener('error', (e) => {
|
||||
|
326
bigbluebutton-html5/imports/api/audio/client/bridge/FullAudioBridge.js
Executable file
326
bigbluebutton-html5/imports/api/audio/client/bridge/FullAudioBridge.js
Executable file
@ -0,0 +1,326 @@
|
||||
import BaseAudioBridge from './base';
|
||||
import Auth from '/imports/ui/services/auth';
|
||||
import logger from '/imports/startup/client/logger';
|
||||
import FullAudioBroker from '/imports/ui/services/bbb-webrtc-sfu/fullaudio-broker';
|
||||
import loadAndPlayMediaStream from '/imports/ui/services/bbb-webrtc-sfu/load-play';
|
||||
import {
|
||||
fetchWebRTCMappedStunTurnServers,
|
||||
getMappedFallbackStun,
|
||||
} from '/imports/utils/fetchStunTurnServers';
|
||||
import getFromMeetingSettings from '/imports/ui/services/meeting-settings';
|
||||
|
||||
const SFU_URL = Meteor.settings.public.kurento.wsUrl;
|
||||
const MEDIA = Meteor.settings.public.media;
|
||||
const DEFAULT_FULLAUDIO_MEDIA_SERVER = MEDIA.audio.fullAudioMediaServer;
|
||||
const MEDIA_TAG = MEDIA.mediaTag.replace(/#/g, '');
|
||||
const GLOBAL_AUDIO_PREFIX = 'GLOBAL_AUDIO_';
|
||||
const RECONNECT_TIMEOUT_MS = MEDIA.listenOnlyCallTimeout || 15000;
|
||||
const OFFERING = MEDIA.listenOnlyOffering;
|
||||
const SENDRECV_ROLE = 'sendrecv';
|
||||
const RECV_ROLE = 'recv';
|
||||
const BRIDGE_NAME = 'fullaudio';
|
||||
const AUDIO_SESSION_NUM_KEY = 'AudioSessionNumber';
|
||||
|
||||
// SFU's base broker has distinct error codes so that it can be reused by different
|
||||
// modules. Errors that have a valid, localized counterpart in audio manager are
|
||||
// mapped so that the user gets a localized error message.
|
||||
// The ones that haven't (ie SFU's servers-side errors), aren't mapped.
|
||||
const errorCodeMap = {
|
||||
1301: 1001,
|
||||
1302: 1002,
|
||||
1305: 1005,
|
||||
1307: 1007,
|
||||
};
|
||||
|
||||
const mapErrorCode = (error) => {
|
||||
const { errorCode } = error;
|
||||
const mappedErrorCode = errorCodeMap[errorCode];
|
||||
if (errorCode == null || mappedErrorCode == null) return error;
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
error.errorCode = mappedErrorCode;
|
||||
return error;
|
||||
};
|
||||
|
||||
const getMediaServerAdapter = () => getFromMeetingSettings(
|
||||
'media-server-fullaudio',
|
||||
DEFAULT_FULLAUDIO_MEDIA_SERVER,
|
||||
);
|
||||
|
||||
const getAudioSessionNumber = () => {
|
||||
let currItem = parseInt(sessionStorage.getItem(AUDIO_SESSION_NUM_KEY), 10);
|
||||
if (!currItem) {
|
||||
currItem = 0;
|
||||
}
|
||||
|
||||
currItem += 1;
|
||||
sessionStorage.setItem(AUDIO_SESSION_NUM_KEY, currItem);
|
||||
return currItem;
|
||||
};
|
||||
|
||||
export default class FullAudioBridge extends BaseAudioBridge {
|
||||
constructor(userData) {
|
||||
super();
|
||||
this.internalMeetingID = userData.meetingId;
|
||||
this.voiceBridge = userData.voiceBridge;
|
||||
this.userId = userData.userId;
|
||||
this.name = userData.username;
|
||||
this.sessionToken = userData.sessionToken;
|
||||
this.media = {
|
||||
inputDevice: {},
|
||||
};
|
||||
this.broker = null;
|
||||
this.reconnecting = false;
|
||||
this.iceServers = [];
|
||||
}
|
||||
|
||||
async changeOutputDevice(value) {
|
||||
const audioContext = document.querySelector(`#${MEDIA_TAG}`);
|
||||
if (audioContext.setSinkId) {
|
||||
try {
|
||||
await audioContext.setSinkId(value);
|
||||
this.media.outputDeviceId = value;
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
logCode: 'fullaudio_changeoutputdevice_error',
|
||||
extraInfo: { error, bridge: BRIDGE_NAME },
|
||||
}, 'Audio bridge failed to change output device');
|
||||
throw new Error(this.baseErrorCodes.MEDIA_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
return this.media.outputDeviceId || value;
|
||||
}
|
||||
|
||||
getPeerConnection() {
|
||||
if (!this.broker) return null;
|
||||
|
||||
const { webRtcPeer } = this.broker;
|
||||
if (webRtcPeer) return webRtcPeer.peerConnection;
|
||||
return null;
|
||||
}
|
||||
|
||||
handleTermination() {
|
||||
return this.callback({ status: this.baseCallStates.ended, bridge: BRIDGE_NAME });
|
||||
}
|
||||
|
||||
clearReconnectionTimeout() {
|
||||
this.reconnecting = false;
|
||||
if (this.reconnectionTimeout) {
|
||||
clearTimeout(this.reconnectionTimeout);
|
||||
this.reconnectionTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
reconnect() {
|
||||
this.broker.stop();
|
||||
this.callback({ status: this.baseCallStates.reconnecting, bridge: BRIDGE_NAME });
|
||||
this.reconnecting = true;
|
||||
// Set up a reconnectionTimeout in case the server is unresponsive
|
||||
// for some reason. If it gets triggered, end the session and stop
|
||||
// trying to reconnect
|
||||
this.reconnectionTimeout = setTimeout(() => {
|
||||
this.callback({
|
||||
status: this.baseCallStates.failed,
|
||||
error: 1010,
|
||||
bridgeError: 'Reconnection timeout',
|
||||
bridge: BRIDGE_NAME,
|
||||
});
|
||||
this.broker.stop();
|
||||
this.clearReconnectionTimeout();
|
||||
}, RECONNECT_TIMEOUT_MS);
|
||||
|
||||
this.joinAudio({ isListenOnly: this.isListenOnly }, this.callback).then(
|
||||
() => this.clearReconnectionTimeout(),
|
||||
).catch((error) => {
|
||||
// Error handling is a no-op because it will be "handled" in handleBrokerFailure
|
||||
logger.debug({
|
||||
logCode: 'fullaudio_reconnect_failed',
|
||||
extraInfo: {
|
||||
errorMessage: error.errorMessage,
|
||||
reconnecting: this.reconnecting,
|
||||
bridge: BRIDGE_NAME,
|
||||
},
|
||||
}, 'Fullaudio reconnect failed');
|
||||
});
|
||||
}
|
||||
|
||||
handleBrokerFailure(error) {
|
||||
return new Promise((resolve, reject) => {
|
||||
mapErrorCode(error);
|
||||
const { errorMessage, errorCause, errorCode } = error;
|
||||
|
||||
if (this.broker.started && !this.reconnecting) {
|
||||
logger.error({
|
||||
logCode: 'fullaudio_error_try_to_reconnect',
|
||||
extraInfo: {
|
||||
errorMessage,
|
||||
errorCode,
|
||||
errorCause,
|
||||
bridge: BRIDGE_NAME,
|
||||
},
|
||||
}, 'Fullaudio failed, try to reconnect');
|
||||
this.reconnect();
|
||||
return resolve();
|
||||
}
|
||||
|
||||
// Already tried reconnecting once OR the user handn't succesfully
|
||||
// connected firsthand. Just finish the session and reject with error
|
||||
logger.error({
|
||||
logCode: 'fullaudio_error',
|
||||
extraInfo: {
|
||||
errorMessage,
|
||||
errorCode,
|
||||
errorCause,
|
||||
reconnecting: this.reconnecting,
|
||||
bridge: BRIDGE_NAME,
|
||||
},
|
||||
}, 'Fullaudio failed');
|
||||
this.clearReconnectionTimeout();
|
||||
this.broker.stop();
|
||||
this.callback({
|
||||
status: this.baseCallStates.failed,
|
||||
error: errorCode,
|
||||
bridgeError: errorMessage,
|
||||
bridge: BRIDGE_NAME,
|
||||
});
|
||||
return reject(error);
|
||||
});
|
||||
}
|
||||
|
||||
dispatchAutoplayHandlingEvent(mediaElement) {
|
||||
const tagFailedEvent = new CustomEvent('audioPlayFailed', {
|
||||
detail: { mediaElement },
|
||||
});
|
||||
window.dispatchEvent(tagFailedEvent);
|
||||
this.callback({ status: this.baseCallStates.autoplayBlocked, bridge: BRIDGE_NAME });
|
||||
}
|
||||
|
||||
handleStart() {
|
||||
const stream = this.broker.webRtcPeer.getRemoteStream();
|
||||
const mediaElement = document.getElementById(MEDIA_TAG);
|
||||
|
||||
return loadAndPlayMediaStream(stream, mediaElement, false).then(() => this
|
||||
.callback({
|
||||
status: this.baseCallStates.started,
|
||||
bridge: BRIDGE_NAME,
|
||||
})).catch((error) => {
|
||||
// NotAllowedError equals autoplay issues, fire autoplay handling event.
|
||||
// This will be handled in audio-manager.
|
||||
if (error.name === 'NotAllowedError') {
|
||||
logger.error({
|
||||
logCode: 'fullaudio_error_autoplay',
|
||||
extraInfo: { errorName: error.name, bridge: BRIDGE_NAME },
|
||||
}, 'Fullaudio media play failed due to autoplay error');
|
||||
this.dispatchAutoplayHandlingEvent(mediaElement);
|
||||
} else {
|
||||
const normalizedError = {
|
||||
errorCode: 1004,
|
||||
errorMessage: error.message || 'AUDIO_PLAY_FAILED',
|
||||
};
|
||||
this.callback({
|
||||
status: this.baseCallStates.failed,
|
||||
error: normalizedError.errorCode,
|
||||
bridgeError: normalizedError.errorMessage,
|
||||
bridge: BRIDGE_NAME,
|
||||
});
|
||||
throw normalizedError;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async _initBrokerEventsPromise() {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
if (!this.broker) resolve(null);
|
||||
|
||||
this.broker.onended = this.handleTermination.bind(this);
|
||||
|
||||
this.broker.onerror = (error) => {
|
||||
this.handleBrokerFailure(error).catch(reject);
|
||||
};
|
||||
|
||||
this.broker.onstart = () => {
|
||||
this.handleStart().then(resolve).catch(reject);
|
||||
};
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async _startBroker(_options) {
|
||||
try {
|
||||
const { isListenOnly } = _options;
|
||||
this.isListenOnly = isListenOnly;
|
||||
|
||||
const callerIdName = [
|
||||
`${this.userId}_${getAudioSessionNumber()}`,
|
||||
'bbbID',
|
||||
isListenOnly ? `${GLOBAL_AUDIO_PREFIX}${this.voiceBridge}` : this.name,
|
||||
].join('-').replace(/"/g, "'");
|
||||
|
||||
const options = {
|
||||
userName: this.name,
|
||||
caleeName: callerIdName,
|
||||
iceServers: this.iceServers,
|
||||
offering: OFFERING,
|
||||
mediaServer: getMediaServerAdapter(),
|
||||
};
|
||||
|
||||
this.broker = new FullAudioBroker(
|
||||
Auth.authenticateURL(SFU_URL),
|
||||
this.voiceBridge,
|
||||
this.userId,
|
||||
this.internalMeetingID,
|
||||
isListenOnly ? RECV_ROLE : SENDRECV_ROLE,
|
||||
options,
|
||||
);
|
||||
|
||||
const initBrokerEventsPromise = this._initBrokerEventsPromise();
|
||||
|
||||
this.broker.listen();
|
||||
|
||||
return initBrokerEventsPromise;
|
||||
} catch (error) {
|
||||
logger.warn({ logCode: 'fullaudio_bridge_broker_init_fail' },
|
||||
'Problem when initializing SFU broker for fullaudio bridge');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async joinAudio(options, callback) {
|
||||
try {
|
||||
this.callback = callback;
|
||||
|
||||
this.iceServers = await fetchWebRTCMappedStunTurnServers(this.sessionToken);
|
||||
} catch (error) {
|
||||
logger.error({ logCode: 'fullaudio_stun-turn_fetch_failed' },
|
||||
'SFU audio bridge failed to fetch STUN/TURN info, using default servers');
|
||||
this.iceServers = getMappedFallbackStun();
|
||||
} finally {
|
||||
await this._startBroker(options);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
async updateAudioConstraints() {
|
||||
// TO BE IMPLEMENTED
|
||||
return true;
|
||||
}
|
||||
|
||||
exitAudio() {
|
||||
const mediaElement = document.getElementById(MEDIA_TAG);
|
||||
|
||||
this.broker.stop();
|
||||
this.clearReconnectionTimeout();
|
||||
|
||||
if (mediaElement && typeof mediaElement.pause === 'function') {
|
||||
mediaElement.pause();
|
||||
mediaElement.srcObject = null;
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FullAudioBridge;
|
@ -14,3 +14,4 @@ throw new Error();
|
||||
|
||||
/* eslint-disable no-unreachable */
|
||||
// BRIDGES LIST
|
||||
import('/imports/api/audio/client/bridge/FullAudioBridge'); // NOSONAR
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
getMappedFallbackStun
|
||||
} from '/imports/utils/fetchStunTurnServers';
|
||||
import getFromMeetingSettings from '/imports/ui/services/meeting-settings';
|
||||
import { shouldForceRelay } from '/imports/ui/services/bbb-webrtc-sfu/utils';
|
||||
|
||||
const SFU_URL = Meteor.settings.public.kurento.wsUrl;
|
||||
const DEFAULT_LISTENONLY_MEDIA_SERVER = Meteor.settings.public.kurento.listenOnlyMediaServer;
|
||||
@ -267,6 +268,7 @@ export default class KurentoAudioBridge extends BaseAudioBridge {
|
||||
offering: OFFERING,
|
||||
mediaServer: getMediaServerAdapter(),
|
||||
signalCandidates: SIGNAL_CANDIDATES,
|
||||
forceRelay: shouldForceRelay(),
|
||||
};
|
||||
|
||||
this.broker = new ListenOnlyBroker(
|
||||
|
@ -1304,7 +1304,11 @@ export default class SIPBridge extends BaseAudioBridge {
|
||||
};
|
||||
|
||||
this.protocol = window.document.location.protocol;
|
||||
this.hostname = window.document.location.hostname;
|
||||
if (MEDIA['sip_ws_host'] != null && MEDIA['sip_ws_host'] != '') {
|
||||
this.hostname = MEDIA.sip_ws_host;
|
||||
} else {
|
||||
this.hostname = window.document.location.hostname;
|
||||
}
|
||||
|
||||
// SDP conversion utilitary methods to be used inside SIP.js
|
||||
window.isUnifiedPlan = isUnifiedPlan;
|
||||
|
@ -17,6 +17,7 @@ export default function upsertValidationState(meetingId, userId, validationStatu
|
||||
};
|
||||
|
||||
try {
|
||||
AuthTokenValidation.remove({ meetingId, userId, connectionId: { $ne: connectionId } });
|
||||
const { numberAffected } = AuthTokenValidation.upsert(selector, modifier);
|
||||
|
||||
if (numberAffected) {
|
||||
|
@ -1,11 +1,33 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { check } from 'meteor/check';
|
||||
import _ from 'lodash';
|
||||
import AuthTokenValidation from '/imports/api/auth-token-validation';
|
||||
import { extractCredentials } from '/imports/api/common/server/helpers';
|
||||
import Logger from '/imports/startup/server/logger';
|
||||
|
||||
function authTokenValidation({ meetingId, userId }) {
|
||||
async function authTokenValidation({ meetingId, userId }) {
|
||||
check(meetingId, String);
|
||||
check(userId, String);
|
||||
|
||||
const credentials = await new Promise((resp)=> {
|
||||
const tempSettimeout = () => {
|
||||
setTimeout(() => {
|
||||
const cred = extractCredentials(this.userId);
|
||||
const objIsEmpty = _.isEmpty(cred);
|
||||
if (objIsEmpty) {
|
||||
return tempSettimeout();
|
||||
}
|
||||
return resp(cred);
|
||||
}, 200);
|
||||
};
|
||||
tempSettimeout();
|
||||
});
|
||||
|
||||
const { meetingId: mId, requesterUserId } = credentials;
|
||||
|
||||
const selector = {
|
||||
meetingId,
|
||||
userId,
|
||||
meetingId: mId,
|
||||
userId: requesterUserId,
|
||||
};
|
||||
|
||||
Logger.debug(`Publishing auth-token-validation for ${meetingId} ${userId}`);
|
||||
|
@ -30,7 +30,11 @@ export function addExternalVideoStreamer(meetingId) {
|
||||
if (!Meteor.StreamerCentral.instances[streamName]) {
|
||||
|
||||
const streamer = new Meteor.Streamer(streamName);
|
||||
streamer.allowRead('all');
|
||||
streamer.allowRead(function allowRead() {
|
||||
if (!this.userId) return false;
|
||||
|
||||
return this.userId && this.userId.includes(meetingId);
|
||||
});
|
||||
streamer.allowWrite('none');
|
||||
streamer.allowEmit(allowRecentMessages);
|
||||
Logger.info(`Created External Video streamer for ${streamName}`);
|
||||
|
@ -2,6 +2,7 @@ import { Match, check } from 'meteor/check';
|
||||
import Logger from '/imports/startup/server/logger';
|
||||
import { GroupChatMsg } from '/imports/api/group-chat-msg';
|
||||
import { BREAK_LINE } from '/imports/utils/lineEndings';
|
||||
import changeHasMessages from '/imports/api/users/server/modifiers/changeHasMessages';
|
||||
|
||||
export function parseMessage(message) {
|
||||
let parsedMessage = message || '';
|
||||
@ -45,6 +46,8 @@ export default function addGroupChatMsg(meetingId, chatId, msg) {
|
||||
|
||||
if (insertedId) {
|
||||
Logger.info(`Added group-chat-msg msgId=${msg.id} chatId=${chatId} meetingId=${meetingId}`);
|
||||
|
||||
changeHasMessages(true, sender.id, meetingId);
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.error(`Error on adding group-chat-msg to collection: ${err}`);
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { GroupChatMsg, UsersTyping } from '/imports/api/group-chat-msg';
|
||||
import Users from '/imports/api/users';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
import { check } from 'meteor/check';
|
||||
import GroupChat from '/imports/api/group-chat';
|
||||
import Logger from '/imports/startup/server/logger';
|
||||
import AuthTokenValidation, { ValidationStates } from '/imports/api/auth-token-validation';
|
||||
|
||||
function groupChatMsg(chatsIds) {
|
||||
function groupChatMsg(chatCount) {
|
||||
check(chatCount, Number);
|
||||
const tokenValidation = AuthTokenValidation.findOne({ connectionId: this.connection.id });
|
||||
|
||||
if (!tokenValidation || tokenValidation.validationStatus !== ValidationStates.VALIDATED) {
|
||||
@ -20,6 +22,14 @@ function groupChatMsg(chatsIds) {
|
||||
|
||||
Logger.debug('Publishing group-chat-msg', { meetingId, userId });
|
||||
|
||||
const chats = GroupChat.find({
|
||||
$or: [
|
||||
{ meetingId, users: { $all: [userId] } },
|
||||
],
|
||||
}).fetch();
|
||||
|
||||
const chatsIds = chats.map((ct) => ct.chatId);
|
||||
|
||||
const User = Users.findOne({ userId, meetingId });
|
||||
const selector = {
|
||||
timestamp: { $gte: User.authTokenValidatedTime },
|
||||
|
@ -8,7 +8,7 @@ export default function randomlySelectedUser({ header, body }) {
|
||||
check(meetingId, String);
|
||||
check(requestedBy, String);
|
||||
check(userIds, Array);
|
||||
check(choice, Number);
|
||||
check(choice, String);
|
||||
|
||||
updateRandomViewer(meetingId, userIds, choice, requestedBy);
|
||||
}
|
||||
|
@ -55,7 +55,7 @@ const optionsFor3 = [
|
||||
export default function updateRandomUser(meetingId, userIds, choice, requesterId) {
|
||||
check(meetingId, String);
|
||||
check(userIds, Array);
|
||||
check(choice, Number);
|
||||
check(choice, String);
|
||||
check(requesterId, String);
|
||||
|
||||
let userList = [];
|
||||
@ -70,7 +70,7 @@ export default function updateRandomUser(meetingId, userIds, choice, requesterId
|
||||
|
||||
const chosenUser = userIds[choice];
|
||||
|
||||
if (choice < 0) { // no viewer
|
||||
if (choice == "") { // no viewer
|
||||
userList = [
|
||||
[requesterId, intervals[0]],
|
||||
[requesterId, 0],
|
||||
|
@ -18,6 +18,21 @@ export default function publishTypedVote(id, pollAnswer) {
|
||||
check(pollAnswer, String);
|
||||
check(id, String);
|
||||
|
||||
const allowedToVote = Polls.findOne({
|
||||
id,
|
||||
users: { $in: [requesterUserId] },
|
||||
meetingId,
|
||||
}, {
|
||||
fields: {
|
||||
users: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (!allowedToVote) {
|
||||
Logger.info(`Poll User={${requesterUserId}} has already voted in PollId={${id}}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const activePoll = Polls.findOne({ meetingId, id }, {
|
||||
fields: {
|
||||
answers: 1,
|
||||
|
@ -1,14 +1,19 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { check } from 'meteor/check';
|
||||
import Logger from '/imports/startup/server/logger';
|
||||
import Users from '/imports/api/users';
|
||||
import Polls from '/imports/api/polls';
|
||||
import AuthTokenValidation, {
|
||||
ValidationStates,
|
||||
} from '/imports/api/auth-token-validation';
|
||||
import { DDPServer } from 'meteor/ddp-server';
|
||||
|
||||
Meteor.server.setPublicationStrategy('polls', DDPServer.publicationStrategies.NO_MERGE);
|
||||
|
||||
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
|
||||
|
||||
function currentPoll(secretPoll) {
|
||||
check(secretPoll, Boolean);
|
||||
const tokenValidation = AuthTokenValidation.findOne({
|
||||
connectionId: this.connection.id,
|
||||
});
|
||||
@ -82,7 +87,16 @@ function polls() {
|
||||
},
|
||||
};
|
||||
|
||||
const noKeyOptions = {
|
||||
fields: {
|
||||
'answers.numVotes': 0,
|
||||
'answers.key': 0,
|
||||
responses: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const { meetingId, userId } = tokenValidation;
|
||||
const User = Users.findOne({ userId, meetingId }, { fields: { role: 1, presenter: 1 } });
|
||||
|
||||
Logger.debug('Publishing polls', { meetingId, userId });
|
||||
|
||||
@ -91,7 +105,15 @@ function polls() {
|
||||
users: userId,
|
||||
};
|
||||
|
||||
return Polls.find(selector, options);
|
||||
if (User) {
|
||||
const poll = Polls.findOne(selector, noKeyOptions);
|
||||
|
||||
if (User.role === ROLE_MODERATOR || poll?.pollType !== 'R-') {
|
||||
return Polls.find(selector, options);
|
||||
}
|
||||
}
|
||||
|
||||
return Polls.find(selector, noKeyOptions);
|
||||
}
|
||||
|
||||
function publish(...args) {
|
||||
|
@ -4,6 +4,7 @@ import BridgeService from './service';
|
||||
import ScreenshareBroker from '/imports/ui/services/bbb-webrtc-sfu/screenshare-broker';
|
||||
import { setSharingScreen, screenShareEndAlert } from '/imports/ui/components/screenshare/service';
|
||||
import { SCREENSHARING_ERRORS } from './errors';
|
||||
import { shouldForceRelay } from '/imports/ui/services/bbb-webrtc-sfu/utils';
|
||||
|
||||
const SFU_CONFIG = Meteor.settings.public.kurento;
|
||||
const SFU_URL = SFU_CONFIG.wsUrl;
|
||||
@ -227,6 +228,7 @@ export default class KurentoScreenshareBridge {
|
||||
offering: OFFERING,
|
||||
mediaServer: BridgeService.getMediaServerAdapter(),
|
||||
signalCandidates: SIGNAL_CANDIDATES,
|
||||
forceRelay: shouldForceRelay(),
|
||||
};
|
||||
|
||||
this.broker = new ScreenshareBroker(
|
||||
@ -287,6 +289,7 @@ export default class KurentoScreenshareBridge {
|
||||
offering: true,
|
||||
mediaServer: BridgeService.getMediaServerAdapter(),
|
||||
signalCandidates: SIGNAL_CANDIDATES,
|
||||
forceRelay: shouldForceRelay(),
|
||||
};
|
||||
|
||||
this.broker = new ScreenshareBroker(
|
||||
|
25
bigbluebutton-html5/imports/api/users/server/modifiers/changeHasMessages.js
Executable file
25
bigbluebutton-html5/imports/api/users/server/modifiers/changeHasMessages.js
Executable file
@ -0,0 +1,25 @@
|
||||
import Logger from '/imports/startup/server/logger';
|
||||
import Users from '/imports/api/users';
|
||||
|
||||
export default function changeHasMessages(hasMessages, userId, meetingId) {
|
||||
const selector = {
|
||||
meetingId,
|
||||
userId,
|
||||
};
|
||||
|
||||
const modifier = {
|
||||
$set: {
|
||||
hasMessages,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const numberAffected = Users.update(selector, modifier);
|
||||
|
||||
if (numberAffected) {
|
||||
Logger.info(`Changed hasMessages=${hasMessages} id=${userId} meeting=${meetingId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.error(`Change hasMessages error: ${err}`);
|
||||
}
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
import { check } from 'meteor/check';
|
||||
import Users from '/imports/api/users';
|
||||
import UsersPersistentData from '/imports/api/users-persistent-data';
|
||||
import VoiceUsers from '/imports/api/voice-users/';
|
||||
import VideoStreams from '/imports/api/video-streams';
|
||||
import Logger from '/imports/startup/server/logger';
|
||||
import setloggedOutStatus from '/imports/api/users-persistent-data/server/modifiers/setloggedOutStatus';
|
||||
@ -8,9 +10,13 @@ import ClientConnections from '/imports/startup/server/ClientConnections';
|
||||
|
||||
const clearAllSessions = (sessionUserId) => {
|
||||
const serverSessions = Meteor.server.sessions;
|
||||
Object.keys(serverSessions)
|
||||
.filter((i) => serverSessions[i].userId === sessionUserId)
|
||||
.forEach((i) => serverSessions[i].close());
|
||||
const interable = serverSessions.values();
|
||||
|
||||
for (const session of interable) {
|
||||
if (session.userId === sessionUserId) {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function removeUser(meetingId, userId) {
|
||||
@ -19,8 +25,8 @@ export default function removeUser(meetingId, userId) {
|
||||
|
||||
try {
|
||||
if (!process.env.BBB_HTML5_ROLE || process.env.BBB_HTML5_ROLE === 'frontend') {
|
||||
const sessionUserId = `${meetingId}-${userId}`;
|
||||
ClientConnections.removeClientConnection(`${meetingId}--${userId}`);
|
||||
const sessionUserId = `${meetingId}--${userId}`;
|
||||
ClientConnections.removeClientConnection(sessionUserId);
|
||||
clearAllSessions(sessionUserId);
|
||||
|
||||
// we don't want to fully process the redis message in frontend
|
||||
@ -40,7 +46,14 @@ export default function removeUser(meetingId, userId) {
|
||||
|
||||
clearUserInfoForRequester(meetingId, userId);
|
||||
|
||||
const currentUser = Users.findOne({ userId, meetingId });
|
||||
const hasMessages = currentUser?.hasMessages;
|
||||
|
||||
if (!hasMessages) {
|
||||
UsersPersistentData.remove(selector);
|
||||
}
|
||||
Users.remove(selector);
|
||||
VoiceUsers.remove({ intId: userId, meetingId })
|
||||
|
||||
Logger.info(`Removed user id=${userId} meeting=${meetingId}`);
|
||||
} catch (err) {
|
||||
|
@ -57,17 +57,6 @@ const fullscreenChangedEvents = [
|
||||
];
|
||||
|
||||
class Base extends Component {
|
||||
static handleFullscreenChange() {
|
||||
if (document.fullscreenElement
|
||||
|| document.webkitFullscreenElement
|
||||
|| document.mozFullScreenElement
|
||||
|| document.msFullscreenElement) {
|
||||
Session.set('isFullscreen', true);
|
||||
} else {
|
||||
Session.set('isFullscreen', false);
|
||||
}
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
@ -76,6 +65,27 @@ class Base extends Component {
|
||||
meetingExisted: false,
|
||||
};
|
||||
this.updateLoadingState = this.updateLoadingState.bind(this);
|
||||
this.handleFullscreenChange = this.handleFullscreenChange.bind(this);
|
||||
}
|
||||
|
||||
handleFullscreenChange() {
|
||||
const { layoutContextDispatch } = this.props;
|
||||
|
||||
if (document.fullscreenElement
|
||||
|| document.webkitFullscreenElement
|
||||
|| document.mozFullScreenElement
|
||||
|| document.msFullscreenElement) {
|
||||
Session.set('isFullscreen', true);
|
||||
} else {
|
||||
layoutContextDispatch({
|
||||
type: ACTIONS.SET_FULLSCREEN_ELEMENT,
|
||||
value: {
|
||||
element: '',
|
||||
group: '',
|
||||
},
|
||||
});
|
||||
Session.set('isFullscreen', false);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@ -94,7 +104,7 @@ class Base extends Component {
|
||||
if (!animations) HTML.classList.add('animationsDisabled');
|
||||
|
||||
fullscreenChangedEvents.forEach((event) => {
|
||||
document.addEventListener(event, Base.handleFullscreenChange);
|
||||
document.addEventListener(event, this.handleFullscreenChange);
|
||||
});
|
||||
Session.set('isFullscreen', false);
|
||||
|
||||
@ -293,7 +303,7 @@ class Base extends Component {
|
||||
|
||||
componentWillUnmount() {
|
||||
fullscreenChangedEvents.forEach((event) => {
|
||||
document.removeEventListener(event, Base.handleFullscreenChange);
|
||||
document.removeEventListener(event, this.handleFullscreenChange);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -10,6 +10,7 @@ import Logger from './logger';
|
||||
import Redis from './redis';
|
||||
|
||||
import setMinBrowserVersions from './minBrowserVersion';
|
||||
import { PrometheusAgent, METRIC_NAMES } from './prom-metrics/index.js'
|
||||
|
||||
let guestWaitHtml = '';
|
||||
|
||||
@ -140,6 +141,13 @@ Meteor.startup(() => {
|
||||
|
||||
setMinBrowserVersions();
|
||||
|
||||
Meteor.onMessage(event => {
|
||||
const { method } = event;
|
||||
if (method) {
|
||||
PrometheusAgent.increment(METRIC_NAMES.METEOR_METHODS, { methodName: method });
|
||||
}
|
||||
});
|
||||
|
||||
Logger.warn(`SERVER STARTED.
|
||||
ENV=${env}
|
||||
nodejs version=${process.version}
|
||||
@ -213,7 +221,7 @@ WebApp.connectHandlers.use('/locale', (req, res) => {
|
||||
|
||||
if (browserLocale.length > 1) {
|
||||
// browser asks for specific locale
|
||||
normalizedLocale = `${browserLocale[0]}_${browserLocale[1].toUpperCase()}`;
|
||||
normalizedLocale = `${browserLocale[0]}_${browserLocale[1]?.toUpperCase()}`;
|
||||
|
||||
const normDefault = usableLocales.find(locale => normalizedLocale === locale);
|
||||
if (normDefault) {
|
||||
|
@ -0,0 +1,35 @@
|
||||
import Agent from './promAgent.js';
|
||||
|
||||
import {
|
||||
METRICS_PREFIX,
|
||||
METRIC_NAMES,
|
||||
buildMetrics
|
||||
} from './metrics.js';
|
||||
|
||||
const {
|
||||
enabled: METRICS_ENABLED,
|
||||
path: METRICS_PATH,
|
||||
collectDefaultMetrics: COLLECT_DEFAULT_METRICS,
|
||||
} = Meteor.settings.private.prometheus
|
||||
? Meteor.settings.private.prometheus
|
||||
: { enabled: false };
|
||||
|
||||
const PrometheusAgent = new Agent({
|
||||
path: METRICS_PATH,
|
||||
prefix: METRICS_PREFIX,
|
||||
collectDefaultMetrics: COLLECT_DEFAULT_METRICS,
|
||||
role: process.env.BBB_HTML5_ROLE,
|
||||
instanceId: parseInt(process.env.INSTANCE_ID, 10) || 1,
|
||||
});
|
||||
|
||||
if (METRICS_ENABLED) {
|
||||
PrometheusAgent.injectMetrics(buildMetrics());
|
||||
PrometheusAgent.start();
|
||||
}
|
||||
|
||||
export {
|
||||
METRIC_NAMES,
|
||||
METRICS_PREFIX,
|
||||
Agent,
|
||||
PrometheusAgent,
|
||||
};
|
@ -0,0 +1,42 @@
|
||||
const {
|
||||
Counter,
|
||||
} = require('prom-client');
|
||||
|
||||
const METRICS_PREFIX = 'html5_'
|
||||
const METRIC_NAMES = {
|
||||
METEOR_METHODS: 'meteorMethods',
|
||||
}
|
||||
|
||||
const buildFrontendMetrics = () => {
|
||||
return {
|
||||
[METRIC_NAMES.METEOR_METHODS]: new Counter({
|
||||
name: `${METRICS_PREFIX}meteor_methods`,
|
||||
help: 'Total number of meteor methods processed in html5',
|
||||
labelNames: ['methodName', 'role', 'instanceId'],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const buildBackendMetrics = () => {
|
||||
// TODO add relevant backend metrics
|
||||
return {}
|
||||
}
|
||||
|
||||
let METRICS;
|
||||
const buildMetrics = () => {
|
||||
if (METRICS == null) {
|
||||
const isFrontend = (!process.env.BBB_HTML5_ROLE || process.env.BBB_HTML5_ROLE === 'frontend');
|
||||
const isBackend = (!process.env.BBB_HTML5_ROLE || process.env.BBB_HTML5_ROLE === 'backend');
|
||||
if (isFrontend) METRICS = buildFrontendMetrics();
|
||||
if (isBackend) METRICS = { ...METRICS, ...buildBackendMetrics()}
|
||||
}
|
||||
|
||||
return METRICS;
|
||||
};
|
||||
|
||||
export {
|
||||
METRICS_PREFIX,
|
||||
METRIC_NAMES,
|
||||
METRICS,
|
||||
buildMetrics,
|
||||
};
|
@ -0,0 +1,86 @@
|
||||
import {
|
||||
register,
|
||||
collectDefaultMetrics,
|
||||
} from 'prom-client';
|
||||
|
||||
import Logger from '../logger';
|
||||
const LOG_PREFIX = '[prom-scrape-agt]';
|
||||
|
||||
class PrometheusScrapeAgent {
|
||||
constructor(options) {
|
||||
this.metrics = {};
|
||||
this.started = false;
|
||||
|
||||
this.path = options.path || '/metrics';
|
||||
this.collectDefaultMetrics = options.collectDefaultMetrics || false;
|
||||
this.metricsPrefix = options.prefix || '';
|
||||
this.collectionTimeout = options.collectionTimeout || 10000;
|
||||
this.roleAndInstanceLabels =
|
||||
options.role
|
||||
? { role: options.role, instanceId: options.instanceId }
|
||||
: {};
|
||||
}
|
||||
|
||||
async collect(response) {
|
||||
try {
|
||||
response.writeHead(200, { 'Content-Type': register.contentType });
|
||||
const content = await register.metrics();
|
||||
response.end(content);
|
||||
Logger.debug(`${LOG_PREFIX} Collected prometheus metrics:\n${content}`);
|
||||
} catch (error) {
|
||||
response.writeHead(500)
|
||||
response.end(error.message);
|
||||
Logger.error(`${LOG_PREFIX} Collecting prometheus metrics: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.collectDefaultMetrics) collectDefaultMetrics({
|
||||
prefix: this.metricsPrefix,
|
||||
timeout: this.collectionTimeout,
|
||||
labels: this.roleAndInstanceLabels,
|
||||
});
|
||||
|
||||
WebApp.connectHandlers.use(this.path, (req, res) => {
|
||||
return this.collect(res);
|
||||
});
|
||||
|
||||
this.started = true;
|
||||
};
|
||||
|
||||
injectMetrics(metricsDictionary) {
|
||||
this.metrics = { ...this.metrics, ...metricsDictionary }
|
||||
}
|
||||
|
||||
increment(metricName, labelsObject) {
|
||||
if (!this.started) return;
|
||||
|
||||
const metric = this.metrics[metricName];
|
||||
if (metric) {
|
||||
labelsObject = { ...labelsObject, ...this.roleAndInstanceLabels };
|
||||
metric.inc(labelsObject)
|
||||
}
|
||||
}
|
||||
|
||||
decrement(metricName, labelsObject) {
|
||||
if (!this.started) return;
|
||||
|
||||
const metric = this.metrics[metricName];
|
||||
if (metric) {
|
||||
labelsObject = { ...labelsObject, ...this.roleAndInstanceLabels };
|
||||
metric.dec(labelsObject)
|
||||
}
|
||||
}
|
||||
|
||||
set(metricName, value, labelsObject) {
|
||||
if (!this.started) return;
|
||||
|
||||
const metric = this.metrics[metricName];
|
||||
if (metric) {
|
||||
labelsObject = { ...labelsObject, ...this.roleAndInstanceLabels };
|
||||
metric.set(labelsObject, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PrometheusScrapeAgent;
|
@ -7,7 +7,6 @@ import _ from 'lodash';
|
||||
const DEFAULT_SETTINGS_FILE_PATH = process.env.BBB_HTML5_SETTINGS || 'assets/app/config/settings.yml';
|
||||
const LOCAL_SETTINGS_FILE_PATH = process.env.BBB_HTML5_LOCAL_SETTINGS || '/etc/bigbluebutton/bbb-html5.yml';
|
||||
|
||||
|
||||
try {
|
||||
if (fs.existsSync(DEFAULT_SETTINGS_FILE_PATH)) {
|
||||
const SETTINGS = YAML.parse(fs.readFileSync(DEFAULT_SETTINGS_FILE_PATH, 'utf-8'));
|
||||
@ -15,13 +14,17 @@ try {
|
||||
if (fs.existsSync(LOCAL_SETTINGS_FILE_PATH)) {
|
||||
console.log('Local configuration found! Merging with default configuration...');
|
||||
const LOCAL_CONFIG = YAML.parse(fs.readFileSync(LOCAL_SETTINGS_FILE_PATH, 'utf-8'));
|
||||
_.merge(SETTINGS, LOCAL_CONFIG);
|
||||
_.mergeWith(SETTINGS, LOCAL_CONFIG, (a, b) => (_.isArray(b) ? b : undefined));
|
||||
} else console.log('Local Configuration not found! Loading default configuration...');
|
||||
|
||||
Meteor.settings = SETTINGS;
|
||||
Meteor.settings.public.app.instanceId = ''; // no longer use instanceId in URLs. Likely permanent change
|
||||
// Meteor.settings.public.app.instanceId = `/${INSTANCE_ID}`;
|
||||
|
||||
Meteor.settings.public.packages = {
|
||||
'dynamic-import': { useLocationOrigin: true },
|
||||
};
|
||||
|
||||
__meteor_runtime_config__.PUBLIC_SETTINGS = SETTINGS.public;
|
||||
} else {
|
||||
throw new Error('File doesn\'t exists');
|
||||
|
@ -73,7 +73,7 @@ const getAvailableQuickPolls = (
|
||||
itemLabel = options.join('/').replace(/[\n.)]/g, '');
|
||||
if (type === pollTypes.Custom) {
|
||||
for (let i = 0; i < options.length; i += 1) {
|
||||
const letterOption = options[i].replace(/[\r.)]/g, '').toUpperCase();
|
||||
const letterOption = options[i]?.replace(/[\r.)]/g, '').toUpperCase();
|
||||
if (letterAnswers.length < MAX_CUSTOM_FIELDS) {
|
||||
letterAnswers.push(letterOption);
|
||||
} else {
|
||||
@ -84,7 +84,7 @@ const getAvailableQuickPolls = (
|
||||
}
|
||||
|
||||
// removes any whitespace from the label
|
||||
itemLabel = itemLabel.replace(/\s+/g, '').toUpperCase();
|
||||
itemLabel = itemLabel?.replace(/\s+/g, '').toUpperCase();
|
||||
|
||||
const numChars = {
|
||||
1: 'A', 2: 'B', 3: 'C', 4: 'D', 5: 'E',
|
||||
|
@ -223,15 +223,15 @@ class App extends Component {
|
||||
currentUserEmoji,
|
||||
intl,
|
||||
hasPublishedPoll,
|
||||
randomlySelectedUser,
|
||||
mountModal,
|
||||
deviceType,
|
||||
isPresenter,
|
||||
meetingLayout,
|
||||
settingsLayout,
|
||||
selectedLayout, // full layout name
|
||||
settingsLayout, // shortened layout name (without Push)
|
||||
layoutType,
|
||||
pushLayoutToEveryone,
|
||||
pushLayoutToEveryone, // is layout pushed
|
||||
layoutContextDispatch,
|
||||
mountRandomUserModal,
|
||||
} = this.props;
|
||||
|
||||
if (meetingLayout !== prevProps.meetingLayout) {
|
||||
@ -244,7 +244,7 @@ class App extends Component {
|
||||
Settings.save();
|
||||
}
|
||||
|
||||
if (settingsLayout !== prevProps.settingsLayout
|
||||
if (selectedLayout !== prevProps.selectedLayout
|
||||
|| settingsLayout !== layoutType) {
|
||||
layoutContextDispatch({
|
||||
type: ACTIONS.SET_LAYOUT_TYPE,
|
||||
@ -256,7 +256,7 @@ class App extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPresenter && randomlySelectedUser.length > 0) mountModal(<RandomUserSelectContainer />);
|
||||
if (mountRandomUserModal) mountModal(<RandomUserSelectContainer />);
|
||||
|
||||
if (prevProps.currentUserEmoji.status !== currentUserEmoji.status) {
|
||||
const formattedEmojiStatus = intl.formatMessage({ id: `app.actionsBar.emojiMenu.${currentUserEmoji.status}Label` })
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { withTracker } from 'meteor/react-meteor-data';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import PropTypes from 'prop-types';
|
||||
@ -19,6 +19,7 @@ import {
|
||||
layoutSelectOutput,
|
||||
layoutDispatch,
|
||||
} from '../layout/context';
|
||||
import _ from 'lodash';
|
||||
|
||||
import {
|
||||
getFontSize,
|
||||
@ -26,7 +27,7 @@ import {
|
||||
validIOSVersion,
|
||||
} from './service';
|
||||
|
||||
import { withModalMounter } from '../modal/service';
|
||||
import { withModalMounter, getModal } from '../modal/service';
|
||||
|
||||
import App from './component';
|
||||
import ActionsBarContainer from '../actions-bar/container';
|
||||
@ -55,14 +56,26 @@ const endMeeting = (code) => {
|
||||
};
|
||||
|
||||
const AppContainer = (props) => {
|
||||
function usePrevious(value) {
|
||||
const ref = useRef();
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
});
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
const {
|
||||
actionsbar,
|
||||
meetingLayout,
|
||||
selectedLayout,
|
||||
settingsLayout,
|
||||
pushLayoutToEveryone,
|
||||
currentUserId,
|
||||
shouldShowPresentation: propsShouldShowPresentation,
|
||||
presentationRestoreOnUpdate,
|
||||
isPresenter,
|
||||
randomlySelectedUser,
|
||||
isModalOpen,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
@ -81,6 +94,13 @@ const AppContainer = (props) => {
|
||||
const shouldShowPresentation = propsShouldShowPresentation
|
||||
&& (presentationIsOpen || presentationRestoreOnUpdate);
|
||||
|
||||
const prevRandomUser = usePrevious(randomlySelectedUser);
|
||||
|
||||
const mountRandomUserModal = !isPresenter
|
||||
&& !_.isEqual( prevRandomUser, randomlySelectedUser)
|
||||
&& randomlySelectedUser.length > 0
|
||||
&& !isModalOpen;
|
||||
|
||||
return currentUserId
|
||||
? (
|
||||
<App
|
||||
@ -91,6 +111,7 @@ const AppContainer = (props) => {
|
||||
currentUserId,
|
||||
layoutType,
|
||||
meetingLayout,
|
||||
selectedLayout,
|
||||
settingsLayout,
|
||||
pushLayoutToEveryone,
|
||||
deviceType,
|
||||
@ -100,6 +121,8 @@ const AppContainer = (props) => {
|
||||
sidebarContentPanel,
|
||||
sidebarContentIsOpen,
|
||||
shouldShowPresentation,
|
||||
mountRandomUserModal,
|
||||
isPresenter,
|
||||
}}
|
||||
{...otherProps}
|
||||
/>
|
||||
@ -191,6 +214,7 @@ export default injectIntl(withModalMounter(withTracker(({ intl, baseControls })
|
||||
currentUserId: currentUser?.userId,
|
||||
isPresenter: currentUser?.presenter,
|
||||
meetingLayout: layout,
|
||||
selectedLayout,
|
||||
settingsLayout: selectedLayout?.replace('Push', ''),
|
||||
pushLayoutToEveryone: selectedLayout?.includes('Push'),
|
||||
audioAlertEnabled: AppSettings.chatAudioAlerts,
|
||||
@ -205,6 +229,7 @@ export default injectIntl(withModalMounter(withTracker(({ intl, baseControls })
|
||||
),
|
||||
hidePresentation: getFromUserSettings('bbb_hide_presentation', LAYOUT_CONFIG.hidePresentation),
|
||||
hideActionsBar: getFromUserSettings('bbb_hide_actions_bar', false),
|
||||
isModalOpen: !!getModal(),
|
||||
};
|
||||
})(AppContainer)));
|
||||
|
||||
|
@ -73,7 +73,10 @@ export const leaveEchoTest = () => {
|
||||
};
|
||||
|
||||
export const closeModal = () => {
|
||||
if (!Service.isConnecting()) showModal(null);
|
||||
if (Service.isConnecting()) {
|
||||
Service.forceExitAudio();
|
||||
}
|
||||
showModal(null);
|
||||
};
|
||||
|
||||
export default {
|
||||
|
@ -82,11 +82,11 @@ class AudioContainer extends PureComponent {
|
||||
componentDidMount() {
|
||||
const { meetingIsBreakout } = this.props;
|
||||
|
||||
this.init();
|
||||
|
||||
if (meetingIsBreakout && !Service.isUsingAudio()) {
|
||||
this.joinAudio();
|
||||
}
|
||||
this.init().then(() => {
|
||||
if (meetingIsBreakout && !Service.isUsingAudio()) {
|
||||
this.joinAudio();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
@ -217,15 +217,15 @@ export default lockContextContainer(withModalMounter(injectIntl(withTracker(({ m
|
||||
meetingIsBreakout,
|
||||
userSelectedMicrophone,
|
||||
userSelectedListenOnly,
|
||||
init: () => {
|
||||
Service.init(messages, intl);
|
||||
init: async () => {
|
||||
await Service.init(messages, intl);
|
||||
const enableVideo = getFromUserSettings('bbb_enable_video', KURENTO_CONFIG.enableVideo);
|
||||
const autoShareWebcam = getFromUserSettings('bbb_auto_share_webcam', KURENTO_CONFIG.autoShareWebcam);
|
||||
if ((!autoJoin || didMountAutoJoin)) {
|
||||
if (enableVideo && autoShareWebcam) {
|
||||
openVideoPreviewModal();
|
||||
}
|
||||
return;
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
Session.set('audioModalIsOpen', true);
|
||||
if (enableVideo && autoShareWebcam) {
|
||||
@ -237,6 +237,7 @@ export default lockContextContainer(withModalMounter(injectIntl(withTracker(({ m
|
||||
openAudioModal();
|
||||
didMountAutoJoin = true;
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
};
|
||||
})(AudioContainer))));
|
||||
|
@ -42,7 +42,7 @@ const audioEventHandler = (event) => {
|
||||
|
||||
const init = (messages, intl) => {
|
||||
AudioManager.setAudioMessages(messages, intl);
|
||||
if (AudioManager.initialized) return;
|
||||
if (AudioManager.initialized) return Promise.resolve(false);
|
||||
const meetingId = Auth.meetingID;
|
||||
const userId = Auth.userID;
|
||||
const { sessionToken } = Auth;
|
||||
@ -63,7 +63,7 @@ const init = (messages, intl) => {
|
||||
microphoneLockEnforced,
|
||||
};
|
||||
|
||||
AudioManager.init(userData, audioEventHandler);
|
||||
return AudioManager.init(userData, audioEventHandler);
|
||||
};
|
||||
|
||||
const isVoiceUser = () => {
|
||||
@ -98,6 +98,7 @@ const toggleMuteMicrophone = throttle(() => {
|
||||
export default {
|
||||
init,
|
||||
exitAudio: () => AudioManager.exitAudio(),
|
||||
forceExitAudio: () => AudioManager.forceExitAudio(),
|
||||
transferCall: () => AudioManager.transferCall(),
|
||||
joinListenOnly: () => AudioManager.joinListenOnly(),
|
||||
joinMicrophone: () => AudioManager.joinMicrophone(),
|
||||
|
@ -41,6 +41,7 @@ const ButtonEmoji = (props) => {
|
||||
const {
|
||||
hideLabel,
|
||||
className,
|
||||
hidden,
|
||||
...newProps
|
||||
} = props;
|
||||
|
||||
@ -55,6 +56,7 @@ const ButtonEmoji = (props) => {
|
||||
|
||||
return (
|
||||
<span>
|
||||
<Styled.EmojiButtonSpace hidden={hidden} />
|
||||
<TooltipContainer title={label}>
|
||||
<Styled.EmojiButton
|
||||
type="button"
|
||||
|
@ -1,6 +1,10 @@
|
||||
import styled from 'styled-components';
|
||||
import Icon from '../../icon/component';
|
||||
import { btnDefaultColor, btnDefaultBg } from '/imports/ui/stylesheets/styled-components/palette';
|
||||
import {
|
||||
btnDefaultColor,
|
||||
btnDefaultBg,
|
||||
colorBackground,
|
||||
} from '/imports/ui/stylesheets/styled-components/palette';
|
||||
import { btnSpacing } from '/imports/ui/stylesheets/styled-components/general';
|
||||
|
||||
const EmojiButtonIcon = styled(Icon)`
|
||||
@ -34,7 +38,7 @@ const EmojiButton = styled.button`
|
||||
border-radius: 50%;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
right: -1px;
|
||||
right: -.2em;
|
||||
bottom: 0;
|
||||
background-color: ${btnDefaultBg};
|
||||
overflow: hidden;
|
||||
@ -51,7 +55,6 @@ const EmojiButton = styled.button`
|
||||
top: 0;
|
||||
height: 60%;
|
||||
left: 0;
|
||||
width: 75%;
|
||||
margin-left: 25%;
|
||||
font-size: 50%;
|
||||
margin-top: 40%;
|
||||
@ -59,8 +62,19 @@ const EmojiButton = styled.button`
|
||||
}
|
||||
`;
|
||||
|
||||
const EmojiButtonSpace = styled.div`
|
||||
position: absolute;
|
||||
height: 1.4em;
|
||||
width: 1.4em;
|
||||
background-color: ${colorBackground};
|
||||
right: -.4em;
|
||||
bottom: -.2em;
|
||||
border-radius: 50%;
|
||||
`;
|
||||
|
||||
export default {
|
||||
EmojiButtonIcon,
|
||||
Label,
|
||||
EmojiButton,
|
||||
EmojiButtonSpace,
|
||||
};
|
||||
|
@ -79,7 +79,7 @@ const takeOwnership = (locale) => {
|
||||
const appendText = (text, locale) => {
|
||||
if (typeof text !== 'string' || text.length === 0) return;
|
||||
|
||||
const formattedText = `${text.trim().replace(/^\w/, (c) => c.toUpperCase())}\n\n`;
|
||||
const formattedText = `${text.trim().replace(/^\w/, (c) => c?.toUpperCase())}\n\n`;
|
||||
makeCall('appendText', formattedText, locale);
|
||||
};
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
import { colorDanger, colorGrayDark } from '/imports/ui/stylesheets/styled-components/palette';
|
||||
import { borderSize } from '/imports/ui/stylesheets/styled-components/general';
|
||||
import { fontSizeSmaller, fontSizeMD, fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography';
|
||||
import { fontSizeSmaller, fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography';
|
||||
|
||||
const SingleTyper = styled.span`
|
||||
overflow: hidden;
|
||||
@ -29,7 +29,6 @@ const TypingIndicator = styled.span`
|
||||
display: block;
|
||||
margin-right: 0.05rem;
|
||||
margin-left: 0.05rem;
|
||||
line-height: ${fontSizeMD};
|
||||
}
|
||||
|
||||
text-align: left;
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user