Merge with fill shape fix

This commit is contained in:
Daniel Petri Rocha 2021-12-16 13:01:26 +01:00
commit 4328222c82
310 changed files with 9030 additions and 1351 deletions

View File

@ -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(

View File

@ -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)
}
}
}

View File

@ -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")

View File

@ -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,

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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)

View File

@ -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 = {

View File

@ -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)
}
}
}

View File

@ -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)

View File

@ -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)
}
}
}
}
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -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,

View File

@ -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
)

View File

@ -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)
}
}

View File

@ -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 =>

View File

@ -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)
)
}

View File

@ -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 = {

View File

@ -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

View File

@ -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)
}
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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))
}
}

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -76,6 +76,7 @@ apps {
ejectOnViolation = false
endMeetingWhenNoMoreAuthedUsers = false
endMeetingWhenNoMoreAuthedUsersAfterMinutes = 2
reduceDuplicatedPick = false
}
analytics {

View File

@ -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)

View File

@ -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)

View File

@ -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
)

View File

@ -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

View File

@ -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();
}

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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 {};
}

View File

@ -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);

View File

@ -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]);

View File

@ -0,0 +1,6 @@
package org.bigbluebutton.api.model.shared;
import org.bigbluebutton.api.model.constraint.JoinPasswordConstraint;
@JoinPasswordConstraint
public class JoinPassword extends Password {}

View File

@ -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 {}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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

View File

@ -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);
}

View File

@ -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);

View File

@ -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);

View File

@ -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))

View File

@ -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)
}

View File

@ -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)

View File

@ -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))
}
}

View File

@ -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)

View File

@ -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",

View File

@ -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" />

View File

@ -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" />
&nbsp;
{
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>
&nbsp;
<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} />

View File

@ -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>
&nbsp;&nbsp;
<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>
);

View File

@ -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>
&nbsp;&nbsp;
<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>
);

View File

@ -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>
&nbsp;&nbsp;&nbsp;
<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>
&nbsp;
{ 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`} />
&nbsp;
{ usersEmojisSummary[user.intId][emoji] }
{ usersEmojisSummary[user.userKey][emoji] }
&nbsp;
<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>
&nbsp;
<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>

View File

@ -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

View File

@ -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;

View File

@ -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

View File

@ -1 +1,2 @@
BIGBLUEBUTTON_RELEASE=2.5-alpha-1

View File

@ -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

View File

@ -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;
}

View File

@ -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) => {

View 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;

View File

@ -14,3 +14,4 @@ throw new Error();
/* eslint-disable no-unreachable */
// BRIDGES LIST
import('/imports/api/audio/client/bridge/FullAudioBridge'); // NOSONAR

View File

@ -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(

View File

@ -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;

View File

@ -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) {

View File

@ -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}`);

View File

@ -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}`);

View File

@ -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}`);

View File

@ -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 },

View File

@ -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);
}

View File

@ -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],

View File

@ -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,

View File

@ -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) {

View File

@ -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(

View 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}`);
}
}

View File

@ -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) {

View File

@ -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);
});
}

View File

@ -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) {

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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;

View File

@ -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');

View File

@ -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',

View File

@ -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` })

View File

@ -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)));

View File

@ -73,7 +73,10 @@ export const leaveEchoTest = () => {
};
export const closeModal = () => {
if (!Service.isConnecting()) showModal(null);
if (Service.isConnecting()) {
Service.forceExitAudio();
}
showModal(null);
};
export default {

View File

@ -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))));

View File

@ -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(),

View File

@ -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"

View File

@ -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,
};

View File

@ -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);
};

View File

@ -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