Merge branch 'wip-new-msg-userLeftFlag' of github.com:Tainan404/bigbluebutton into wip-new-msg-userLeftFlag

This commit is contained in:
Tainan Felipe 2022-01-07 11:41:19 -03:00
commit 68942d45db
155 changed files with 3937 additions and 1191 deletions

View File

@ -39,15 +39,7 @@ trait SendGroupChatMessageMsgHdlr {
}
}
// Check if this message was sent while the lock settings was being changed.
val isDelayedMessage = System.currentTimeMillis() - MeetingStatus2x.getPermissionsChangedOn(liveMeeting.status) < 5000
if (applyPermissionCheck && chatLocked && !isDelayedMessage) {
val meetingId = liveMeeting.props.meetingProp.intId
val reason = "No permission to send a message to this group chat."
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, bus.outGW, liveMeeting)
state
} else {
if (!(applyPermissionCheck && chatLocked)) {
def makeHeader(name: String, meetingId: String, userId: String): BbbClientMsgHeader = {
BbbClientMsgHeader(name, meetingId, userId)
}
@ -90,7 +82,7 @@ trait SendGroupChatMessageMsgHdlr {
case Some(ns) => ns
case None => state
}
}
} else { state }
}
}

View File

@ -0,0 +1,57 @@
package org.bigbluebutton.core.apps.users
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.models.{ UserState, Users2x }
import org.bigbluebutton.core.running.{ LiveMeeting, OutMsgRouter }
import org.bigbluebutton.core.apps.{ PermissionCheck, RightsManagementTrait }
import org.bigbluebutton.core.domain.MeetingState2x
trait ChangeUserPinStateReqMsgHdlr extends RightsManagementTrait {
this: UsersApp =>
val liveMeeting: LiveMeeting
val outGW: OutMsgRouter
def handleChangeUserPinStateReqMsg(msg: ChangeUserPinStateReqMsg): Unit = {
log.info("handleAssignPinReqMsg: changedBy={} pin={} userId={}", msg.body.changedBy, msg.body.pin, msg.body.userId)
def broadcastUserPinChange(user: UserState, pin: Boolean): Unit = {
val routingChange = Routing.addMsgToClientRouting(
MessageTypes.BROADCAST_TO_MEETING,
liveMeeting.props.meetingProp.intId, user.intId
)
val envelopeChange = BbbCoreEnvelope(UserPinStateChangedEvtMsg.NAME, routingChange)
val headerChange = BbbClientMsgHeader(UserPinStateChangedEvtMsg.NAME, liveMeeting.props.meetingProp.intId,
user.intId)
val bodyChange = UserPinStateChangedEvtMsgBody(user.intId, pin, msg.body.changedBy)
val eventChange = UserPinStateChangedEvtMsg(headerChange, bodyChange)
val msgEventChange = BbbCommonEnvCoreMsg(envelopeChange, eventChange)
outGW.send(msgEventChange)
}
if (permissionFailed(PermissionCheck.MOD_LEVEL, PermissionCheck.VIEWER_LEVEL, liveMeeting.users2x, msg.body.changedBy)
|| liveMeeting.props.meetingProp.isBreakout) {
val meetingId = liveMeeting.props.meetingProp.intId
val reason = "No permission to change pin in meeting."
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.body.changedBy, reason, outGW, liveMeeting)
} else {
for {
oldPin <- Users2x.findPin(liveMeeting.users2x)
} yield {
if (oldPin.intId != msg.body.userId) {
Users2x.changePin(liveMeeting.users2x, oldPin.intId, false)
broadcastUserPinChange(oldPin, false)
}
}
for {
newPin <- Users2x.findWithIntId(liveMeeting.users2x, msg.body.userId)
} yield {
if (newPin.pin != msg.body.pin) {
Users2x.changePin(liveMeeting.users2x, newPin.intId, msg.body.pin)
broadcastUserPinChange(newPin, msg.body.pin)
}
}
}
}
}

View File

@ -166,6 +166,7 @@ class UsersApp(
with SelectRandomViewerReqMsgHdlr
with GetWebcamsOnlyForModeratorReqMsgHdlr
with AssignPresenterReqMsgHdlr
with ChangeUserPinStateReqMsgHdlr
with EjectDuplicateUserReqMsgHdlr
with EjectUserFromMeetingCmdMsgHdlr
with EjectUserFromMeetingSysMsgHdlr

View File

@ -21,7 +21,7 @@ trait ModifyWhiteboardAccessPubMsgHdlr extends RightsManagementTrait {
bus.outGW.send(msgEvent)
}
if (filterWhiteboardMessage(msg.body.whiteboardId, msg.header.userId, liveMeeting) && permissionFailed(PermissionCheck.GUEST_LEVEL, PermissionCheck.PRESENTER_LEVEL, liveMeeting.users2x, msg.header.userId)) {
if (permissionFailed(PermissionCheck.GUEST_LEVEL, PermissionCheck.PRESENTER_LEVEL, liveMeeting.users2x, msg.header.userId)) {
val meetingId = liveMeeting.props.meetingProp.intId
val reason = "No permission to modify access to the whiteboard."
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, bus.outGW, liveMeeting)

View File

@ -136,6 +136,16 @@ object Users2x {
}
}
def changePin(users: Users2x, intId: String, pin: Boolean): Option[UserState] = {
for {
u <- findWithIntId(users, intId)
} yield {
val newUser = u.modify(_.pin).setTo(pin)
users.save(newUser)
newUser
}
}
def setEmojiStatus(users: Users2x, intId: String, emoji: String): Option[UserState] = {
for {
u <- findWithIntId(users, intId)
@ -184,6 +194,24 @@ object Users2x {
users.toVector.find(u => u.presenter)
}
def hasPin(users: Users2x): Boolean = {
findPin(users) match {
case Some(p) => true
case None => false
}
}
def isPin(intId: String, users: Users2x): Boolean = {
findWithIntId(users, intId) match {
case Some(u) => u.pin
case None => false
}
}
def findPin(users: Users2x): Option[UserState] = {
users.toVector.find(u => u.pin)
}
def findModerator(users: Users2x): Option[UserState] = {
users.toVector.find(u => u.role == Roles.MODERATOR_ROLE)
}
@ -300,6 +328,7 @@ case class UserState(
name: String,
role: String,
guest: Boolean,
pin: Boolean,
authed: Boolean,
guestStatus: String,
emoji: String,

View File

@ -109,6 +109,8 @@ class ReceivedJsonMsgHandlerActor(
routeGenericMsg[GetPresenterGroupReqMsg](envelope, jsonNode)
case UserActivitySignCmdMsg.NAME =>
routeGenericMsg[UserActivitySignCmdMsg](envelope, jsonNode)
case ChangeUserPinStateReqMsg.NAME =>
routeGenericMsg[ChangeUserPinStateReqMsg](envelope, jsonNode)
case SelectRandomViewerReqMsg.NAME =>
routeGenericMsg[SelectRandomViewerReqMsg](envelope, jsonNode)
@ -143,6 +145,8 @@ class ReceivedJsonMsgHandlerActor(
routeGenericMsg[CamStreamUnsubscribedInSfuEvtMsg](envelope, jsonNode)
case CamBroadcastStoppedInSfuEvtMsg.NAME =>
routeGenericMsg[CamBroadcastStoppedInSfuEvtMsg](envelope, jsonNode)
case EjectUserCamerasCmdMsg.NAME =>
routeGenericMsg[EjectUserCamerasCmdMsg](envelope, jsonNode)
// Voice
case RecordingStartedVoiceConfEvtMsg.NAME =>

View File

@ -74,6 +74,7 @@ trait HandlerHelpers extends SystemConfiguration {
authed = regUser.authed,
guestStatus = regUser.guestStatus,
emoji = "none",
pin = false,
presenter = false,
locked = MeetingStatus2x.getPermissions(liveMeeting.status).lockOnJoin,
avatar = regUser.avatarURL,

View File

@ -88,6 +88,7 @@ class MeetingActor(
with CamStreamSubscribedInSfuEvtMsgHdlr
with CamStreamUnsubscribedInSfuEvtMsgHdlr
with CamBroadcastStoppedInSfuEvtMsgHdlr
with EjectUserCamerasCmdMsgHdlr
with EjectUserFromVoiceCmdMsgHdlr
with EndMeetingSysCmdMsgHdlr
@ -388,6 +389,7 @@ class MeetingActor(
case m: CamStreamSubscribedInSfuEvtMsg => handleCamStreamSubscribedInSfuEvtMsg(m)
case m: CamStreamUnsubscribedInSfuEvtMsg => handleCamStreamUnsubscribedInSfuEvtMsg(m)
case m: CamBroadcastStoppedInSfuEvtMsg => handleCamBroadcastStoppedInSfuEvtMsg(m)
case m: EjectUserCamerasCmdMsg => handleEjectUserCamerasCmdMsg(m)
case m: UserJoinedVoiceConfEvtMsg => handleUserJoinedVoiceConfEvtMsg(m)
case m: LogoutAndEndMeetingCmdMsg => usersApp.handleLogoutAndEndMeetingCmdMsg(m, state)
@ -402,6 +404,7 @@ class MeetingActor(
case m: GetRecordingStatusReqMsg => usersApp.handleGetRecordingStatusReqMsg(m)
case m: ChangeUserEmojiCmdMsg => handleChangeUserEmojiCmdMsg(m)
case m: SelectRandomViewerReqMsg => usersApp.handleSelectRandomViewerReqMsg(m)
case m: ChangeUserPinStateReqMsg => usersApp.handleChangeUserPinStateReqMsg(m)
// Client requested to eject user
case m: EjectUserFromMeetingCmdMsg =>

View File

@ -73,6 +73,7 @@ class AnalyticsActor(val includeChat: Boolean) extends Actor with ActorLogging {
case m: UserJoinedVoiceConfToClientEvtMsg => logMessage(msg)
case m: UserDisconnectedFromGlobalAudioMsg => logMessage(msg)
case m: AssignPresenterReqMsg => logMessage(msg)
case m: ChangeUserPinStateReqMsg => logMessage(msg)
case m: ScreenshareStartedVoiceConfEvtMsg => logMessage(msg)
case m: ScreenshareStoppedVoiceConfEvtMsg => logMessage(msg)
case m: ScreenshareRtmpBroadcastStartedVoiceConfEvtMsg => logMessage(msg)

View File

@ -0,0 +1,50 @@
package org.bigbluebutton.core2.message.handlers
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 EjectUserCamerasCmdMsgHdlr {
this: MeetingActor =>
val outGW: OutMsgRouter
def handleEjectUserCamerasCmdMsg(msg: EjectUserCamerasCmdMsg): Unit = {
val requesterUserId = msg.header.userId
val meetingId = liveMeeting.props.meetingProp.intId
val userToEject = msg.body.userId
val ejectionDisabled = !liveMeeting.props.usersProp.allowModsToEjectCameras
val isBreakout = liveMeeting.props.meetingProp.isBreakout
val badPermission = permissionFailed(
PermissionCheck.MOD_LEVEL,
PermissionCheck.VIEWER_LEVEL,
liveMeeting.users2x,
requesterUserId
)
if (ejectionDisabled || isBreakout || badPermission) {
val reason = "No permission to eject cameras from user."
PermissionCheck.ejectUserForFailedPermission(
meetingId,
requesterUserId,
reason,
outGW,
liveMeeting
)
} else {
log.info("Ejecting user cameras. meetingId=" + meetingId
+ " userId=" + userToEject
+ " requesterUserId=" + requesterUserId)
val broadcastedWebcams = Webcams.findWebcamsForUser(liveMeeting.webcams, userToEject)
broadcastedWebcams foreach { webcam =>
// Goes to SFU and comes back through CamBroadcastStoppedInSfuEvtMsg
val event = MsgBuilder.buildCamBroadcastStopSysMsg(
meetingId, userToEject, webcam.stream.id
)
outGW.send(event)
}
}
}
}

View File

@ -560,10 +560,10 @@ 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)

View File

@ -12,6 +12,7 @@ object UserJoinedMeetingEvtMsgBuilder {
role = userState.role, guest = userState.guest, authed = userState.authed,
guestStatus = userState.guestStatus,
emoji = userState.emoji,
pin = userState.pin,
presenter = userState.presenter, locked = userState.locked, avatar = userState.avatar,
clientType = userState.clientType)

View File

@ -66,7 +66,7 @@ trait FakeTestData {
}
def createFakeUser(liveMeeting: LiveMeeting, regUser: RegisteredUser): UserState = {
UserState(intId = regUser.id, extId = regUser.externId, name = regUser.name, role = regUser.role,
UserState(intId = regUser.id, extId = regUser.externId, name = regUser.name, role = regUser.role, pin = false,
guest = regUser.guest, authed = regUser.authed, guestStatus = regUser.guestStatus,
emoji = "none", locked = false, presenter = false, avatar = regUser.avatarURL, clientType = "unknown",
pickExempted = false, userLeftFlag = UserLeftFlag(false, 0))

View File

@ -19,7 +19,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(),
@ -28,18 +27,25 @@ case class Meeting(
)
case class User(
intId: String,
userKey: String,
extId: String,
intIds: Map[String,UserId] = Map(),
name: String,
isModerator: Boolean,
isDialIn: Boolean = false,
currentIntId: String = null,
answers: Map[String,String] = Map(),
talk: Talk = Talk(),
emojis: Vector[Emoji] = Vector(),
webcams: Vector[Webcam] = Vector(),
totalOfMessages: Long = 0,
)
case class UserId(
intId: String,
registeredOn: Long = System.currentTimeMillis(),
leftOn: Long = 0,
userLeftFlag: Boolean = false,
)
case class Poll(
@ -91,8 +97,9 @@ class LearningDashboardActor(
) extends Actor with ActorLogging {
private var meetings: Map[String, Meeting] = Map()
private var meetingsLastJsonHash : Map[String,String] = Map()
private var meetingExcludedUserIds : Map[String,Vector[String]] = Map()
private var meetingAccessTokens: Map[String,String] = Map()
private var meetingsLastJsonHash: Map[String,String] = Map()
private var meetingExcludedUserIds: Map[String,Vector[String]] = Map()
system.scheduler.schedule(10.seconds, 10.seconds, self, SendPeriodicReport)
@ -112,6 +119,8 @@ class LearningDashboardActor(
// User
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)
@ -144,10 +153,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)
}
}
@ -165,40 +174,95 @@ class LearningDashboardActor(
}
}
private def handleUserJoinedMeetingEvtMsg(msg: UserJoinedMeetingEvtMsg): Unit = {
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: 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)
)
})
val user = findUserByIntId(meeting, msg.body.userId).getOrElse(null)
this.addUserToMeeting(meeting.intId, user)
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)
}
@ -209,10 +273,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)
}
@ -222,11 +286,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)
}
}
@ -234,11 +298,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)
}
}
@ -246,30 +310,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
)
})
this.addUserToMeeting(meeting.intId, user)
}
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)
}
@ -279,7 +334,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)
}
@ -288,11 +343,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)
@ -308,7 +363,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)
}
}
@ -328,7 +383,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`
@ -342,7 +397,7 @@ 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 updatedMeeting = meeting.copy(users = meeting.users + (updatedUser.userKey -> updatedUser))
meetings += (updatedMeeting.intId -> updatedMeeting)
}
}
@ -373,10 +428,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 {
@ -397,18 +452,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))
})
)
@ -418,17 +462,74 @@ class LearningDashboardActor(
sendReport(updatedMeeting)
meetings = meetings.-(updatedMeeting.intId)
meetingAccessTokens = meetingAccessTokens.-(updatedMeeting.intId)
meetingExcludedUserIds = meetingExcludedUserIds.-(updatedMeeting.intId)
meetingsLastJsonHash = meetingsLastJsonHash.-(updatedMeeting.intId)
log.info(" removed for meeting {}.",updatedMeeting.intId)
}
}
private def addUserToMeeting(meetingIntId: String, user: User): Unit = {
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(user.extId)) {
meetings += (meeting.intId -> meeting.copy(users = meeting.users + (user.intId -> user.copy(leftOn = 0))))
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
))
))
))
}
}
}
@ -446,7 +547,10 @@ class LearningDashboardActor(
val activityJsonHash : String = MessageDigest.getInstance("MD5").digest(activityJson.getBytes).mkString
if(!meetingsLastJsonHash.contains(meeting.intId) || meetingsLastJsonHash.get(meeting.intId).getOrElse("") != activityJsonHash) {
val event = MsgBuilder.buildLearningDashboardEvtMsg(meeting.intId, activityJson)
for {
learningDashboardAccessToken <- meetingAccessTokens.get(meeting.intId)
} yield {
val event = MsgBuilder.buildLearningDashboardEvtMsg(meeting.intId, learningDashboardAccessToken, activityJson)
outGW.send(event)
meetingsLastJsonHash += (meeting.intId -> activityJsonHash)
@ -454,5 +558,6 @@ class LearningDashboardActor(
log.info("Activity Report sent for meeting {}",meeting.intId)
}
}
}
}

View File

@ -40,6 +40,7 @@ trait AppsTestFixtures {
val maxUsers = 25
val guestPolicy = "ALWAYS_ASK"
val allowModsToUnmuteUsers = false
val allowModsToEjectCameras = false
val authenticatedGuest = false
val red5DeskShareIPTestFixture = "127.0.0.1"
@ -60,7 +61,7 @@ trait AppsTestFixtures {
modOnlyMessage = modOnlyMessage)
val voiceProp = VoiceProp(telVoice = voiceConfId, voiceConf = voiceConfId, dialNumber = dialNumber, muteOnStart = muteOnStart)
val usersProp = UsersProp(maxUsers = maxUsers, webcamsOnlyForModerator = webcamsOnlyForModerator,
guestPolicy = guestPolicy, allowModsToUnmuteUsers = allowModsToUnmuteUsers, authenticatedGuest = authenticatedGuest)
guestPolicy = guestPolicy, allowModsToUnmuteUsers = allowModsToUnmuteUsers, allowModsToEjectCameras = allowModsToEjectCameras, authenticatedGuest = authenticatedGuest)
val metadataProp = new MetadataProp(metadata)
val defaultProps = DefaultProps(meetingProp, breakoutProps, durationProps, password, recordProp, welcomeProp, voiceProp,

View File

@ -28,7 +28,7 @@ case class WelcomeProp(welcomeMsgTemplate: String, welcomeMsg: String, modOnlyMe
case class VoiceProp(telVoice: String, voiceConf: String, dialNumber: String, muteOnStart: Boolean)
case class UsersProp(maxUsers: Int, webcamsOnlyForModerator: Boolean, guestPolicy: String, meetingLayout: String, allowModsToUnmuteUsers: Boolean, authenticatedGuest: Boolean)
case class UsersProp(maxUsers: Int, webcamsOnlyForModerator: Boolean, guestPolicy: String, meetingLayout: String, allowModsToUnmuteUsers: Boolean, allowModsToEjectCameras: Boolean, authenticatedGuest: Boolean)
case class MetadataProp(metadata: collection.immutable.Map[String, String])

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

@ -106,6 +106,7 @@ case class UserJoinedMeetingEvtMsg(
case class UserJoinedMeetingEvtMsgBody(intId: String, extId: String, name: String, role: String,
guest: Boolean, authed: Boolean, guestStatus: String,
emoji: String,
pin: Boolean,
presenter: Boolean, locked: Boolean, avatar: String, clientType: String)
/**
@ -214,6 +215,16 @@ object AssignPresenterReqMsg { val NAME = "AssignPresenterReqMsg" }
case class AssignPresenterReqMsg(header: BbbClientMsgHeader, body: AssignPresenterReqMsgBody) extends StandardMsg
case class AssignPresenterReqMsgBody(requesterId: String, newPresenterId: String, newPresenterName: String, assignedBy: String)
/**
* Sent from client to change the video pin of the user in the meeting.
*/
object ChangeUserPinStateReqMsg { val NAME = "ChangeUserPinStateReqMsg" }
case class ChangeUserPinStateReqMsg(header: BbbClientMsgHeader, body: ChangeUserPinStateReqMsgBody) extends StandardMsg
case class ChangeUserPinStateReqMsgBody(userId: String, pin: Boolean, changedBy: String)
object UserPinStateChangedEvtMsg { val NAME = "UserPinStateChangedEvtMsg" }
case class UserPinStateChangedEvtMsg(header: BbbClientMsgHeader, body: UserPinStateChangedEvtMsgBody) extends BbbCoreMsg
case class UserPinStateChangedEvtMsgBody(userId: String, pin: Boolean, changedBy: String)
/**
* Sent from client to change the role of the user in the meeting.
*/

View File

@ -118,6 +118,16 @@ case class EjectUserFromSfuSysMsg(
) extends BbbCoreMsg
case class EjectUserFromSfuSysMsgBody(userId: String)
/**
* Sent by the client to eject all cameras from user #userId
*/
object EjectUserCamerasCmdMsg { val NAME = "EjectUserCamerasCmdMsg" }
case class EjectUserCamerasCmdMsg(
header: BbbClientMsgHeader,
body: EjectUserCamerasCmdMsgBody
) extends StandardMsg
case class EjectUserCamerasCmdMsgBody(userId: String)
/**
* Sent to bbb-webrtc-sfu to tear down broadcaster stream #streamId
*/

View File

@ -36,6 +36,7 @@ trait TestFixtures {
val maxUsers = 25
val muteOnStart = false
val allowModsToUnmuteUsers = false
val allowModsToEjectCameras = false
val keepEvents = false
val guestPolicy = "ALWAYS_ASK"
val authenticatedGuest = false
@ -55,7 +56,7 @@ trait TestFixtures {
modOnlyMessage = modOnlyMessage)
val voiceProp = VoiceProp(telVoice = voiceConfId, voiceConf = voiceConfId, dialNumber = dialNumber, muteOnStart = muteOnStart)
val usersProp = UsersProp(maxUsers = maxUsers, webcamsOnlyForModerator = webcamsOnlyForModerator,
guestPolicy = guestPolicy, allowModsToUnmuteUsers = allowModsToUnmuteUsers, authenticatedGuest = authenticatedGuest)
guestPolicy = guestPolicy, allowModsToUnmuteUsers = allowModsToUnmuteUsers, allowModsToEjectCameras = allowModsToEjectCameras, authenticatedGuest = authenticatedGuest)
val metadataProp = new MetadataProp(metadata)
val screenshareProps = ScreenshareProps(screenshareConf = "FixMe!", red5ScreenshareIp = "fixMe!",
red5ScreenshareApp = "fixMe!")

View File

@ -46,6 +46,7 @@ public class ApiParams {
public static final String MUTE_ON_START = "muteOnStart";
public static final String MEETING_KEEP_EVENTS = "meetingKeepEvents";
public static final String ALLOW_MODS_TO_UNMUTE_USERS = "allowModsToUnmuteUsers";
public static final String ALLOW_MODS_TO_EJECT_CAMERAS = "allowModsToEjectCameras";
public static final String NAME = "name";
public static final String PARENT_MEETING_ID = "parentMeetingID";
public static final String PASSWORD = "password";

View File

@ -416,7 +416,7 @@ public class MeetingService implements MessageListener {
m.getMeetingExpireIfNoUserJoinedInMinutes(), m.getmeetingExpireWhenLastUserLeftInMinutes(),
m.getUserInactivityInspectTimerInMinutes(), m.getUserInactivityThresholdInMinutes(),
m.getUserActivitySignResponseDelayInMinutes(), m.getEndWhenNoModerator(), m.getEndWhenNoModeratorDelayInMinutes(),
m.getMuteOnStart(), m.getAllowModsToUnmuteUsers(), m.getMeetingKeepEvents(),
m.getMuteOnStart(), m.getAllowModsToUnmuteUsers(), m.getAllowModsToEjectCameras(), m.getMeetingKeepEvents(),
m.breakoutRoomsParams,
m.lockSettingsParams, m.getHtml5InstanceId());
}
@ -963,10 +963,9 @@ 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());
logData.put("externalMeetingId", activityJsonObject.get("extId").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

@ -87,6 +87,7 @@ public class ParamsProcessorUtil {
private boolean webcamsOnlyForModerator;
private boolean defaultMuteOnStart = false;
private boolean defaultAllowModsToUnmuteUsers = false;
private boolean defaultAllowModsToEjectCameras = false;
private boolean defaultKeepEvents = false;
private Boolean useDefaultLogo;
private String defaultLogoURL;
@ -623,6 +624,12 @@ public class ParamsProcessorUtil {
}
meeting.setAllowModsToUnmuteUsers(allowModsToUnmuteUsers);
Boolean allowModsToEjectCameras = defaultAllowModsToEjectCameras;
if (!StringUtils.isEmpty(params.get(ApiParams.ALLOW_MODS_TO_EJECT_CAMERAS))) {
allowModsToEjectCameras = Boolean.parseBoolean(params.get(ApiParams.ALLOW_MODS_TO_EJECT_CAMERAS));
}
meeting.setAllowModsToEjectCameras(allowModsToEjectCameras);
return meeting;
}
@ -1074,6 +1081,14 @@ public class ParamsProcessorUtil {
return defaultAllowModsToUnmuteUsers;
}
public void setAllowModsToEjectCameras(Boolean value) {
defaultAllowModsToEjectCameras = value;
}
public Boolean getAllowModsToEjectCameras() {
return defaultAllowModsToEjectCameras;
}
public List<String> decodeIds(String encodeid) {
ArrayList<String> ids=new ArrayList<>();
try {

View File

@ -86,6 +86,7 @@ public class Meeting {
private String customCopyright = "";
private Boolean muteOnStart = false;
private Boolean allowModsToUnmuteUsers = false;
private Boolean allowModsToEjectCameras = false;
private Boolean meetingKeepEvents;
private Integer meetingExpireIfNoUserJoinedInMinutes = 5;
@ -514,6 +515,14 @@ public class Meeting {
return allowModsToUnmuteUsers;
}
public void setAllowModsToEjectCameras(Boolean value) {
allowModsToEjectCameras = value;
}
public Boolean getAllowModsToEjectCameras() {
return allowModsToEjectCameras;
}
public void userJoined(User user) {
User u = getUserById(user.getInternalUserId());
if (u != null) {

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

@ -1,5 +1,7 @@
package org.bigbluebutton.api.model.constraint;
import org.bigbluebutton.api.model.validator.PasswordValidator;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Retention;
@ -8,8 +10,7 @@ import java.lang.annotation.Target;
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
@Size(min = 2, max = 64, message = "Password must be between 8 and 20 characters")
@Constraint(validatedBy = {})
@Constraint(validatedBy = {PasswordValidator.class})
@Target(FIELD)
@Retention(RUNTIME)
public @interface PasswordConstraint {

View File

@ -0,0 +1,35 @@
package org.bigbluebutton.api.model.validator;
import org.bigbluebutton.api.model.constraint.PasswordConstraint;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
public class PasswordValidator implements ConstraintValidator<PasswordConstraint, String> {
private static Logger log = LoggerFactory.getLogger(PasswordValidator.class);
@Override
public void initialize(PasswordConstraint constraintAnnotation) {
ConstraintValidator.super.initialize(constraintAnnotation);
}
@Override
public boolean isValid(String password, ConstraintValidatorContext context) {
log.info("Validating password [{}]", password);
if(password == null || password.equals("")) {
log.info("Provided password is either null or an empty string");
return true;
}
if(password.length() < 2 || password.length() > 64) {
log.info("Passwords must be between 2 and 64 characters in length");
return false;
}
return true;
}
}

View File

@ -30,6 +30,7 @@ public interface IBbbWebApiGWApp {
Integer endWhenNoModeratorDelayInMinutes,
Boolean muteOnStart,
Boolean allowModsToUnmuteUsers,
Boolean allowModsToEjectCameras,
Boolean keepEvents,
BreakoutRoomsParams breakoutParams,
LockSettingsParams lockSettingsParams,

View File

@ -140,6 +140,7 @@ class BbbWebApiGWApp(
endWhenNoModeratorDelayInMinutes: java.lang.Integer,
muteOnStart: java.lang.Boolean,
allowModsToUnmuteUsers: java.lang.Boolean,
allowModsToEjectCameras: java.lang.Boolean,
keepEvents: java.lang.Boolean,
breakoutParams: BreakoutRoomsParams,
lockSettingsParams: LockSettingsParams,
@ -177,7 +178,9 @@ class BbbWebApiGWApp(
modOnlyMessage = modOnlyMessage)
val voiceProp = VoiceProp(telVoice = voiceBridge, voiceConf = voiceBridge, dialNumber = dialNumber, muteOnStart = muteOnStart.booleanValue())
val usersProp = UsersProp(maxUsers = maxUsers.intValue(), webcamsOnlyForModerator = webcamsOnlyForModerator.booleanValue(),
guestPolicy = guestPolicy, meetingLayout = meetingLayout, allowModsToUnmuteUsers = allowModsToUnmuteUsers.booleanValue(), authenticatedGuest = authenticatedGuest.booleanValue())
guestPolicy = guestPolicy, meetingLayout = meetingLayout, allowModsToUnmuteUsers = allowModsToUnmuteUsers.booleanValue(),
allowModsToEjectCameras = allowModsToEjectCameras.booleanValue(),
authenticatedGuest = authenticatedGuest.booleanValue())
val metadataProp = MetadataProp(mapAsScalaMap(metadata).toMap)
val screenshareProps = ScreenshareProps(
screenshareConf = voiceBridge + screenshareConfSuffix,

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

@ -1,2 +1,2 @@
git clone --branch 1.8.13 --depth 1 https://github.com/ether/etherpad-lite bbb-etherpad
git clone --branch 1.8.16 --depth 1 https://github.com/ether/etherpad-lite bbb-etherpad

View File

@ -48,18 +48,18 @@ 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`;
}
}
@ -139,12 +139,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;
@ -230,7 +235,7 @@ class App extends React.Component {
</span>
)
: null
}
}
<br />
<span className="text-sm font-medium">{activitiesJson.name || ''}</span>
</h1>
@ -280,8 +285,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="border-pink-500"
iconClass="bg-pink-50 text-pink-500"
onClick={() => {

View File

@ -31,7 +31,9 @@ class PollsTable extends React.Component {
</svg>
</th>
{typeof polls === 'object' && Object.values(polls || {}).length > 0 ? (
Object.values(polls || {}).map((poll, index) => <th className="px-3.5 2xl:px-4 py-3 text-center">{poll.question || `Poll ${index + 1}`}</th>)
Object.values(polls || {})
.sort((a, b) => ((a.createdOn > b.createdOn) ? 1 : -1))
.map((poll, index) => <th className="px-3.5 2xl:px-4 py-3 text-center">{poll.question || `Poll ${index + 1}`}</th>)
) : null }
</tr>
</thead>
@ -55,13 +57,15 @@ class PollsTable extends React.Component {
</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) => (
Object.values(polls || {})
.sort((a, b) => ((a.createdOn > b.createdOn) ? 1 : -1))
.map((poll) => (
<td className="px-3.5 2xl:px-4 py-3 text-sm text-center">
{ getUserAnswer(user, poll) }
{ poll.anonymous
@ -132,7 +136,9 @@ class PollsTable extends React.Component {
</div>
</div>
</td>
{Object.values(polls || {}).map((poll) => (
{Object.values(polls || {})
.sort((a, b) => ((a.createdOn > b.createdOn) ? 1 : -1))
.map((poll) => (
<td className="px-3.5 2xl:px-4 py-3 text-sm text-center">
{ poll.anonymousAnswers.map((answer) => <p>{answer}</p>) }
</td>

View File

@ -12,11 +12,17 @@ class StatusTable extends React.Component {
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 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);
@ -66,21 +72,16 @@ class StatusTable extends React.Component {
</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 = getUserEmojisSummary(user,
null,
period,
period + spanMinutes);
return (
{ periods.map((period) => (
<td className="px-3.5 2xl:px-4 py-3 text-sm col-text-left">
{
user.registeredOn > period && user.registeredOn < period + spanMinutes
? (
<span title={intl.formatDate(user.registeredOn, {
{ Object.values(user.intIds).map(({ registeredOn, leftOn }) => (
<>
{ registeredOn >= period && registeredOn < period + spanMinutes ? (
<span title={intl.formatDate(registeredOn, {
month: 'short',
day: 'numeric',
hour: '2-digit',
@ -103,9 +104,20 @@ class StatusTable extends React.Component {
/>
</svg>
</span>
) : null
}
{ Object.keys(userEmojisInPeriod)
) : null }
{ (function getEmojis() {
const userEmojisInPeriod = getUserEmojisSummary(
user,
null,
registeredOn > period && registeredOn < period + spanMinutes
? registeredOn : period,
leftOn > period && leftOn < period + spanMinutes
? leftOn : period + spanMinutes,
);
return (
Object
.keys(userEmojisInPeriod)
.map((emoji) => (
<div className="text-sm text-gray-800">
<i className={`${emojiConfigs[emoji].icon} text-sm`} />
@ -117,11 +129,11 @@ class StatusTable extends React.Component {
defaultMessage={emojiConfigs[emoji].defaultMessage}
/>
</div>
)) }
{
user.leftOn > period && user.leftOn < period + spanMinutes
? (
<span title={intl.formatDate(user.leftOn, {
))
);
}()) }
{ leftOn >= period && leftOn < period + spanMinutes ? (
<span title={intl.formatDate(leftOn, {
month: 'short',
day: 'numeric',
hour: '2-digit',
@ -144,11 +156,11 @@ class StatusTable extends React.Component {
/>
</svg>
</span>
) : null
}
) : null }
</>
)) }
</td>
);
}) }
)) }
</tr>
))) : null }
</tbody>

View File

@ -83,7 +83,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) {
@ -97,7 +97,7 @@ class UsersTable extends React.Component {
const usersActivityScore = {};
Object.values(allUsers || {}).forEach((user) => {
usersActivityScore[user.intId] = getActivityScore(user);
usersActivityScore[user.userKey] = getActivityScore(user);
});
return (
@ -175,10 +175,10 @@ class UsersTable extends React.Component {
{ 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;
@ -189,7 +189,7 @@ class UsersTable extends React.Component {
})
.map((user) => (
<tr key={user} className="text-gray-700">
<td className="px-3.5 2xl:px-4 py-3 col-text-left text-sm">
<td className="flex items-center px-3.5 2xl:px-4 py-3 col-text-left text-sm">
<div className="inline-block relative w-8 h-8 rounded-full">
{/* <img className="object-cover w-full h-full rounded-full" */}
{/* src="" */}
@ -202,9 +202,11 @@ 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>
{ 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"
@ -221,7 +223,7 @@ class UsersTable extends React.Component {
/>
</svg>
<FormattedDate
value={user.registeredOn}
value={intId.registeredOn}
month="short"
day="numeric"
hour="2-digit"
@ -229,8 +231,7 @@ class UsersTable extends React.Component {
second="2-digit"
/>
</p>
{
user.leftOn > 0
{ intId.leftOn > 0
? (
<p className="text-xs text-gray-600 dark:text-gray-400">
<svg
@ -249,7 +250,7 @@ class UsersTable extends React.Component {
</svg>
<FormattedDate
value={user.leftOn}
value={intId.leftOn}
month="short"
day="numeric"
hour="2-digit"
@ -258,8 +259,14 @@ class UsersTable extends React.Component {
/>
</p>
)
: null
}
: null }
{ index === Object.values(user.intIds).length - 1
? null
: (
<hr className="my-1" />
) }
</>
)) }
</div>
</td>
<td className="px-3.5 2xl:px-4 py-3 text-sm text-center items-center">
@ -278,23 +285,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 />
{
(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={`${getOnlinePercentage(user.registeredOn, user.leftOn).toString()}%`}
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: `${getOnlinePercentage(user.registeredOn, user.leftOn).toString()}%` }}
style={{ width: `${percentage.toString()}%` }}
role="progressbar"
/>
</div>
);
}())
}
</td>
<td className="px-3.5 2xl:px-4 py-3 text-sm text-center">
{ user.talk.totalTime > 0
@ -367,11 +385,11 @@ class UsersTable extends React.Component {
</td>
<td className="px-3.5 2xl:px-4 py-3 text-sm col-text-left">
{
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}
@ -408,23 +426,23 @@ class UsersTable extends React.Component {
!user.isModerator ? (
<td className="px-3.5 2xl:px-4 py-3 text-sm text-center items">
<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-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" />

View File

@ -1 +1 @@
BIGBLUEBUTTON_RELEASE=2.4-rc-7
BIGBLUEBUTTON_RELEASE=2.4.1

View File

@ -1,20 +0,0 @@
These instructions help for determine which localization strings are used in the bigbluebutton-client
1) Get the keys from the properties file, execute:
awk 'match($0,"="){ print substr($0,1,RSTART-1) }' /home/firstuser/dev/bigbluebutton/bigbluebutton-client/locale/en_US/bbbResources.properties &> keys.properties
2) Copy the following text to a file called routine.sh:
for i in $(cat keys.properties)
do
if grep -nr --exclude-dir="locale" $i /home/firstuser/dev/bigbluebutton/bigbluebutton-client/
then
echo "$i" >> keys-used.properties
else
echo "$i" >> keys-not-used.properties
fi
done
Then run: sh routine.sh

View File

@ -31,7 +31,7 @@ log_history=28
#
# Delete presentations older than N days
#
find /var/bigbluebutton/ -maxdepth 1 -type d -name "*-*" -mtime +$history -exec rm -rf '{}' +
find /var/bigbluebutton/ -maxdepth 1 -type d -name "*-[0-9]*" -mtime +$history -exec rm -rf '{}' +
#
# Delete streams from Kurento and mediasoup older than N days

View File

@ -52,7 +52,7 @@
<div class='span six html5clientOnly input-center'>
<div class='join-meeting '>
<h4>Try BigBlueButton</h4>
<p>(requires <a href="http://docs.bigbluebutton.org/2.2/install.html#5-install-api-demos-optional">API demos</a> to be installed)</p>
<p>(requires <a href="https://docs.bigbluebutton.org/2.4/install.html#install">API demos</a> to be installed)</p>
<form name="form1" method="GET" onsubmit="return checkform(this);" action="/demo/demoHTML5.jsp">
@ -257,16 +257,16 @@
<div class="row">
<div class="span six first">
<p>BigBlueButton and the BigBlueButton logo are trademarks of <a href="http://bigbluebutton.org/">BigBlueButton Inc.</a></p>
<p>BigBlueButton and the BigBlueButton logo are trademarks of <a href="https://bigbluebutton.org/">BigBlueButton Inc.</a></p>
</div>
<div class="span six last">
<ul>
<li>
Follow Us:
</li>
<li><a class="twitter" href="http://www.twitter.com/bigbluebutton" title="BigBlueButton Twitter Page" target="_blank"><i class="fa fa-twitter"></i></a></li>
<li><a class="facebook" href="http://www.facebook.com/bigbluebutton" title="BigBlueButton Facebook Page" target="_blank"><i class="fa fa-facebook"></i></a></li>
<li><a class="youtube" href="http://www.youtube.com/bigbluebuttonshare" title="BigBlueButton YouTube Page" target="_blank"><i class="fa fa-youtube"></i> </a></li>
<li><a class="twitter" href="https://www.twitter.com/bigbluebutton" title="BigBlueButton Twitter Page" target="_blank"><i class="fa fa-twitter"></i></a></li>
<li><a class="facebook" href="https://www.facebook.com/bigbluebutton" title="BigBlueButton Facebook Page" target="_blank"><i class="fa fa-facebook"></i></a></li>
<li><a class="youtube" href="https://www.youtube.com/bigbluebuttonshare" title="BigBlueButton YouTube Page" target="_blank"><i class="fa fa-youtube"></i> </a></li>
</ul>
</div>
</div>
@ -275,7 +275,7 @@
<div class="row">
<div class="span twelve center">
<p>Copyright &copy; 2021 BigBlueButton Inc.<br>
<small>Version <a href="http://docs.bigbluebutton.org/">2.3</a></small>
<small>Version <a href="https://docs.bigbluebutton.org/">2.4</a></small>
</p>
</div>
</div>

View File

@ -84,13 +84,13 @@
<div class='row'>
<div >
<h2>BigBlueButton HTML5 client test server</h2>
<p> <a href="http://bigbluebutton.org/" target="_blank">BigBlueButton</a> is an open source web conferencing system for on-line learning. This is a public test server for the BigBlueButton <a href="http://docs.bigbluebutton.org/html/html5-overview.html">HTML5 client</a> currently under development.</p>
<p> <a href="https://bigbluebutton.org/" target="_blank">BigBlueButton</a> is an open source web conferencing system for on-line learning. This is a public test server for the BigBlueButton <a href="http://docs.bigbluebutton.org/html/html5-overview.html">HTML5 client</a> currently under development.</p>
<p> Our goal for the upcoming release of the HTML5 client is to implement all the <a href="https://youtu.be/oh0bEk3YSwI">viewer capabilities</a> of the Flash client. Students join online classes as a viewer. The HTML5 client will give remote students the ability to join from their Android and Apple (iOS 11+) devices. Users using the Flash and HTML5 clients can join the same meeting (hence the two choices above). We built the HTML5 client using web real-time communication (WebRTC), <a href="https://facebook.github.io/react/">React</a>, and <a href="https://www.mongodb.com/">MongoDB</a>.</p>
<p> What can this developer build of the HTML5 client do right now? Pretty much everything the Flash client can do for viewers except (a) view a desktop sharing stream from the presenter and (b) send/receive webcam streams. We're working on (a) and (b). For now, we are really happy to share with you our progress and get <a href="https://docs.google.com/forms/d/1gFz5JdN3vD6jxhlVskFYgtEKEcexdDnUzpkwUXwQ4OY/viewform?usp=send_for">your feedback</a> on what has been implemeted so far. Enjoy!</p>
<h4>For Developers</h4>
<p> The BigBlueButton project is <a href="http://bigbluebutton.org/support">supported</a> by a community of developers that care about good design and a streamlined user experience. </p>
<p>See <a href="http://docs.bigbluebutton.org" target="_blank">Documentation</a> for more information on how you can integrate BigBlueButton with your project.</p>
<p> The BigBlueButton project is <a href="https://bigbluebutton.org/support">supported</a> by a community of developers that care about good design and a streamlined user experience. </p>
<p>See <a href="https://docs.bigbluebutton.org" target="_blank">Documentation</a> for more information on how you can integrate BigBlueButton with your project.</p>
</div>
<div class="span one"></div>
@ -269,17 +269,17 @@
<div class="row">
<div class="span six first">
<p>BigBlueButton and the BigBlueButton logo are trademarks of <a href="http://bigbluebutton.org/">BigBlueButton Inc.</a></p>
<p>BigBlueButton and the BigBlueButton logo are trademarks of <a href="https://bigbluebutton.org/">BigBlueButton Inc.</a></p>
</div>
<div class="span six last">
<ul>
<li>
Follow Us:
</li>
<li><a class="twitter" href="http://www.twitter.com/bigbluebutton" title="BigBlueButton Twitter Page" target="_blank"><i class="fa fa-twitter"></i></a></li>
<li><a class="facebook" href="http://www.facebook.com/bigbluebutton" title="BigBlueButton Facebook Page" target="_blank"><i class="fa fa-facebook"></i></a></li>
<li><a class="youtube" href="http://www.youtube.com/bigbluebuttonshare" title="BigBlueButton YouTube Page" target="_blank"><i class="fa fa-youtube"></i> </a></li>
<li><a class="google" href="http://google.com/+bigbluebutton" title="BigBlueButton Google Plus" target="_blank"><i class="fa fa-google-plus"></i></a></li>
<li><a class="twitter" href="https://www.twitter.com/bigbluebutton" title="BigBlueButton Twitter Page" target="_blank"><i class="fa fa-twitter"></i></a></li>
<li><a class="facebook" href="https://www.facebook.com/bigbluebutton" title="BigBlueButton Facebook Page" target="_blank"><i class="fa fa-facebook"></i></a></li>
<li><a class="youtube" href="https://www.youtube.com/bigbluebuttonshare" title="BigBlueButton YouTube Page" target="_blank"><i class="fa fa-youtube"></i> </a></li>
<li><a class="google" href="https://google.com/+bigbluebutton" title="BigBlueButton Google Plus" target="_blank"><i class="fa fa-google-plus"></i></a></li>
</ul>
</div>
</div>
@ -287,8 +287,8 @@
<div class="row">
<div class="span twelve center">
<p>Copyright &copy; 2018 BigBlueButton Inc.<br>
<small>Version <a href="http://docs.bigbluebutton.org/">2.0-RC1</a></small>
<p>Copyright &copy; 2021 BigBlueButton Inc.<br>
<small>Version <a href="https://docs.bigbluebutton.org/">2.4</a></small>
</p>
</div>
</div>

View File

@ -346,3 +346,9 @@
.icon-bbb-external-video_off:before {
content: "\e95e";
}
.icon-bbb-pin-video_on:before {
content: "\e964";
}
.icon-bbb-pin-video_off:before {
content: "\e965";
}

View File

@ -15,7 +15,6 @@ 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';
@ -263,7 +262,6 @@ export default class FullAudioBridge extends BaseAudioBridge {
userName: this.name,
caleeName: callerIdName,
iceServers: this.iceServers,
offering: OFFERING,
mediaServer: getMediaServerAdapter(),
};

View File

@ -42,6 +42,7 @@ const TRACE_SIP = MEDIA.traceSip || false;
const AUDIO_MICROPHONE_CONSTRAINTS = Meteor.settings.public.app.defaultSettings
.application.microphoneConstraints;
const SDP_SEMANTICS = MEDIA.sdpSemantics;
const FORCE_RELAY = MEDIA.forceRelay;
const DEFAULT_INPUT_DEVICE_ID = 'default';
const DEFAULT_OUTPUT_DEVICE_ID = 'default';
@ -557,6 +558,7 @@ class SIPSession {
peerConnectionConfiguration: {
iceServers,
sdpSemantics: SDP_SEMANTICS,
iceTransportPolicy: FORCE_RELAY ? 'relay' : undefined,
},
},
displayName: callerIdName,

View File

@ -1,33 +1,11 @@
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';
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;
function authTokenValidation({ meetingId, userId }) {
const selector = {
meetingId: mId,
userId: requesterUserId,
meetingId,
userId,
};
Logger.debug(`Publishing auth-token-validation for ${meetingId} ${userId}`);

View File

@ -61,6 +61,7 @@ export default function addMeeting(meeting) {
authenticatedGuest: Boolean,
maxUsers: Number,
allowModsToUnmuteUsers: Boolean,
allowModsToEjectCameras: Boolean,
meetingLayout: String,
},
durationProps: {

View File

@ -32,11 +32,12 @@ function currentPoll(secretPoll) {
const User = Users.findOne({ userId, meetingId }, { fields: { role: 1, presenter: 1 } });
if (!!User && (User.role === ROLE_MODERATOR || User.presenter)) {
if (!!User && User.presenter) {
Logger.debug('Publishing Polls', { meetingId, userId });
const selector = {
meetingId,
requester: userId,
};
const options = { fields: {} };
@ -45,13 +46,16 @@ function currentPoll(secretPoll) {
if ((hasPoll && hasPoll.secretPoll) || secretPoll) {
options.fields.responses = 0;
selector.secretPoll = true;
} else {
selector.secretPoll = false;
}
return Polls.find(selector, options);
}
Logger.warn(
'Publishing current-poll was requested by non-moderator connection',
'Publishing current-poll was requested by non-presenter connection',
{ meetingId, userId, connectionId: this.connection.id },
);
return Polls.find({ meetingId: '' });

View File

@ -15,6 +15,7 @@ const BRIDGE_NAME = 'kurento'
const SCREENSHARE_VIDEO_TAG = 'screenshareVideo';
const SEND_ROLE = 'send';
const RECV_ROLE = 'recv';
const DEFAULT_VOLUME = 1;
// the error-code mapping is bridge specific; that's why it's not in the errors util
const ERROR_MAP = {
@ -181,6 +182,28 @@ export default class KurentoScreenshareBridge {
}
}
setVolume(volume) {
const mediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG);
if (mediaElement) {
if (typeof volume === 'number' && volume >= 0 && volume <= 1) {
mediaElement.volume = volume;
}
return mediaElement.volume;
}
return DEFAULT_VOLUME;
}
getVolume() {
const mediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG);
if (mediaElement) return mediaElement.volume;
return DEFAULT_VOLUME;
}
handleViewerStart() {
const mediaElement = document.getElementById(SCREENSHARE_VIDEO_TAG);

View File

@ -15,6 +15,7 @@ export default function addUserPersistentData(user) {
intId: String,
extId: String,
name: String,
pin: Boolean,
role: String,
guest: Boolean,
authed: Boolean,
@ -39,6 +40,7 @@ export default function addUserPersistentData(user) {
avatar,
guest,
color,
pin,
} = user;
const userData = {
@ -51,6 +53,7 @@ export default function addUserPersistentData(user) {
avatar,
guest,
color,
pin,
loggedOut: false,
};

View File

@ -7,6 +7,7 @@ import handlePresenterAssigned from './handlers/presenterAssigned';
import handleEmojiStatus from './handlers/emojiStatus';
import handleUserEjected from './handlers/userEjected';
import handleChangeRole from './handlers/changeRole';
import handleUserPinChanged from './handlers/userPinChanged';
import handleUserInactivityInspect from './handlers/userInactivityInspect';
RedisPubSub.on('PresenterAssignedEvtMsg', handlePresenterAssigned);
@ -17,4 +18,5 @@ RedisPubSub.on('UserEmojiChangedEvtMsg', handleEmojiStatus);
RedisPubSub.on('UserEjectedFromMeetingEvtMsg', handleUserEjected);
RedisPubSub.on('UserRoleChangedEvtMsg', handleChangeRole);
RedisPubSub.on('UserLeftFlagEvtMsg', handleUserLeftFlag);
RedisPubSub.on('UserPinStateChangedEvtMsg', handleUserPinChanged);
RedisPubSub.on('UserInactivityInspectMsg', handleUserInactivityInspect);

View File

@ -0,0 +1,13 @@
import { check } from 'meteor/check';
import changePin from '../modifiers/changePin';
export default function handlePinAssigned({ body }, meetingId) {
const { userId, pin, changedBy } = body;
check(meetingId, String);
check(userId, String);
check(pin, Boolean);
check(changedBy, String);
return changePin(meetingId, userId, pin, changedBy);
}

View File

@ -9,6 +9,7 @@ import toggleUserLock from './methods/toggleUserLock';
import setUserEffectiveConnectionType from './methods/setUserEffectiveConnectionType';
import userActivitySign from './methods/userActivitySign';
import userLeftMeeting from './methods/userLeftMeeting';
import changePin from './methods/changePin';
import setRandomUser from './methods/setRandomUser';
Meteor.methods({
@ -22,5 +23,6 @@ Meteor.methods({
setUserEffectiveConnectionType,
userActivitySign,
userLeftMeeting,
changePin,
setRandomUser,
});

View File

@ -0,0 +1,34 @@
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import RedisPubSub from '/imports/startup/server/redis';
import Logger from '/imports/startup/server/logger';
import Users from '/imports/api/users';
import { extractCredentials } from '/imports/api/common/server/helpers';
export default function changePin(userId, pin) {
try {
const REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'ChangeUserPinStateReqMsg';
const { meetingId, requesterUserId } = extractCredentials(this.userId);
check(meetingId, String);
check(requesterUserId, String);
check(userId, String);
check(pin, Boolean);
const payload = {
pin,
changedBy: requesterUserId,
userId,
};
Logger.verbose('User pin requested', {
userId, meetingId, changedBy: requesterUserId, pin,
});
RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
} catch (err) {
Logger.error(`Exception while invoking method changePin ${err.stack}`);
}
}

View File

@ -24,6 +24,7 @@ export default function addDialInUser(meetingId, voiceUser) {
presenter: false,
locked: false, // TODO
avatar: '',
pin: false,
clientType: 'dial-in-user',
};

View File

@ -33,6 +33,7 @@ export default function addUser(meetingId, userData) {
presenter: Boolean,
locked: Boolean,
avatar: String,
pin: Boolean,
clientType: String,
});

View File

@ -0,0 +1,25 @@
import Logger from '/imports/startup/server/logger';
import Users from '/imports/api/users';
export default function changePin(meetingId, userId, pin, changedBy) {
const selector = {
meetingId,
userId,
};
const modifier = {
$set: {
pin,
},
};
try {
const numberAffected = Users.update(selector, modifier);
if (numberAffected) {
Logger.info(`Change pin=${pin} id=${userId} meeting=${meetingId} changedBy=${changedBy}`);
}
} catch (err) {
Logger.error(`Change pin error: ${err}`);
}
}

View File

@ -2,7 +2,9 @@ import RedisPubSub from '/imports/startup/server/redis';
import handleUserSharedHtml5Webcam from './handlers/userSharedHtml5Webcam';
import handleUserUnsharedHtml5Webcam from './handlers/userUnsharedHtml5Webcam';
import handleFloorChanged from './handlers/floorChanged';
import handlePinnedChanged from './handlers/userPinChanged';
RedisPubSub.on('UserBroadcastCamStartedEvtMsg', handleUserSharedHtml5Webcam);
RedisPubSub.on('UserBroadcastCamStoppedEvtMsg', handleUserUnsharedHtml5Webcam);
RedisPubSub.on('AudioFloorChangedEvtMsg', handleFloorChanged);
RedisPubSub.on('UserPinStateChangedEvtMsg', handlePinnedChanged);

View File

@ -0,0 +1,13 @@
import { check } from 'meteor/check';
import changePin from '../modifiers/changePin';
export default function userPinChanged({ body }, meetingId) {
const { userId, pin, changedBy } = body;
check(meetingId, String);
check(userId, String);
check(pin, Boolean);
check(changedBy, String);
return changePin(meetingId, userId, pin, changedBy);
}

View File

@ -1,8 +1,10 @@
import { Meteor } from 'meteor/meteor';
import userShareWebcam from './methods/userShareWebcam';
import userUnshareWebcam from './methods/userUnshareWebcam';
import ejectUserCameras from './methods/ejectUserCameras';
Meteor.methods({
userShareWebcam,
userUnshareWebcam,
ejectUserCameras,
});

View File

@ -0,0 +1,28 @@
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import Logger from '/imports/startup/server/logger';
import RedisPubSub from '/imports/startup/server/redis';
import { extractCredentials } from '/imports/api/common/server/helpers';
export default function ejectUserCameras(userId) {
try {
const REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'EjectUserCamerasCmdMsg';
const { meetingId, requesterUserId } = extractCredentials(this.userId);
check(meetingId, String);
check(requesterUserId, String);
check(userId, String);
Logger.info(`Requesting ejection of cameras: userToEject=${userId} requesterUserId=${requesterUserId}`);
const payload = {
userId,
};
RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, requesterUserId, payload);
} catch (err) {
Logger.error(`Exception while invoking method ejectUserCameras ${err.stack}`);
}
}

View File

@ -0,0 +1,26 @@
import Logger from '/imports/startup/server/logger';
import VideoStreams from '/imports/api/video-streams';
export default function changePin(meetingId, userId, pin) {
const selector = {
meetingId,
userId,
};
const modifier = {
$set: {
pin,
},
};
try {
const numberAffected = VideoStreams.update(selector, modifier, { multi: true });
if (numberAffected) {
Logger.info(`Updated user streams pinned userId=${userId} pinned=${pin}`);
}
} catch (error) {
return Logger.error(`Error updating stream pinned status: ${error}`);
}
return null;
}

View File

@ -6,6 +6,7 @@ import {
getUserName,
} from '/imports/api/video-streams/server/helpers';
import VoiceUsers from '/imports/api/voice-users/';
import Users from '/imports/api/users/';
const BASE_FLOOR_TIME = "0";
@ -20,7 +21,13 @@ export default function sharedWebcam(meetingId, userId, stream) {
{ meetingId, intId: userId },
{ fields: { floor: 1, lastFloorTime: 1 }}
) || {};
const u = Users.findOne(
{ meetingId, intId: userId },
{ fields: { pin: 1 } },
) || {};
const floor = vu.floor || false;
const pin = u.pin || false;
const lastFloorTime = vu.lastFloorTime || BASE_FLOOR_TIME;
const selector = {
@ -35,6 +42,7 @@ export default function sharedWebcam(meetingId, userId, stream) {
name,
lastFloorTime,
floor,
pin,
},
};

View File

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

View File

@ -214,7 +214,7 @@ class App extends Component {
window.ondragover = (e) => { e.preventDefault(); };
window.ondrop = (e) => { e.preventDefault(); };
if (isMobile()) makeCall('setMobileUser');
if (deviceInfo.isMobile) makeCall('setMobileUser');
ConnectionStatusService.startRoundTripTime();
@ -355,6 +355,7 @@ class App extends Component {
return (
<div
role="region"
className={styles.captionsWrapper}
style={
{
@ -382,6 +383,7 @@ class App extends Component {
return (
<section
role="region"
className={styles.actionsbar}
aria-label={intl.formatMessage(intlMessages.actionsBarLabel)}
aria-hidden={this.shouldAriaHide()}

View File

@ -77,6 +77,8 @@ const intlMessages = defineMessages({
},
});
let prevBreakoutData = {};
class BreakoutRoom extends PureComponent {
static sortById(a, b) {
if (a.userId > b.userId) {
@ -143,7 +145,9 @@ class BreakoutRoom extends PureComponent {
const breakoutUrlData = getBreakoutRoomUrl(requestedBreakoutId);
if (!breakoutUrlData) return false;
if (breakoutUrlData.redirectToHtml5JoinURL !== '') {
if (breakoutUrlData.redirectToHtml5JoinURL !== ''
&& breakoutUrlData.redirectToHtml5JoinURL !== prevBreakoutData.redirectToHtml5JoinURL) {
prevBreakoutData = breakoutUrlData;
window.open(breakoutUrlData.redirectToHtml5JoinURL, '_blank');
_.delay(() => this.setState({ generated: true, waiting: false }), 1000);
}

View File

@ -11,10 +11,7 @@ import {
removeAllListeners,
getPlayingState,
} from './service';
import {
isMobile, isTablet,
} from '../layout/utils';
import deviceInfo from '/imports/utils/deviceInfo';
import logger from '/imports/startup/client/logger';
@ -151,7 +148,6 @@ class VideoPlayer extends Component {
this.setPlaybackRate = this.setPlaybackRate.bind(this);
this.onBeforeUnload = this.onBeforeUnload.bind(this);
this.isMobile = isMobile() || isTablet();
this.mobileHoverSetTimeout = null;
}
@ -545,7 +541,8 @@ class VideoPlayer extends Component {
: styles.dontShowMobileHoverToolbar;
const desktopHoverToolBarStyle = styles.hoverToolbar;
const hoverToolbarStyle = this.isMobile ? mobileHoverToolBarStyle : desktopHoverToolBarStyle;
const hoverToolbarStyle = deviceInfo.isMobile ? mobileHoverToolBarStyle : desktopHoverToolBarStyle;
const isMinimized = width === 0 && height === 0;
return (
<span
@ -557,6 +554,7 @@ class VideoPlayer extends Component {
height,
width,
pointerEvents: isResizing ? 'none' : 'inherit',
display: isMinimized && 'none',
}}
>
<div
@ -616,7 +614,7 @@ class VideoPlayer extends Component {
{this.renderFullscreenButton()}
</div>
),
(this.isMobile && playing) && (
(deviceInfo.isMobile && playing) && (
<span
className={styles.mobileControlsOverlay}
key="mobile-overlay-external-video"

View File

@ -20,7 +20,7 @@
position: relative;
bottom: 3.5em;
left: 1em;
padding: 0.25rem 0.5rem;
min-width: 200px;
background-color: rgba(0,0,0,0.5);
border-radius: 32px;

View File

@ -6,15 +6,15 @@ import { styles } from './styles';
const intlMessages = defineMessages({
title: {
id: 'app.error.fallback.view.title',
id: 'app.error.fallback.presentation.title',
description: 'title for presentation when fallback is showed',
},
description: {
id: 'app.error.fallback.view.description',
id: 'app.error.fallback.presentation.description',
description: 'description for presentation when fallback is showed',
},
reloadButton: {
id: 'app.error.fallback.view.reloadButton',
id: 'app.error.fallback.presentation.reloadButton',
description: 'Button label when fallback is showed',
},
});

View File

@ -400,14 +400,17 @@ const reducer = (state, action) => {
// SIDEBAR CONTENT
case ACTIONS.SET_SIDEBAR_CONTENT_IS_OPEN: {
const { sidebarContent } = state.input;
const { sidebarContent, sidebarNavigation } = state.input;
if (sidebarContent.isOpen === action.value) {
return state;
}
// When opening content sidebar, the navigation sidebar should be opened as well
if (action.value === true) sidebarNavigation.isOpen = true;
return {
...state,
input: {
...state.input,
sidebarNavigation,
sidebarContent: {
...sidebarContent,
isOpen: action.value,

View File

@ -165,7 +165,7 @@ class CustomLayout extends Component {
type: ACTIONS.SET_LAYOUT_INPUT,
value: _.defaultsDeep({
sidebarNavigation: {
isOpen: input.sidebarNavigation.isOpen || false,
isOpen: input.sidebarNavigation.isOpen || sidebarContentPanel !== PANELS.NONE || false,
},
sidebarContent: {
isOpen: sidebarContentPanel !== PANELS.NONE,

View File

@ -99,7 +99,7 @@ class PresentationFocusLayout extends Component {
type: ACTIONS.SET_LAYOUT_INPUT,
value: defaultsDeep({
sidebarNavigation: {
isOpen: input.sidebarNavigation.isOpen || false,
isOpen: input.sidebarNavigation.isOpen || sidebarContentPanel !== PANELS.NONE || false,
},
sidebarContent: {
isOpen: sidebarContentPanel !== PANELS.NONE,

View File

@ -101,7 +101,7 @@ class SmartLayout extends Component {
type: ACTIONS.SET_LAYOUT_INPUT,
value: _.defaultsDeep({
sidebarNavigation: {
isOpen: input.sidebarNavigation.isOpen || false,
isOpen: input.sidebarNavigation.isOpen || sidebarContentPanel !== PANELS.NONE || false,
},
sidebarContent: {
isOpen: sidebarContentPanel !== PANELS.NONE,

View File

@ -104,7 +104,7 @@ class VideoFocusLayout extends Component {
value: defaultsDeep(
{
sidebarNavigation: {
isOpen: input.sidebarNavigation.isOpen || false,
isOpen: input.sidebarNavigation.isOpen || sidebarContentPanel !== PANELS.NONE || false,
},
sidebarContent: {
isOpen: sidebarContentPanel !== PANELS.NONE,

View File

@ -39,9 +39,9 @@ const getLearningDashboardAccessToken = () => ((
const setLearningDashboardCookie = () => {
const learningDashboardAccessToken = getLearningDashboardAccessToken();
if (learningDashboardAccessToken !== null) {
const cookieExpiresDate = new Date();
cookieExpiresDate.setTime(cookieExpiresDate.getTime() + (3600000 * 24 * 30)); // keep cookie 30d
document.cookie = `learningDashboardAccessToken-${Auth.meetingID}=${getLearningDashboardAccessToken()}; expires=${cookieExpiresDate.toGMTString()}; path=/`;
const lifetime = new Date();
lifetime.setTime(lifetime.getTime() + (3600000)); // 1h (extends 7d when open Dashboard)
document.cookie = `ld-${Auth.meetingID}=${getLearningDashboardAccessToken()}; expires=${lifetime.toGMTString()}; path=/`;
return true;
}
return false;

View File

@ -287,6 +287,7 @@ class SettingsDropdown extends PureComponent {
<Button
label={intl.formatMessage(intlMessages.optionsLabel)}
icon="more"
data-test="optionsButton"
ghost
circle
hideLabel

View File

@ -854,6 +854,7 @@ class Presentation extends PureComponent {
return (
<div
role="region"
ref={(ref) => { this.refPresentationContainer = ref; }}
className={styles.presentationContainer}
style={{

View File

@ -102,6 +102,20 @@ export default class Cursor extends Component {
// we need to find the BBox of the text, so that we could set a proper border box arount it
}
shouldComponentUpdate(nextProps) {
const { cursorX, cursorY, slideWidth, slideHeight } = this.props;
if (cursorX !== nextProps.cursorX || cursorY !== nextProps.cursorY) {
const cursorCoordinate = Cursor.getCursorCoordinates(
nextProps.cursorX,
nextProps.cursorY,
slideWidth,
slideHeight,
);
this.cursorCoordinate = cursorCoordinate;
}
return true;
}
componentDidUpdate(prevProps, prevState) {
const {
scaledSizes,
@ -147,16 +161,6 @@ export default class Cursor extends Component {
scaledSizes: Cursor.getScaledSizes(this.props, this.state),
});
}
if (cursorX !== prevProps.cursorX || cursorY !== prevProps.cursorY) {
const cursorCoordinate = Cursor.getCursorCoordinates(
cursorX,
cursorY,
slideWidth,
slideHeight,
);
this.cursorCoordinate = cursorCoordinate;
}
}
setLabelBoxDimensions(labelBoxWidth, labelBoxHeight) {

View File

@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import _ from 'lodash';
import FullscreenButtonContainer from '../fullscreen-button/container';
import SwitchButtonContainer from './switch-button/container';
import VolumeSlider from '../external-video-player/volume-slider/component';
import { styles } from './styles';
import AutoplayOverlay from '../media/autoplay-overlay/component';
import logger from '/imports/startup/client/logger';
@ -15,6 +16,8 @@ import {
screenshareHasStarted,
getMediaElement,
attachLocalPreviewStream,
setVolume,
getVolume,
} from '/imports/ui/components/screenshare/service';
import {
isStreamStateUnhealthy,
@ -22,6 +25,7 @@ import {
unsubscribeFromStreamStateChange,
} from '/imports/ui/services/bbb-webrtc-sfu/stream-state-service';
import { ACTIONS } from '/imports/ui/components/layout/enums';
import deviceInfo from '/imports/utils/deviceInfo';
const intlMessages = defineMessages({
screenShareLabel: {
@ -54,6 +58,7 @@ const intlMessages = defineMessages({
});
const ALLOW_FULLSCREEN = Meteor.settings.public.app.allowFullscreen;
const MOBILE_HOVER_TIMEOUT = 5000;
class ScreenshareComponent extends React.Component {
static renderScreenshareContainerInside(mainText) {
@ -71,6 +76,8 @@ class ScreenshareComponent extends React.Component {
autoplayBlocked: false,
isStreamHealthy: false,
switched: false,
// Volume control hover toolbar
showHoverToolBar: false,
};
this.onLoadedData = this.onLoadedData.bind(this);
@ -79,6 +86,11 @@ class ScreenshareComponent extends React.Component {
this.failedMediaElements = [];
this.onStreamStateChange = this.onStreamStateChange.bind(this);
this.onSwitched = this.onSwitched.bind(this);
this.handleOnVolumeChanged = this.handleOnVolumeChanged.bind(this);
this.handleOnMuted = this.handleOnMuted.bind(this);
this.volume = getVolume();
this.mobileHoverSetTimeout = null;
}
componentDidMount() {
@ -205,6 +217,19 @@ class ScreenshareComponent extends React.Component {
this.setState((prevState) => ({ switched: !prevState.switched }));
}
handleOnVolumeChanged(volume) {
this.volume = volume;
setVolume(volume);
}
handleOnMuted(muted) {
if (muted) {
setVolume(0);
} else {
setVolume(this.volume);
}
}
renderFullscreenButton() {
const { intl, fullscreenElementId, fullscreenContext } = this.props;
@ -247,6 +272,50 @@ class ScreenshareComponent extends React.Component {
);
}
renderMobileVolumeControlOverlay () {
return (
<span
className={styles.mobileControlsOverlay}
key="mobile-overlay-screenshare"
ref={(ref) => { this.overlay = ref; }}
onTouchStart={() => {
clearTimeout(this.mobileHoverSetTimeout);
this.setState({ showHoverToolBar: true });
}}
onTouchEnd={() => {
this.mobileHoverSetTimeout = setTimeout(
() => this.setState({ showHoverToolBar: false }),
MOBILE_HOVER_TIMEOUT,
);
}}
/>
);
}
renderVolumeSlider() {
const { showHoverToolBar } = this.state;
const mobileHoverToolBarStyle = showHoverToolBar
? styles.showMobileHoverToolbar
: styles.dontShowMobileHoverToolbar;
const desktopHoverToolBarStyle = styles.hoverToolbar;
const hoverToolbarStyle = deviceInfo.isMobile
? mobileHoverToolBarStyle
: desktopHoverToolBarStyle;
return [(
<div className={hoverToolbarStyle} key='hover-toolbar-screenshare'>
<VolumeSlider
volume={getVolume()}
muted={getVolume() === 0}
onVolumeChanged={this.handleOnVolumeChanged}
onMuted={this.handleOnMuted}
/>
</div>
),
(deviceInfo.isMobile) && this.renderMobileVolumeControlOverlay(),
];
}
renderVideo(switched) {
const { isGloballyBroadcasting } = this.props;
@ -300,7 +369,7 @@ class ScreenshareComponent extends React.Component {
}
renderScreenshareDefault() {
const { intl } = this.props;
const { intl, enableVolumeControl } = this.props;
const { loaded } = this.state;
return (
@ -313,6 +382,7 @@ class ScreenshareComponent extends React.Component {
>
{loaded && this.renderFullscreenButton()}
{this.renderVideo(true)}
{loaded && enableVolumeControl && this.renderVolumeSlider() }
<div className={styles.screenshareContainerDefault}>
{
@ -397,4 +467,5 @@ ScreenshareComponent.propTypes = {
formatMessage: PropTypes.func.isRequired,
}).isRequired,
isPresenter: PropTypes.bool.isRequired,
enableVolumeControl: PropTypes.bool.isRequired,
};

View File

@ -13,6 +13,7 @@ import {
import ScreenshareComponent from './component';
import LayoutContext from '../layout/context';
import getFromUserSettings from '/imports/ui/services/users-settings';
import { shouldEnableVolumeControl } from './service';
const ScreenshareContainer = (props) => {
const fullscreenElementId = 'Screenshare';
@ -52,5 +53,6 @@ export default withTracker(() => {
shouldEnableSwapLayout,
toggleSwapLayout: MediaService.toggleSwapLayout,
hidePresentation: getFromUserSettings('bbb_hide_presentation', LAYOUT_CONFIG.hidePresentation),
enableVolumeControl: shouldEnableVolumeControl(),
};
})(ScreenshareContainer);

View File

@ -11,6 +11,7 @@ import AudioService from '/imports/ui/components/audio/service';
import { Meteor } from "meteor/meteor";
import MediaStreamUtils from '/imports/utils/media-stream-utils';
const VOLUME_CONTROL_ENABLED = Meteor.settings.public.kurento.screenshare.enableVolumeControl;
const SCREENSHARE_MEDIA_ELEMENT_NAME = 'screenshareVideo';
/**
@ -89,6 +90,14 @@ const getMediaElement = () => {
return document.getElementById(SCREENSHARE_MEDIA_ELEMENT_NAME);
}
const setVolume = (volume) => {
KurentoBridge.setVolume(volume);
};
const getVolume = () => KurentoBridge.getVolume();
const shouldEnableVolumeControl = () => VOLUME_CONTROL_ENABLED && screenshareHasAudio();
const attachLocalPreviewStream = (mediaElement) => {
const stream = KurentoBridge.gdmStream;
if (stream && mediaElement) {
@ -183,6 +192,7 @@ export {
isVideoBroadcasting,
screenshareHasEnded,
screenshareHasStarted,
screenshareHasAudio,
shareScreen,
screenShareEndAlert,
dataSavingSetting,
@ -192,4 +202,7 @@ export {
attachLocalPreviewStream,
isGloballyBroadcasting,
getStats,
setVolume,
getVolume,
shouldEnableVolumeControl,
};

View File

@ -1,10 +1,34 @@
@import "/imports/ui/components/media/styles";
@import '/imports/ui/components/loading-screen/styles';
.screenshareContainer {
.hoverToolbar {
display: none;
:hover > & {
display: flex;
align-items: center;
justify-content: center;
}
}
.mobileControlsOverlay {
position: absolute;
top:0;
left: 0;
width: 100%;
height: 100%;
background-color: transparent;
}
.showMobileHoverToolbar {
display: flex;
z-index: 2;
}
.dontShowMobileHoverToolbar {
display: none;
}
.screenshareContainer {
position: relative;
background-color: var(--color-content-background);
width: 100%;
height: 100%;

View File

@ -325,6 +325,7 @@ const getUsersProp = () => {
{
fields: {
'usersProp.allowModsToUnmuteUsers': 1,
'usersProp.allowModsToEjectCameras': 1,
'usersProp.authenticatedGuest': 1,
},
},
@ -334,6 +335,7 @@ const getUsersProp = () => {
return {
allowModsToUnmuteUsers: false,
allowModsToEjectCameras: false,
authenticatedGuest: false,
};
};
@ -405,6 +407,10 @@ const getAvailableActions = (
const allowedToChangeWhiteboardAccess = amIPresenter
&& !amISubjectUser;
const allowedToEjectCameras = amIModerator
&& !amISubjectUser
&& usersProp.allowModsToEjectCameras;
return {
allowedToChatPrivately,
allowedToMuteAudio,
@ -417,6 +423,7 @@ const getAvailableActions = (
allowedToChangeStatus,
allowedToChangeUserLockStatus,
allowedToChangeWhiteboardAccess,
allowedToEjectCameras,
};
};
@ -457,6 +464,10 @@ const toggleVoice = (userId) => {
}
};
const ejectUserCameras = (userId) => {
makeCall('ejectUserCameras', userId);
};
const getEmoji = () => {
const currentUser = Users.findOne({ userId: Auth.userID },
{ fields: { emoji: 1 } });
@ -670,4 +681,5 @@ export default {
getUsersProp,
getUserCount,
sortUsersByCurrent,
ejectUserCameras,
};

View File

@ -52,6 +52,7 @@ class UserListItem extends PureComponent {
raiseHandPushAlert,
layoutContextDispatch,
isRTL,
ejectUserCameras,
} = this.props;
const contents = (
@ -90,6 +91,7 @@ class UserListItem extends PureComponent {
raiseHandPushAlert,
layoutContextDispatch,
isRTL,
ejectUserCameras,
}}
/>
);

View File

@ -33,6 +33,7 @@ export default withTracker(({ user }) => {
removeUser: UserListService.removeUser,
toggleUserLock: UserListService.toggleUserLock,
changeRole: UserListService.changeRole,
ejectUserCameras: UserListService.ejectUserCameras,
assignPresenter: UserListService.assignPresenter,
getAvailableActions: UserListService.getAvailableActions,
normalizeEmojiName: UserListService.normalizeEmojiName,

View File

@ -10,6 +10,7 @@ import Icon from '/imports/ui/components/icon/component';
import lockContextContainer from '/imports/ui/components/lock-viewers/context/container';
import { withModalMounter } from '/imports/ui/components/modal/service';
import RemoveUserModal from '/imports/ui/components/modal/remove-user/component';
import VideoService from '/imports/ui/components/video-provider/service';
import BBBMenu from '/imports/ui/components/menu/component';
import { styles } from './styles';
import UserName from '../user-name/component';
@ -49,6 +50,14 @@ const messages = defineMessages({
id: 'app.userList.menu.chat.label',
description: 'label for option to start a new private chat',
},
PinUserWebcam: {
id: 'app.userList.menu.webcamPin.label',
description: 'label for pin user webcam',
},
UnpinUserWebcam: {
id: 'app.userList.menu.webcamUnpin.label',
description: 'label for pin user webcam',
},
ClearStatusLabel: {
id: 'app.userList.menu.clearStatus.label',
description: 'Clear the emoji status of this user',
@ -69,6 +78,10 @@ const messages = defineMessages({
id: 'app.userList.menu.removeWhiteboardAccess.label',
description: 'label to remove user whiteboard access',
},
ejectUserCamerasLabel: {
id: 'app.userList.menu.ejectUserCameras.label',
description: 'label to eject user cameras',
},
RemoveUserLabel: {
id: 'app.userList.menu.removeUser.label',
description: 'Forcefully remove this user from the meeting',
@ -121,7 +134,9 @@ const messages = defineMessages({
const propTypes = {
compact: PropTypes.bool.isRequired,
user: PropTypes.shape({}).isRequired,
user: PropTypes.shape({
userId: PropTypes.string.isRequired,
}).isRequired,
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
@ -231,6 +246,7 @@ class UserDropdown extends PureComponent {
removeUser,
toggleVoice,
changeRole,
ejectUserCameras,
lockSettingsProps,
hasPrivateChatBetweenUsers,
toggleUserLock,
@ -244,7 +260,7 @@ class UserDropdown extends PureComponent {
layoutContextDispatch,
} = this.props;
const { showNestedOptions } = this.state;
const { clientType } = user;
const { clientType, isSharingWebcam, pin: userIsPinned } = user;
const isDialInUser = clientType === 'dial-in-user';
const amIPresenter = currentUser.presenter;
@ -266,6 +282,7 @@ class UserDropdown extends PureComponent {
allowedToChangeStatus,
allowedToChangeUserLockStatus,
allowedToChangeWhiteboardAccess,
allowedToEjectCameras,
} = actionPermissions;
const { disablePrivateChat } = lockSettingsProps;
@ -317,6 +334,21 @@ class UserDropdown extends PureComponent {
});
}
if (isSharingWebcam
&& isMeteorConnected
&& VideoService.isVideoPinEnabledForCurrentUser()) {
actions.push({
key: 'pinVideo',
label: userIsPinned
? intl.formatMessage(messages.UnpinUserWebcam)
: intl.formatMessage(messages.PinUserWebcam),
onClick: () => {
VideoService.toggleVideoPin(user.userId, userIsPinned);
},
icon: userIsPinned ? 'pin-video_off' : 'pin-video_on',
});
}
const showChatOption = CHAT_ENABLED
&& enablePrivateChat
&& !isDialInUser
@ -483,6 +515,22 @@ class UserDropdown extends PureComponent {
});
}
if (allowedToEjectCameras
&& user.isSharingWebcam
&& isMeteorConnected
&& !meetingIsBreakout
) {
actions.push({
key: 'ejectUserCameras',
label: intl.formatMessage(messages.ejectUserCamerasLabel),
onClick: () => {
this.onActionsHide(ejectUserCameras(user.userId));
this.handleClose();
},
icon: 'video_off',
});
}
return actions;
}

View File

@ -46,7 +46,22 @@ const intlMessages = defineMessages({
camBgAriaDesc: {
id: 'app.video.virtualBackground.camBgAriaDesc',
description: 'Label for virtual background button aria',
}
},
background: {
id: 'app.video.virtualBackground.background',
description: 'Label for the background word',
},
...IMAGE_NAMES.reduce((prev, imageName) => {
const id = imageName.split('.').shift();
return {
...prev,
[id]: {
id: `app.video.virtualBackground.${id}`,
description: `Label for the ${id} camera option`,
defaultMessage: '{background} {index}',
},
};
}, {})
});
const VirtualBgSelector = ({
@ -168,15 +183,20 @@ const VirtualBgSelector = ({
</>
{IMAGE_NAMES.map((imageName, index) => {
const label = intl.formatMessage(intlMessages[imageName.split('.').shift()], {
index: index + 2,
background: intl.formatMessage(intlMessages.background),
});
return (
<div key={`${imageName}-${index}`} style={{ position: 'relative' }}>
<Button
id={`${imageName}-${index}`}
label={capitalizeFirstLetter(imageName.split('.').shift())}
label={label}
tabIndex={disabled ? -1 : 0}
role="button"
className={thumbnailStyles.join(' ')}
aria-label={capitalizeFirstLetter(imageName.split('.').shift())}
aria-label={label}
aria-describedby={`vr-cam-btn-${index}`}
aria-pressed={currentVirtualBg?.name?.includes(imageName.split('.').shift())}
hideLabel
@ -190,7 +210,7 @@ const VirtualBgSelector = ({
node.click();
}} aria-hidden className={styles.thumbnail} src={getVirtualBackgroundThumbnail(imageName)} />
<div aria-hidden className="sr-only" id={`vr-cam-btn-${index}`}>
{intl.formatMessage(intlMessages.camBgAriaDesc, { 0: capitalizeFirstLetter(imageName.split('.').shift()) })}
{intl.formatMessage(intlMessages.camBgAriaDesc, { 0: label })}
</div>
</div>
)

View File

@ -129,7 +129,7 @@ class VideoProvider extends Component {
this.wsQueue = [];
this.restartTimeout = {};
this.restartTimer = {};
this.webRtcPeers = VideoService.getWebRtcPeers();
this.webRtcPeers = {};
this.outboundIceQueues = {};
this.videoTags = {};
@ -148,9 +148,10 @@ class VideoProvider extends Component {
componentDidMount() {
this._isMounted = true;
VideoService.updatePeerDictionaryReference(this.webRtcPeers);
this.ws.onopen = this.onWsOpen;
this.ws.onclose = this.onWsClose;
window.addEventListener('online', this.openWs);
window.addEventListener('offline', this.onWsClose);
@ -172,6 +173,8 @@ class VideoProvider extends Component {
}
componentWillUnmount() {
VideoService.updatePeerDictionaryReference({});
this.ws.onmessage = null;
this.ws.onopen = null;
this.ws.onclose = null;
@ -251,14 +254,23 @@ class VideoProvider extends Component {
this.setState({ socketOpen: true });
}
updateThreshold(numberOfPublishers) {
findAllPrivilegedStreams () {
const { streams } = this.props;
// Privileged streams are: floor holders
return streams.filter(stream => stream.floor || stream.pin);
}
updateQualityThresholds(numberOfPublishers) {
const { threshold, profile } = VideoService.getThreshold(numberOfPublishers);
if (profile) {
const publishers = Object.values(this.webRtcPeers)
const privilegedStreams = this.findAllPrivilegedStreams();
Object.values(this.webRtcPeers)
.filter(peer => peer.isPublisher)
.forEach((peer) => {
// 0 means no threshold in place. Reapply original one if needed
const profileToApply = (threshold === 0) ? peer.originalProfileId : profile;
// 1) Threshold 0 means original profile/inactive constraint
// 2) Privileged streams are: floor holders
const exempt = threshold === 0 || privilegedStreams.some(vs => vs.stream === peer.stream)
const profileToApply = exempt ? peer.originalProfileId : profile;
VideoService.applyCameraProfile(peer, profileToApply);
});
}
@ -302,7 +314,7 @@ class VideoProvider extends Component {
this.disconnectStreams(streamsToDisconnect);
if (CAMERA_QUALITY_THRESHOLDS_ENABLED) {
this.updateThreshold(this.props.totalNumberOfStreams);
this.updateQualityThresholds(this.props.totalNumberOfStreams);
}
}

View File

@ -6,6 +6,7 @@ import Meetings from '/imports/api/meetings';
import Users from '/imports/api/users';
import VideoStreams from '/imports/api/video-streams';
import UserListService from '/imports/ui/components/user-list/service';
import { meetingIsBreakout } from '/imports/ui/components/app/service';
import { makeCall } from '/imports/ui/services/api';
import { notify } from '/imports/ui/services/notification';
import deviceInfo from '/imports/utils/deviceInfo';
@ -28,6 +29,7 @@ const SFU_URL = Meteor.settings.public.kurento.wsUrl;
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
const ROLE_VIEWER = Meteor.settings.public.user.role_viewer;
const MIRROR_WEBCAM = Meteor.settings.public.app.mirrorOwnWebcam;
const PIN_WEBCAM = Meteor.settings.public.kurento.enableVideoPin;
const CAMERA_QUALITY_THRESHOLDS = Meteor.settings.public.kurento.cameraQualityThresholds.thresholds || [];
const {
paginationToggleEnabled: PAGINATION_TOGGLE_ENABLED,
@ -78,7 +80,11 @@ class VideoService {
}
this.updateNumberOfDevices();
}
this.webRtcPeers = {};
// FIXME this is abhorrent. Remove when peer lifecycle is properly decoupled
// from the React component's lifecycle. Any attempt at a half-baked
// decoupling will most probably generate problems - prlanzarin Dec 16 2021
this.webRtcPeersRef = {};
}
defineProperties(obj) {
@ -242,13 +248,15 @@ class VideoService {
// Page size refers only to the number of subscribers. Publishers are always
// shown, hence not accounted for
const nofPages = Math.ceil((numberOfSubscribers || numberOfPublishers) / pageSize);
const nofPages = Math.ceil(numberOfSubscribers / pageSize);
if (nofPages !== this.numberOfPages) {
this.numberOfPages = nofPages;
// Check if we have to page back on the current video page index due to a
// page ceasing to exist
if ((this.currentVideoPageIndex + 1) > this.numberOfPages) {
if (nofPages === 0) {
this.currentVideoPageIndex = 0;
} else if ((this.currentVideoPageIndex + 1) > this.numberOfPages) {
this.getPreviousVideoPage();
}
}
@ -364,11 +372,14 @@ class VideoService {
getVideoPage (streams, pageSize) {
// Publishers are taken into account for the page size calculations. They
// also appear on every page.
const [mine, others] = _.partition(streams, (vs => { return Auth.userID === vs.userId; }));
// also appear on every page. Same for pinned user.
const [filtered, others] = _.partition(streams, (vs) => Auth.userID === vs.userId || vs.pin);
// Separate pin from local cameras
const [pin, mine] = _.partition(filtered, (vs) => vs.pin);
// Recalculate total number of pages
this.setNumberOfPages(mine.length, others.length, pageSize);
this.setNumberOfPages(filtered.length, others.length, pageSize);
const chunkIndex = this.currentVideoPageIndex * pageSize;
// This is an extra check because pagination is globally in effect (hard
@ -379,10 +390,9 @@ class VideoService {
.slice(chunkIndex, (chunkIndex + pageSize)) || [];
if (getSortingMethod(sortingMethod).localFirst) {
return [...mine, ...paginatedStreams];
return [...pin, ...mine, ...paginatedStreams];
}
return [...paginatedStreams, ...mine];
return [...pin, ...paginatedStreams, ...mine];
}
getUsersIdFromVideoStreams() {
@ -394,6 +404,16 @@ class VideoService {
return usersId;
}
getVideoPinByUser(userId) {
const user = Users.findOne({ userId }, { fields: { pin: 1 } });
return user.pin;
}
toggleVideoPin(userId, userIsPinned) {
makeCall('changePin', userId, !userIsPinned);
}
getVideoStreams() {
const pageSize = this.getMyPageSize();
const isPaginationDisabled = !this.isPaginationEnabled() || pageSize === 0;
@ -571,6 +591,24 @@ class VideoService {
return isOwnWebcam && isEnabledMirroring;
}
isPinEnabled() {
return PIN_WEBCAM;
}
// In user-list it is necessary to check if the user is sharing his webcam
isVideoPinEnabledForCurrentUser() {
const currentUser = Users.findOne({ userId: Auth.userID },
{ fields: { role: 1 } });
const isModerator = currentUser.role === 'MODERATOR';
const isBreakout = meetingIsBreakout();
const isPinEnabled = this.isPinEnabled();
return !!(isModerator
&& isPinEnabled
&& !isBreakout);
}
getMyStreamId(deviceId) {
const videoStream = VideoStreams.findOne(
{
@ -830,14 +868,6 @@ class VideoService {
return VideoPreviewService.getStream(this.deviceId);
}
/**
* Getter for webRtcPeers hash, which stores a reference for all
* RTCPeerConnection objects.
*/
getWebRtcPeers() {
return this.webRtcPeers;
}
/**
* Get all active video peers.
* @returns An Object containing the reference for all active peers peers
@ -851,13 +881,11 @@ class VideoService {
if (!activeVideoStreams) return null;
const peers = this.getWebRtcPeers();
const activePeers = {};
activeVideoStreams.forEach((stream) => {
if (peers[stream.stream]) {
activePeers[stream.stream] = peers[stream.stream].peerConnection;
if (this.webRtcPeersRef[stream.stream]) {
activePeers[stream.stream] = this.webRtcPeersRef[stream.stream].peerConnection;
}
});
@ -902,6 +930,10 @@ class VideoService {
return stats;
}
updatePeerDictionaryReference(newRef) {
this.webRtcPeersRef = newRef;
}
}
const videoService = new VideoService();
@ -944,7 +976,11 @@ export default {
getPageChangeDebounceTime: () => { return PAGE_CHANGE_DEBOUNCE_TIME },
getUsersIdFromVideoStreams: () => videoService.getUsersIdFromVideoStreams(),
shouldRenderPaginationToggle: () => videoService.shouldRenderPaginationToggle(),
toggleVideoPin: (userId, pin) => videoService.toggleVideoPin(userId, pin),
getVideoPinByUser: (userId) => videoService.getVideoPinByUser(userId),
isVideoPinEnabledForCurrentUser: () => videoService.isVideoPinEnabledForCurrentUser(),
isPinEnabled: () => videoService.isPinEnabled(),
getPreloadedStream: () => videoService.getPreloadedStream(),
getWebRtcPeers: () => videoService.getWebRtcPeers(),
getStats: () => videoService.getStats(),
updatePeerDictionaryReference: (newRef) => videoService.updatePeerDictionaryReference(newRef),
};

View File

@ -3,6 +3,18 @@ import Auth from '/imports/ui/services/auth';
const DEFAULT_SORTING_MODE = 'LOCAL_ALPHABETICAL';
// pin first
export const sortPin = (s1, s2) => {
if (s1.pin) {
return -1;
} if (s2.pin) {
return 1;
}
return 0;
};
export const mandatorySorting = (s1, s2) => sortPin(s1, s2);
// lastFloorTime, descending
export const sortVoiceActivity = (s1, s2) => {
if (s2.lastFloorTime < s1.lastFloorTime) {
@ -12,7 +24,7 @@ export const sortVoiceActivity = (s1, s2) => {
} else return 0;
};
// lastFloorTime (descending) -> alphabetical -> local
// pin -> lastFloorTime (descending) -> alphabetical -> local
export const sortVoiceActivityLocal = (s1, s2) => {
if (s1.userId === Auth.userID) {
return 1;
@ -20,23 +32,22 @@ export const sortVoiceActivityLocal = (s1, s2) => {
return -1;
}
return sortVoiceActivity(s1, s2)
|| UserListService.sortUsersByName(s1, s2);
}
// local -> lastFloorTime (descending) -> alphabetical
export const sortLocalVoiceActivity = (s1, s2) => {
return UserListService.sortUsersByCurrent(s1, s2)
return mandatorySorting(s1, s2)
|| sortVoiceActivity(s1, s2)
|| UserListService.sortUsersByName(s1, s2);
}
// local -> alphabetic
export const sortLocalAlphabetical = (s1, s2) => {
return UserListService.sortUsersByCurrent(s1, s2)
|| UserListService.sortUsersByName(s1, s2);
};
// pin -> local -> lastFloorTime (descending) -> alphabetical
export const sortLocalVoiceActivity = (s1, s2) => mandatorySorting(s1, s2)
|| UserListService.sortUsersByCurrent(s1, s2)
|| sortVoiceActivity(s1, s2)
|| UserListService.sortUsersByName(s1, s2);
// pin -> local -> alphabetic
export const sortLocalAlphabetical = (s1, s2) => mandatorySorting(s1, s2)
|| UserListService.sortUsersByCurrent(s1, s2)
|| UserListService.sortUsersByName(s1, s2);
export const sortPresenter = (s1, s2) => {
if (UserListService.isUserPresenter(s1.userId)) {
return -1;
@ -45,12 +56,11 @@ export const sortPresenter = (s1, s2) => {
} else return 0;
};
// local -> presenter -> alphabetical
export const sortLocalPresenterAlphabetical = (s1, s2) => {
return UserListService.sortUsersByCurrent(s1, s2)
// pin -> local -> presenter -> alphabetical
export const sortLocalPresenterAlphabetical = (s1, s2) => mandatorySorting(s1, s2)
|| UserListService.sortUsersByCurrent(s1, s2)
|| sortPresenter(s1, s2)
|| UserListService.sortUsersByName(s1, s2);
};
// SORTING_METHODS: registrar of configurable video stream sorting modes
// Keys are the method name (String) which are to be configured in settings.yml
@ -75,7 +85,9 @@ export const sortLocalPresenterAlphabetical = (s1, s2) => {
// 1.1.: the sorting function has the same behaviour as a regular .sort callback
// 2 - add an entry to SORTING_METHODS, the key being the name to be used
// in settings.yml and the value object like the aforementioned
const MANDATORY_DATA_TYPES = { userId: 1, stream: 1, name: 1, deviceId: 1, };
const MANDATORY_DATA_TYPES = {
userId: 1, stream: 1, name: 1, deviceId: 1, floor: 1, pin: 1,
};
const SORTING_METHODS = Object.freeze({
// Default
LOCAL_ALPHABETICAL: {
@ -120,5 +132,7 @@ export const sortVideoStreams = (streams, mode) => {
stream: videoStream.stream,
userId: videoStream.userId,
name: videoStream.name,
floor: videoStream.floor,
pin: videoStream.pin,
}));
};

View File

@ -23,24 +23,6 @@ const propTypes = {
};
const intlMessages = defineMessages({
focusLabel: {
id: 'app.videoDock.webcamFocusLabel',
},
focusDesc: {
id: 'app.videoDock.webcamFocusDesc',
},
unfocusLabel: {
id: 'app.videoDock.webcamUnfocusLabel',
},
unfocusDesc: {
id: 'app.videoDock.webcamUnfocusDesc',
},
mirrorLabel: {
id: 'app.videoDock.webcamMirrorLabel',
},
mirrorDesc: {
id: 'app.videoDock.webcamMirrorDesc',
},
autoplayBlockedDesc: {
id: 'app.videoDock.autoplayBlockedDesc',
},
@ -77,8 +59,6 @@ const findOptimalGrid = (canvasWidth, canvasHeight, gutter, aspectRatio, numItem
};
const ASPECT_RATIO = 4 / 3;
const ACTION_NAME_FOCUS = 'focus';
const ACTION_NAME_MIRROR = 'mirror';
// const ACTION_NAME_BACKGROUND = 'blurBackground';
class VideoList extends Component {
@ -93,7 +73,6 @@ class VideoList extends Component {
filledArea: 0,
},
autoplayBlocked: false,
mirroredCameras: [],
};
this.ticking = false;
@ -108,6 +87,7 @@ class VideoList extends Component {
this.setOptimalGrid = this.setOptimalGrid.bind(this);
this.handleAllowAutoplay = this.handleAllowAutoplay.bind(this);
this.handlePlayElementFailed = this.handlePlayElementFailed.bind(this);
this.handleVideoFocus = this.handleVideoFocus.bind(this);
this.autoplayWasHandled = false;
}
@ -249,24 +229,6 @@ class VideoList extends Component {
});
}
mirrorCamera(stream) {
const { mirroredCameras } = this.state;
if (this.cameraIsMirrored(stream)) {
this.setState({
mirroredCameras: mirroredCameras.filter((x) => x !== stream),
});
} else {
this.setState({
mirroredCameras: mirroredCameras.concat([stream]),
});
}
}
cameraIsMirrored(stream) {
const { mirroredCameras } = this.state;
return mirroredCameras.indexOf(stream) >= 0;
}
displayPageButtons() {
const { numberOfPages, cameraDock } = this.props;
const { width: cameraDockWidth } = cameraDock;
@ -350,7 +312,6 @@ class VideoList extends Component {
renderVideoList() {
const {
intl,
streams,
onVideoItemMount,
onVideoItemUnmount,
@ -361,24 +322,7 @@ class VideoList extends Component {
return streams.map((vs) => {
const { stream, userId, name } = vs;
const isFocused = focusedId === stream;
const isFocusedIntlKey = !isFocused ? 'focus' : 'unfocus';
const isMirrored = this.cameraIsMirrored(stream);
const actions = [{
actionName: ACTION_NAME_MIRROR,
label: intl.formatMessage(intlMessages.mirrorLabel),
description: intl.formatMessage(intlMessages.mirrorDesc),
onClick: () => this.mirrorCamera(stream),
}];
if (numOfStreams > 2) {
actions.push({
actionName: ACTION_NAME_FOCUS,
label: intl.formatMessage(intlMessages[`${isFocusedIntlKey}Label`]),
description: intl.formatMessage(intlMessages[`${isFocusedIntlKey}Desc`]),
onClick: () => this.handleVideoFocus(stream),
});
}
const isFocused = focusedId === stream && numOfStreams > 2;
return (
<div
@ -393,8 +337,8 @@ class VideoList extends Component {
cameraId={stream}
userId={userId}
name={name}
mirrored={isMirrored}
actions={actions}
focused={isFocused}
onHandleVideoFocus={this.handleVideoFocus}
onVideoItemMount={(videoRef) => {
this.handleCanvasResize();
onVideoItemMount(stream, videoRef);

View File

@ -306,3 +306,52 @@
flex-basis: 100%;
height: 5px;
}
.wrapper {
position: absolute;
top: 0;
left: auto;
background-color: var(--color-transparent);
cursor: pointer;
border: 0;
z-index: 2;
margin: 2px;
[dir="rtl"] & {
right: auto;
left :0;
}
[class*="presentationZoomControls"] & {
position: relative !important;
}
}
.button {
padding: 5px;
&,
&:active,
&:hover,
&:focus {
background-color: var(--color-transparent) !important;
border: none !important;
i {
border: none !important;
color: var(--color-white);
font-size: 1rem;
background-color: var(--color-transparent) !important;
}
}
}
.dark {
background-color: rgba(0,0,0,.3);
}
.pinIcon {
position: absolute;
left: 2px;
font-size: 2rem;
top: 2px;
}

View File

@ -1,10 +1,12 @@
import React, { Component } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import browserInfo from '/imports/utils/browserInfo';
import { Meteor } from 'meteor/meteor';
import PropTypes from 'prop-types';
import cx from 'classnames';
import BBBMenu from '/imports/ui/components/menu/component';
import Icon from '/imports/ui/components/icon/component';
import Button from '/imports/ui/components/button/component';
import FullscreenService from '/imports/ui/components/fullscreen-button/service';
import FullscreenButtonContainer from '/imports/ui/components/fullscreen-button/container';
import { styles } from '../styles';
@ -20,6 +22,42 @@ const ALLOW_FULLSCREEN = Meteor.settings.public.app.allowFullscreen;
const { isSafari } = browserInfo;
const FULLSCREEN_CHANGE_EVENT = isSafari ? 'webkitfullscreenchange' : 'fullscreenchange';
const intlMessages = defineMessages({
focusLabel: {
id: 'app.videoDock.webcamFocusLabel',
},
focusDesc: {
id: 'app.videoDock.webcamFocusDesc',
},
unfocusLabel: {
id: 'app.videoDock.webcamUnfocusLabel',
},
unfocusDesc: {
id: 'app.videoDock.webcamUnfocusDesc',
},
pinLabel: {
id: 'app.videoDock.webcamPinLabel',
},
pinDesc: {
id: 'app.videoDock.webcamPinDesc',
},
unpinLabel: {
id: 'app.videoDock.webcamUnpinLabel',
},
unpinLabelDisabled: {
id: 'app.videoDock.webcamUnpinLabelDisabled',
},
unpinDesc: {
id: 'app.videoDock.webcamUnpinDesc',
},
mirrorLabel: {
id: 'app.videoDock.webcamMirrorLabel',
},
mirrorDesc: {
id: 'app.videoDock.webcamMirrorDesc',
},
});
class VideoListItem extends Component {
constructor(props) {
super(props);
@ -29,6 +67,7 @@ class VideoListItem extends Component {
videoIsReady: false,
isFullscreen: false,
isStreamHealthy: false,
isMirrored: false,
};
this.mirrorOwnWebcam = VideoService.mirrorOwnWebcam(props.userId);
@ -127,31 +166,51 @@ class VideoListItem extends Component {
getAvailableActions() {
const {
actions,
intl,
cameraId,
name,
numOfStreams,
onHandleVideoFocus,
user,
focused,
} = this.props;
const MAX_WIDTH = 640;
const fullWidthMenu = window.innerWidth < MAX_WIDTH;
const menuItems = [];
if (fullWidthMenu) menuItems.push({
key: `${cameraId}-${name}`,
label: name,
onClick: () => {},
disabled: true,
})
actions?.map((a, i) => {
let topDivider = false;
if (i === 0 && fullWidthMenu) topDivider = true;
const pinned = user?.pin;
const userId = user?.userId;
const isPinnedIntlKey = !pinned ? 'pin' : 'unpin';
const isFocusedIntlKey = !focused ? 'focus' : 'unfocus';
const menuItems = [{
key: `${cameraId}-mirror`,
label: intl.formatMessage(intlMessages.mirrorLabel),
description: intl.formatMessage(intlMessages.mirrorDesc),
onClick: () => this.mirrorCamera(cameraId),
}];
if (numOfStreams > 2) {
menuItems.push({
key: `${cameraId}-${a?.actionName}`,
label: a?.label,
description: a?.description,
onClick: a?.onClick,
dividerTop: topDivider,
key: `${cameraId}-focus`,
label: intl.formatMessage(intlMessages[`${isFocusedIntlKey}Label`]),
description: intl.formatMessage(intlMessages[`${isFocusedIntlKey}Desc`]),
onClick: () => onHandleVideoFocus(cameraId),
});
}
if (VideoService.isVideoPinEnabledForCurrentUser()) {
menuItems.push({
key: `${cameraId}-pin`,
label: intl.formatMessage(intlMessages[`${isPinnedIntlKey}Label`]),
description: intl.formatMessage(intlMessages[`${isPinnedIntlKey}Desc`]),
onClick: () => VideoService.toggleVideoPin(userId, pinned),
});
return menuItems
}
return menuItems;
}
mirrorCamera() {
const { isMirrored } = this.state;
this.setState({ isMirrored: !isMirrored });
}
renderFullscreenButton() {
@ -173,16 +232,50 @@ class VideoListItem extends Component {
);
}
renderPinButton() {
const { user, intl } = this.props;
const pinned = user?.pin;
const userId = user?.userId;
const shouldRenderPinButton = pinned && userId;
const videoPinActionAvailable = VideoService.isVideoPinEnabledForCurrentUser();
if (!shouldRenderPinButton) return null;
const wrapperClassName = cx({
[styles.wrapper]: true,
[styles.dark]: true,
});
return (
<div className={wrapperClassName}>
<Button
color="default"
icon={!pinned ? 'pin-video_on' : 'pin-video_off'}
size="sm"
onClick={() => VideoService.toggleVideoPin(userId, true)}
label={videoPinActionAvailable
? intl.formatMessage(intlMessages.unpinLabel)
: intl.formatMessage(intlMessages.unpinLabelDisabled)}
hideLabel
disabled={!videoPinActionAvailable}
className={styles.button}
data-test="pinVideoButton"
/>
</div>
);
}
render() {
const {
videoIsReady,
isStreamHealthy,
isMirrored,
} = this.state;
const {
name,
user,
voiceUser,
numOfStreams,
mirrored,
isFullscreenContext,
} = this.props;
const availableActions = this.getAvailableActions();
@ -190,13 +283,17 @@ class VideoListItem extends Component {
const shouldRenderReconnect = !isStreamHealthy && videoIsReady;
const { isFirefox } = browserInfo;
const talking = voiceUser?.talking;
const listenOnly = voiceUser?.listenOnly;
const muted = voiceUser?.muted;
const voiceUserJoined = voiceUser?.joined;
return (
<div
data-test={voiceUser.talking ? 'webcamItemTalkingUser' : 'webcamItem'}
data-test={talking ? 'webcamItemTalkingUser' : 'webcamItem'}
className={cx({
[styles.content]: true,
[styles.talking]: voiceUser.talking,
[styles.talking]: talking,
[styles.fullscreen]: isFullscreenContext,
})}
>
@ -208,7 +305,7 @@ class VideoListItem extends Component {
className={cx({
[styles.connecting]: true,
[styles.content]: true,
[styles.talking]: voiceUser.talking,
[styles.talking]: talking,
})}
>
<span className={styles.loadingText}>{name}</span>
@ -231,8 +328,7 @@ class VideoListItem extends Component {
data-test={this.mirrorOwnWebcam ? 'mirroredVideoContainer' : 'videoContainer'}
className={cx({
[styles.media]: true,
[styles.mirroredVideo]: (this.mirrorOwnWebcam && !mirrored)
|| (!this.mirrorOwnWebcam && mirrored),
[styles.mirroredVideo]: isMirrored,
[styles.unhealthyStream]: shouldRenderReconnect,
})}
ref={(ref) => { this.videoTag = ref; }}
@ -240,6 +336,7 @@ class VideoListItem extends Component {
playsInline
/>
{videoIsReady && this.renderFullscreenButton()}
{videoIsReady && this.renderPinButton()}
</div>
{videoIsReady
&& (
@ -274,9 +371,9 @@ class VideoListItem extends Component {
</span>
</div>
)}
{voiceUser.muted && !voiceUser.listenOnly ? <Icon className={styles.muted} iconName="unmute_filled" /> : null}
{voiceUser.listenOnly ? <Icon className={styles.voice} iconName="listen" /> : null}
{voiceUser.joined && !voiceUser.muted ? <Icon className={styles.voice} iconName="unmute" /> : null}
{muted && !listenOnly ? <Icon className={styles.muted} iconName="unmute_filled" /> : null}
{listenOnly ? <Icon className={styles.voice} iconName="listen" /> : null}
{voiceUserJoined && !muted ? <Icon className={styles.voice} iconName="unmute" /> : null}
</div>
)}
</div>
@ -284,15 +381,29 @@ class VideoListItem extends Component {
}
}
export default VideoListItem;
export default injectIntl(VideoListItem);
VideoListItem.defaultProps = {
numOfStreams: 0,
user: null,
};
VideoListItem.propTypes = {
actions: PropTypes.arrayOf(PropTypes.object).isRequired,
cameraId: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
numOfStreams: PropTypes.number,
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
onHandleVideoFocus: PropTypes.func.isRequired,
user: PropTypes.shape({
pin: PropTypes.bool.isRequired,
userId: PropTypes.string.isRequired,
}).isRequired,
voiceUser: PropTypes.shape({
muted: PropTypes.bool.isRequired,
listenOnly: PropTypes.bool.isRequired,
talking: PropTypes.bool.isRequired,
}).isRequired,
focused: PropTypes.bool.isRequired,
};

View File

@ -2,6 +2,7 @@ import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { withTracker } from 'meteor/react-meteor-data';
import VoiceUsers from '/imports/api/voice-users/';
import Users from '/imports/api/users/';
import VideoListItem from './component';
import LayoutContext from '/imports/ui/components/layout/context';
@ -32,6 +33,8 @@ export default withTracker((props) => {
return {
voiceUser: VoiceUsers.findOne({ intId: userId },
{ fields: { muted: 1, listenOnly: 1, talking: 1 } }),
user: Users.findOne({ intId: userId },
{ fields: { pin: 1, userId: 1 } }),
};
})(VideoListItemContainer);

View File

@ -249,6 +249,7 @@ const WebcamComponent = ({
>
<div
id="cameraDock"
role="region"
className={draggableClassName}
draggable={cameraDock.isDraggable && !isFullscreen ? 'true' : undefined}
style={{

View File

@ -2,6 +2,7 @@ import browserInfo from '/imports/utils/browserInfo';
import deviceInfo from '/imports/utils/deviceInfo';
const FORCE_RELAY_ON_FF = Meteor.settings.public.kurento.forceRelayOnFirefox;
const FORCE_RELAY = Meteor.settings.public.media.forceRelay;
/*
* Whether TURN/relay usage should be forced to work around Firefox's lack of
@ -15,7 +16,7 @@ const shouldForceRelay = () => {
const { isFirefox } = browserInfo;
const { isIos } = deviceInfo;
return (isFirefox && !isIos) && FORCE_RELAY_ON_FF;
return FORCE_RELAY || ((isFirefox && !isIos) && FORCE_RELAY_ON_FF);
};
export {

View File

@ -18,6 +18,52 @@ import {
const blurValue = '25px';
function drawImageProp(ctx, img, x, y, w, h, offsetX, offsetY) {
if (arguments.length === 2) {
x = y = 0;
w = ctx.canvas.width;
h = ctx.canvas.height;
}
// Default offset is center
offsetX = typeof offsetX === 'number' ? offsetX : 0.5;
offsetY = typeof offsetY === 'number' ? offsetY : 0.5;
// Keep bounds [0.0, 1.0]
if (offsetX < 0) offsetX = 0;
if (offsetY < 0) offsetY = 0;
if (offsetX > 1) offsetX = 1;
if (offsetY > 1) offsetY = 1;
const iw = img.width,
ih = img.height,
r = Math.min(w / iw, h / ih);
let nw = iw * r,
nh = ih * r,
cx, cy, cw, ch, ar = 1;
// Decide which gap to fill
if (nw < w) ar = w / nw;
if (Math.abs(ar - 1) < 1e-14 && nh < h) ar = h / nh;
nw *= ar;
nh *= ar;
// Calc source rectangle
cw = iw / (nw / w);
ch = ih / (nh / h);
cx = (iw - cw) * offsetX;
cy = (ih - ch) * offsetY;
// Make sure source rectangle is valid
if (cx < 0) cx = 0;
if (cy < 0) cy = 0;
if (cw > iw) cw = iw;
if (ch > ih) ch = ih;
ctx.drawImage(img, cx, cy, cw, ch, x, y, w, h);
}
class VirtualBackgroundService {
_model;
@ -104,12 +150,15 @@ class VirtualBackgroundService {
this._outputCanvasCtx.globalCompositeOperation = 'destination-over';
if (this._options.virtualBackground.isVirtualBackground) {
this._outputCanvasCtx.drawImage(
drawImageProp(
this._outputCanvasCtx,
this._virtualImage,
0,
0,
this._inputVideoElement.width,
this._inputVideoElement.height
this._inputVideoElement.height,
0.5,
0.5,
);
} else {
this._outputCanvasCtx.filter = `blur(${blurValue})`;

View File

@ -12,6 +12,7 @@
"test-visual-regression": "export REGRESSION_TESTING=true;env $(cat ../bigbluebutton-tests/puppeteer/.env | xargs) jest all.test.js --color --detectOpenHandles --forceExit",
"test-visual-regression:recording": "export WITH_RECORD=true;export REGRESSION_TESTING=true;env $(cat ../bigbluebutton-tests/puppeteer/.env | xargs) jest all.test.js --color --detectOpenHandles --forceExit",
"lint": "eslint . --ext .jsx,.js",
"lint:file": "eslint",
"preinstall": "npx npm-force-resolutions"
},
"meteor": {

View File

@ -214,6 +214,7 @@ public:
# Experiment(al). Controls whether ICE candidates should be signaled.
# Applies to webcams, listen only and screen sharing. True is "stable behavior".
signalCandidates: true
# Forces relay usage only on Firefox. Applies to listen only, webcams and screenshare.
forceRelayOnFirefox: false
cameraTimeouts:
# Base camera timeout: used as the camera *sharing* timeout and
@ -223,6 +224,8 @@ public:
# subscribe reattempt increases the reconnection timer up to this
maxTimeout: 60000
screenshare:
# Whether volume control should be allowed if screen sharing has audio
enableVolumeControl: false
# Experimental. True is the canonical behavior. Flip to false to reverse
# the negotiation flow for subscribers.
subscriberOffering: false
@ -329,6 +332,7 @@ public:
enableScreensharing: true
enableVideo: true
enableVideoMenu: true
enableVideoPin: false
enableListenOnly: true
# Experimental. Server wide configuration to choose which bbb-webrtc-sfu
# media server adapter should be used for listen only.
@ -521,6 +525,9 @@ public:
stunTurnServersFetchAddress: '/bigbluebutton/api/stuns'
cacheStunTurnServers: true
fallbackStunServer: ''
# Forces relay usage on all browsers, environments and media modules.
# If true, supersedes public.kurento.forceRelayOnFirefox
forceRelay: false
mediaTag: '#remote-media'
callTransferTimeout: 5000
callHangupTimeout: 2000

View File

@ -90,6 +90,7 @@
"app.userList.menu.unmuteUserAudio.label": "إلغاء كتم صوت المستخدم",
"app.userList.menu.giveWhiteboardAccess.label" : "امنح الوصول إلى السبورة",
"app.userList.menu.removeWhiteboardAccess.label": "إزالة الوصول إلى السبورة",
"app.userList.menu.ejectUserCameras.label": "أغلق الكاميرات",
"app.userList.userAriaLabel": "{0} {1} {2} حالات {3}",
"app.userList.menu.promoteUser.label": "الترقية إلى مشرف",
"app.userList.menu.demoteUser.label": "تخفيض الى المشاهد",
@ -587,7 +588,7 @@
"app.guest.errorSeeConsole": "خطأ: مزيد من التفاصيل في وحدة التحكم.",
"app.guest.noModeratorResponse": "لا يوجد رد من المشرف.",
"app.guest.noSessionToken": "لم يتم استلام رمز جلسة.",
"app.guest.windowTitle": "ردهة الضيوف",
"app.guest.windowTitle": "BigBlueButton - ردهة الضيوف",
"app.guest.missingToken": "الضيف يفتقد رمز الجلسة.",
"app.guest.missingSession": "الضيف فقد الجلسة.",
"app.guest.missingMeeting": "الاجتماع غير موجود.",
@ -718,6 +719,7 @@
"app.video.joinVideo": "مشاركة كاميرا",
"app.video.connecting": "جارٍ بدء مشاركة كاميرا الويب ...",
"app.video.leaveVideo": "إلغاء مشاركة الكاميرا",
"app.video.advancedVideo": "افتح الإعدادات المتقدمة",
"app.video.iceCandidateError": "خطأ في إضافة مرشح ICE",
"app.video.iceConnectionStateError": "فشل الاتصال (خطأ ICE 1107)",
"app.video.permissionError": "خطأ في مشاركة الكاميرا . يرجى التحقق من الأذونات",
@ -750,6 +752,10 @@
"app.video.clientDisconnected": "لا يمكن مشاركة كاميرا الويب بسبب مشاكل الاتصال",
"app.video.virtualBackground.none": "لا أحد",
"app.video.virtualBackground.blur": "خلفية ضبابية",
"app.video.virtualBackground.home": "الرئيسية",
"app.video.virtualBackground.board": "اللوح",
"app.video.virtualBackground.coffeeshop": "مقهى",
"app.video.virtualBackground.background": "خلفية",
"app.video.virtualBackground.genericError": "فشل في تطبيق تأثير الكاميرا. حاول مجددا.",
"app.video.virtualBackground.camBgAriaDesc": "تعيين الخلفية الافتراضية لكاميرا الويب على {0}",
"app.video.dropZoneLabel": "أفلت هنا",
@ -917,8 +923,6 @@
"playback.player.video.wrapper.aria": "مساحة الفيديو",
"app.learningDashboard.dashboardTitle": "لوحة التعلم",
"app.learningDashboard.user": "مستخدم",
"app.learningDashboard.shareButton": "شارك مع الآخرين",
"app.learningDashboard.shareLinkCopied": "تم نسخ الرابط بنجاح!",
"app.learningDashboard.indicators.meetingStatusEnded": "انتهت",
"app.learningDashboard.indicators.meetingStatusActive": "فعًال",
"app.learningDashboard.indicators.usersOnline": "المستخدمين النشطين",

View File

@ -7,7 +7,7 @@
"app.chat.inputLabel": "Çata daxil edilmiş mesaj {0}",
"app.chat.inputPlaceholder": "{0} mesaj göndər",
"app.chat.titlePublic": "Ortaq Çat",
"app.chat.titlePrivate": "{0} Şəxşi Çat",
"app.chat.titlePrivate": "{0} ilə şəxsi çat",
"app.chat.partnerDisconnected": "{0} görüşü tərk etdi",
"app.chat.closeChatLabel": "Bağla {0}",
"app.chat.hideChatLabel": "Gizlət {0}",
@ -18,7 +18,7 @@
"app.chat.dropdown.save": "Saxla",
"app.chat.label": "Çat",
"app.chat.offline": "Oflayn",
"app.chat.pollResult": "Anket nəticələri",
"app.chat.pollResult": "Sorğu nəticələri",
"app.chat.emptyLogLabel": "Çat jurnalı boşdur",
"app.chat.clearPublicChatMessage": "Çat tarixçəsi moderator tərəfindən təmizləndi.",
"app.chat.multi.typing": "Bir neçə istifadəçi yazır",
@ -31,15 +31,15 @@
"app.captions.menu.ariaStartDesc": "Mövzu redaktoru açılır və modallar bağlanır",
"app.captions.menu.select": "Dil seçimi et",
"app.captions.menu.ariaSelect": "Dillər",
"app.captions.menu.subtitle": "Zəhmət olmasa sessiyanızda qapalı başlıqlar üçün bir dil və üslub seçin.",
"app.captions.menu.title": "Başlığı bağla",
"app.captions.menu.subtitle": "Zəhmət olmasa sessiyanızda alt yazılar üçün bir dil və üslub seçin.",
"app.captions.menu.title": "Alt yazılar",
"app.captions.menu.fontSize": "Ölçü",
"app.captions.menu.fontColor": "Yazı rəngi",
"app.captions.menu.fontFamily": "Şrift",
"app.captions.menu.backgroundColor": "Fon rəngi",
"app.captions.menu.previewLabel": "Özizləmə",
"app.captions.menu.cancelLabel": "Ləğv et",
"app.captions.pad.hide": "Bağlı başlıqları gizlət",
"app.captions.pad.hide": "Alt yazını gizlət",
"app.captions.pad.tip": "Alətlər paneli üçün Esc düyməsini sıxın",
"app.captions.pad.ownership": "Öhdənə götür",
"app.captions.pad.ownershipTooltip": "{0} başlıqlarının admini təyin edin.",
@ -48,10 +48,12 @@
"app.captions.pad.dictationStop": "Nitqin tanınmasını bitir",
"app.captions.pad.dictationOnDesc": "Yazı səhvlərini yoxla",
"app.captions.pad.dictationOffDesc": "Nitq tanınmasını söndür",
"app.textInput.sendLabel": "Göndər",
"app.note.title": "Qeydləri bölüş",
"app.note.label": "Qeyd",
"app.note.hideNoteLabel": "Qeydləri gözlət",
"app.note.tipLabel": "Diqqəti redaktora yönləndirmək üçün ESC düyməsini sıxın.",
"app.note.locked": "Kilidlənib",
"app.user.activityCheck": "İstifadəçi fəaliyyətini yoxla",
"app.user.activityCheck.label": "({0}) istifadəçilərin sessiyada olduğunu yoxlayın",
"app.user.activityCheck.check": "Yoxla",
@ -67,8 +69,13 @@
"app.userList.byModerator": "by (Moderator)",
"app.userList.label": "İstifadəçi siyahısı",
"app.userList.toggleCompactView.label": "Kompakt modu aktivləşdir",
"app.userList.moderator": "Moderator",
"app.userList.mobile": "Mobil",
"app.userList.guest": "Qonaq",
"app.userList.sharingWebcam": "Veb kamera",
"app.userList.menuTitleContext": "Mümkün variantlar",
"app.userList.chatListItem.unreadSingular": "Bir yeni mesaj",
"app.userList.chatListItem.unreadPlural": "{0} yeni mesaj",
"app.userList.menu.chat.label": "Xüsusi çatı başlat",
"app.userList.menu.clearStatus.label": "Statusu təmizlə",
"app.userList.menu.removeUser.label": "İstifadəçini sil",
@ -76,11 +83,12 @@
"app.userlist.menu.removeConfirmation.desc": "Bu istifadəçinin iclasa yenidən qoşulmasının qarşısını alın.",
"app.userList.menu.muteUserAudio.label": "İstifadəçini susdur",
"app.userList.menu.unmuteUserAudio.label": "İstifadəçini danışdır",
"app.userList.menu.ejectUserCameras.label": "Kameraları bağla",
"app.userList.userAriaLabel": "{0} {1} {2} Status {3}",
"app.userList.menu.promoteUser.label": "Moderatora yüksəldin",
"app.userList.menu.demoteUser.label": "İzləyici et",
"app.userList.menu.unlockUser.label": "ıq {0}",
"app.userList.menu.lockUser.label": "Kilidli {0}",
"app.userList.menu.unlockUser.label": "Kilidi aç: {0}",
"app.userList.menu.lockUser.label": "Kilidlə: {0}",
"app.userList.menu.directoryLookup.label": "Qovluq axtarışı",
"app.userList.menu.makePresenter.label": "Təqdimatçı et",
"app.userList.userOptions.manageUsersLabel": "İstifadəçiləri düzəlt",
@ -174,6 +182,7 @@
"app.presentationUploder.currentBadge": "Mövcud",
"app.presentationUploder.rejectedError": "Fayl yükləmək mümkün olmadı. Zəhmət olmasa yoxlayın.",
"app.presentationUploder.upload.progress": "Yüklənir ({0}%)",
"app.presentationUploder.upload.413": "Fayl çox böyükdür, {0} MB limitini aşır.",
"app.presentationUploder.genericError": "Ups, bir şey səhv oldu ...",
"app.presentationUploder.upload.408": "Nişan yükləmə vaxtı keçməsini tələb edin.",
"app.presentationUploder.upload.404": "404: Yanlış yükləmə işarəsi",
@ -187,6 +196,7 @@
"app.presentationUploder.conversion.officeDocConversionFailed": "Office sənədini yükləmək mümkün olmadı. Zəhmət olmasa bunun əvəzinə bir PDF yükləyin.",
"app.presentationUploder.conversion.timeout": "Bağışlayın, konvertasiya həddindən çox vaxt apardı",
"app.presentationUploder.conversion.pageCountFailed": "Səhifə sayını müəyyən etmək mümkün olmadı",
"app.presentationUploder.conversion.unsupportedDocument": "Fayl dəstəklənmir",
"app.presentationUploder.isDownloadableLabel": "Təqdimatın yüklənməsinə icazə verilmir - təqdimatın yüklənməsinə icazə vermək üçün vurun",
"app.presentationUploder.isNotDownloadableLabel": "Təqdimatın endirilməsinə icazə verilir - təqdimatın yüklənməsinə icazə verməmək üçün vurun",
"app.presentationUploder.removePresentationLabel": "Təqdimatı sil",
@ -201,15 +211,17 @@
"app.presentationUploder.itemPlural" : "bəndlər",
"app.presentationUploder.clearErrors": "Səhvləri silin",
"app.presentationUploder.clearErrorsDesc": "Uğursuz təqdimat yükləmələrini təmizləyir",
"app.poll.pollPaneTitle": "Anket",
"app.poll.quickPollTitle": "Tez Anket",
"app.poll.hidePollDesc": "Anket menyu bölməsini gizlədir",
"app.poll.quickPollInstruction": "Anketə başlamaq üçün aşağıda bir seçim seçin.",
"app.presentationUploder.uploadViewTitle": "Təqdimat yüklə",
"app.poll.pollPaneTitle": "Sorğu",
"app.poll.quickPollTitle": "Cəld sorğu",
"app.poll.hidePollDesc": "Sorğu bölməsini gizlədir",
"app.poll.quickPollInstruction": "Sorğuya başlamaq üçün aşağıdan bir seçim seçin.",
"app.poll.activePollInstruction": "Anketinizə canlı cavabları görmək üçün bu paneli açıq buraxın. Hazır olduğunuzda, nəticələri dərc etmək və anketi bitirmək üçün 'Anket nəticələrini dərc et' seçin.",
"app.poll.cancelPollLabel": "İmtina",
"app.poll.closeLabel": "Bağla",
"app.poll.waitingLabel": "Cavab gözlənilir ({0}/{1})",
"app.poll.ariaInputCount": "Xüsusi anket seçimi {0} / {1}",
"app.poll.customPlaceholder": "Anket seçimi əlavə edin",
"app.poll.ariaInputCount": "Xüsusi sorğu seçimi {0} / {1}",
"app.poll.customPlaceholder": "Sorğu variantı əlavə et",
"app.poll.noPresentationSelected": "Təqdimat seçilməyib! Zəhmət olmasa birini seçin.",
"app.poll.clickHereToSelect": "Seçmək üçün buraya bas",
"app.poll.t": "Doğru",
@ -232,8 +244,9 @@
"app.poll.answer.e": "E",
"app.poll.liveResult.usersTitle": "İstifadəçilər",
"app.poll.liveResult.responsesTitle": "Cavab",
"app.polling.pollingTitle": "Anket variantları",
"app.polling.pollAnswerLabel": "Anket cavabı {0}",
"app.poll.emptyPollOpt": "Boş",
"app.polling.pollingTitle": "Sorğu variantları",
"app.polling.pollAnswerLabel": "Sorğu cavabı {0}",
"app.polling.pollAnswerDesc": "{0} səs vermək üçün bu seçimi seç",
"app.failedMessage": "Bağışlayın, serverə qoşulmaqda problem var.",
"app.downloadPresentationButton.label": "Original təqdimatı yüklə",
@ -321,25 +334,29 @@
"app.settings.dataSavingTab.screenShare": "Desktop paylaşımı aktiv et",
"app.settings.dataSavingTab.description": "Internet sərfiyyatına qənaet etmək üçün nəyin hal-hazırda göstərildiyini müəyyən et.",
"app.settings.save-notification.label": "Tənzimləmələr yadda saxlanıldır",
"app.statusNotifier.lowerHands": "Aşağı əllər",
"app.statusNotifier.raisedHandsTitle": "Qaldırılmış Əllər",
"app.statusNotifier.lowerHands": "Əlləri aşağı sal",
"app.statusNotifier.raisedHandsTitle": "Qaldırılmış əllər",
"app.statusNotifier.raisedHandDesc": "{0} əl qaldırdılar",
"app.statusNotifier.raisedHandDescOneUser": "{0} əl qaldırdı",
"app.statusNotifier.and": "və",
"app.switch.onLabel": "Açıq",
"app.switch.offLabel": "Bağlı",
"app.talkingIndicator.ariaMuteDesc" : "İstifadəçini səsini kəsmək üçün seç",
"app.talkingIndicator.isTalking" : "{0} danışır",
"app.talkingIndicator.moreThanMaxIndicatorsTalking" : "{0}+ danışır",
"app.talkingIndicator.moreThanMaxIndicatorsWereTalking" : "{0}+ danışırdı",
"app.talkingIndicator.wasTalking" : "{0} danışmağı dayandırdı",
"app.actionsBar.actionsDropdown.actionsLabel": "Əməliyyatlar",
"app.actionsBar.actionsDropdown.initPollLabel": "Sorğunu başla",
"app.actionsBar.actionsDropdown.initPollLabel": "Sorğu başlat",
"app.actionsBar.actionsDropdown.desktopShareLabel": "Ekranı paylaş",
"app.actionsBar.actionsDropdown.lockedDesktopShareLabel": "Ekranı paylaşımı kilidlənib",
"app.actionsBar.actionsDropdown.stopDesktopShareLabel": "Ekran paylaşımını dayandır",
"app.actionsBar.actionsDropdown.presentationDesc": "Təqdimatı yüklə",
"app.actionsBar.actionsDropdown.initPollDesc": "Təqdimatı yüklə",
"app.actionsBar.actionsDropdown.initPollDesc": "Sorğu başlat",
"app.actionsBar.actionsDropdown.desktopShareDesc": "Ekranı başqaları ilə paylaş",
"app.actionsBar.actionsDropdown.stopDesktopShareDesc": "Ekranı {0} ilə paylaşmağı dayandır ",
"app.actionsBar.actionsDropdown.pollBtnLabel": "Sorğunu başla",
"app.actionsBar.actionsDropdown.pollBtnDesc": "Sorğunu panelini dəyiş",
"app.actionsBar.actionsDropdown.pollBtnLabel": "Sorğu başlat",
"app.actionsBar.actionsDropdown.pollBtnDesc": "Sorğu panelini göstərir/gizlədir",
"app.actionsBar.actionsDropdown.saveUserNames": "İstifadəçi adını yadda saxla",
"app.actionsBar.actionsDropdown.createBreakoutRoom": "Fasilə otağı yarat.",
"app.actionsBar.actionsDropdown.createBreakoutRoomDesc": "Mövcud sessiya üçün fasilələr yarat.",
@ -351,6 +368,7 @@
"app.actionsBar.emojiMenu.awayLabel": "Uzaqda",
"app.actionsBar.emojiMenu.awayDesc": "Statusu uzaqda olaraq dəyiş",
"app.actionsBar.emojiMenu.raiseHandLabel": "Əl qaldırın",
"app.actionsBar.emojiMenu.lowerHandLabel": "Əlini aşağı sal",
"app.actionsBar.emojiMenu.raiseHandDesc": "Sual vermək üçün əlini qaldır",
"app.actionsBar.emojiMenu.neutralLabel": "Qeyri-müəyyən",
"app.actionsBar.emojiMenu.neutralDesc": "Statusu qeyri-müəyyənə dəyiş",
@ -438,6 +456,10 @@
"app.audio.enterSessionLabel": "Sessiyaya qoyul",
"app.audio.playSoundLabel": "Səsi çal",
"app.audio.backLabel": "Geriyə",
"app.audio.loading": "Yüklənir",
"app.audio.microphones": "Mikrofonlar",
"app.audio.speakers": "Dinamiklər",
"app.audio.noDeviceFound": "Heç bir qurğu tapılmadı",
"app.audio.audioSettings.titleLabel": "Səs tənzimlələrini seç",
"app.audio.audioSettings.descriptionLabel": "Zəhmət olmasa nəzərə alıb ki, brouzerdə modal yaranacaq, və mikrofonunuzu paylaşmağınızı tələb edəcək.",
"app.audio.audioSettings.microphoneSourceLabel": "Microfon mənbəyi",
@ -497,6 +519,8 @@
"app.toast.setEmoji.label": "Emoji status {0} təyin olundu",
"app.toast.meetingMuteOn.label": "Bütün istifadəçilərin səsi kəsildi",
"app.toast.meetingMuteOff.label": "Görüş səs kəsilməsi deaktiv olunub",
"app.toast.setEmoji.raiseHand": "Əlini qaldırımısan",
"app.toast.setEmoji.lowerHand": "Əlini aşağı saldın",
"app.notification.recordingStart": "Bu sessiyas hal-hazırda qeydiyyata alınır",
"app.notification.recordingStop": "Bu sessiyas hal-hazırda qeydiyyata alınmır",
"app.notification.recordingPaused": "Bu sessiyas daha qeydiyyata alınmır",
@ -516,6 +540,7 @@
"app.shortcut-help.hidePrivateChat": "Şəxsi çatı gizlət",
"app.shortcut-help.closePrivateChat": "Şəxsi çatı bağla",
"app.shortcut-help.openActions": "Əməliyyatlar menyusunu aç",
"app.shortcut-help.raiseHand": "Əlini qaldır/endir",
"app.shortcut-help.openDebugWindow": "Debaq pəncərəsini açın",
"app.shortcut-help.openStatus": "Status menyusunu aç",
"app.shortcut-help.togglePan": "Əl alətini aktivləşdir (Təqdimatçı)",
@ -602,8 +627,8 @@
"app.sfu.invalidSdp2202":"İştirakçı etibarsız bir media istəyi yaratdı (SDP xətası 2202)",
"app.sfu.noAvailableCodec2203": "Server uyğun codec tapa bilmədi (xəta 2203)",
"app.meeting.endNotification.ok.label": "OK",
"app.whiteboard.annotations.poll": "Anket nəticələri dərc edildi",
"app.whiteboard.annotations.pollResult": "Anket nəticələri",
"app.whiteboard.annotations.poll": "Sorğu nəticələri dərc edildi",
"app.whiteboard.annotations.pollResult": "Sorğu nəticələri",
"app.whiteboard.toolbar.tools": "Alətlər",
"app.whiteboard.toolbar.tools.hand": "Əl",
"app.whiteboard.toolbar.tools.pencil": "Qələm",
@ -694,7 +719,9 @@
"app.debugWindow.form.userAgentLabel": "İstifadəçi agenti",
"app.debugWindow.form.button.copy": "Kopyala",
"app.debugWindow.form.enableAutoarrangeLayoutLabel": "Avtomatik Düzenleme Düzenini aktivləşdirin",
"app.debugWindow.form.enableAutoarrangeLayoutDescription": "(veb kameralar sahəsini sürükləsəniz və ya ölçüsünü dəyişsəniz, deaktiv ediləcək)"
"app.debugWindow.form.enableAutoarrangeLayoutDescription": "(veb kameralar sahəsini sürükləsəniz və ya ölçüsünü dəyişsəniz, deaktiv ediləcək)",
"app.learningDashboard.indicators.raiseHand": "Əlini qaldır",
"app.learningDashboard.usersTable.colRaiseHands": "Əlləri qaldır"
}

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