Merge branch 'v2.4.x-release' into wip-new-msg-userLeftFlag
This commit is contained in:
commit
6ed8fb304a
@ -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 }
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -166,6 +166,7 @@ class UsersApp(
|
||||
with SelectRandomViewerReqMsgHdlr
|
||||
with GetWebcamsOnlyForModeratorReqMsgHdlr
|
||||
with AssignPresenterReqMsgHdlr
|
||||
with ChangeUserPinStateReqMsgHdlr
|
||||
with EjectDuplicateUserReqMsgHdlr
|
||||
with EjectUserFromMeetingCmdMsgHdlr
|
||||
with EjectUserFromMeetingSysMsgHdlr
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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 =>
|
||||
|
@ -60,6 +60,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,
|
||||
|
@ -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 =>
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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))
|
||||
|
@ -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(),
|
||||
emojis: Vector[Emoji] = Vector(),
|
||||
webcams: Vector[Webcam] = Vector(),
|
||||
totalOfMessages: Long = 0,
|
||||
registeredOn: Long = System.currentTimeMillis(),
|
||||
leftOn: Long = 0,
|
||||
)
|
||||
|
||||
case class UserId(
|
||||
intId: String,
|
||||
registeredOn: Long = System.currentTimeMillis(),
|
||||
leftOn: Long = 0,
|
||||
userLeftFlag: Boolean = false,
|
||||
)
|
||||
|
||||
case class Poll(
|
||||
@ -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)
|
||||
|
||||
@ -110,9 +117,11 @@ class LearningDashboardActor(
|
||||
case m: GroupChatMessageBroadcastEvtMsg => handleGroupChatMessageBroadcastEvtMsg(m)
|
||||
|
||||
// User
|
||||
case m: UserRegisteredRespMsg => handleUserRegisteredRespMsg(m)
|
||||
case m: UserJoinedMeetingEvtMsg => handleUserJoinedMeetingEvtMsg(m)
|
||||
case m: UserLeftMeetingEvtMsg => handleUserLeftMeetingEvtMsg(m)
|
||||
case m: UserRegisteredRespMsg => handleUserRegisteredRespMsg(m)
|
||||
case m: UserJoinedMeetingEvtMsg => handleUserJoinedMeetingEvtMsg(m)
|
||||
case m: UserJoinMeetingReqMsg => handleUserJoinMeetingReqMsg(m)
|
||||
case m: UserLeaveReqMsg => handleUserLeaveReqMsg(m)
|
||||
case m: UserLeftMeetingEvtMsg => handleUserLeftMeetingEvtMsg(m)
|
||||
case m: UserEmojiChangedEvtMsg => handleUserEmojiChangedEvtMsg(m)
|
||||
case m: UserRoleChangedEvtMsg => handleUserRoleChangedEvtMsg(m)
|
||||
case m: UserBroadcastCamStartedEvtMsg => handleUserBroadcastCamStartedEvtMsg(m)
|
||||
@ -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,12 +547,16 @@ 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)
|
||||
outGW.send(event)
|
||||
for {
|
||||
learningDashboardAccessToken <- meetingAccessTokens.get(meeting.intId)
|
||||
} yield {
|
||||
val event = MsgBuilder.buildLearningDashboardEvtMsg(meeting.intId, learningDashboardAccessToken, activityJson)
|
||||
outGW.send(event)
|
||||
|
||||
meetingsLastJsonHash += (meeting.intId -> activityJsonHash)
|
||||
meetingsLastJsonHash += (meeting.intId -> activityJsonHash)
|
||||
|
||||
log.info("Activity Report sent for meeting {}",meeting.intId)
|
||||
log.info("Activity Report sent for meeting {}",meeting.intId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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])
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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!")
|
||||
|
@ -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";
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -30,6 +30,7 @@ public interface IBbbWebApiGWApp {
|
||||
Integer endWhenNoModeratorDelayInMinutes,
|
||||
Boolean muteOnStart,
|
||||
Boolean allowModsToUnmuteUsers,
|
||||
Boolean allowModsToEjectCameras,
|
||||
Boolean keepEvents,
|
||||
BreakoutRoomsParams breakoutParams,
|
||||
LockSettingsParams lockSettingsParams,
|
||||
|
@ -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,
|
||||
|
@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
|
@ -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={() => {
|
||||
|
@ -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,41 +57,43 @@ class PollsTable extends React.Component {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold">{user.name}</p>
|
||||
<p className="font-semibold truncate xl:max-w-sm max-w-xs">{user.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{typeof polls === 'object' && Object.values(polls || {}).length > 0 ? (
|
||||
Object.values(polls || {}).map((poll) => (
|
||||
<td className="px-3.5 2xl:px-4 py-3 text-sm text-center">
|
||||
{ getUserAnswer(user, poll) }
|
||||
{ poll.anonymous
|
||||
? (
|
||||
<span title={intl.formatMessage({
|
||||
id: 'app.learningDashboard.pollsTable.anonymousAnswer',
|
||||
defaultMessage: 'Anonymous Poll (answers in the last row)',
|
||||
})}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
Object.values(polls || {})
|
||||
.sort((a, b) => ((a.createdOn > b.createdOn) ? 1 : -1))
|
||||
.map((poll) => (
|
||||
<td className="px-3.5 2xl:px-4 py-3 text-sm text-center">
|
||||
{ getUserAnswer(user, poll) }
|
||||
{ poll.anonymous
|
||||
? (
|
||||
<span title={intl.formatMessage({
|
||||
id: 'app.learningDashboard.pollsTable.anonymousAnswer',
|
||||
defaultMessage: 'Anonymous Poll (answers in the last row)',
|
||||
})}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)
|
||||
: null }
|
||||
</td>
|
||||
))
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)
|
||||
: null }
|
||||
</td>
|
||||
))
|
||||
) : null }
|
||||
</tr>
|
||||
))) : null }
|
||||
@ -132,11 +136,13 @@ class PollsTable extends React.Component {
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{Object.values(polls || {}).map((poll) => (
|
||||
<td className="px-3.5 2xl:px-4 py-3 text-sm text-center">
|
||||
{ poll.anonymousAnswers.map((answer) => <p>{answer}</p>) }
|
||||
</td>
|
||||
))}
|
||||
{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>
|
||||
))}
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
|
@ -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,89 +72,95 @@ class StatusTable extends React.Component {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-semibold">{user.name}</p>
|
||||
<p className="font-semibold truncate xl:max-w-sm max-w-xs">{user.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{ periods.map((period) => {
|
||||
const userEmojisInPeriod = getUserEmojisSummary(user,
|
||||
null,
|
||||
period,
|
||||
period + spanMinutes);
|
||||
return (
|
||||
<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, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
{ periods.map((period) => (
|
||||
<td className="px-3.5 2xl:px-4 py-3 text-sm col-text-left">
|
||||
{ Object.values(user.intIds).map(({ registeredOn, leftOn }) => (
|
||||
<>
|
||||
{ registeredOn >= period && registeredOn < period + spanMinutes ? (
|
||||
<span title={intl.formatDate(registeredOn, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 text-xs text-green-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 text-xs text-green-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
{ Object.keys(userEmojisInPeriod)
|
||||
.map((emoji) => (
|
||||
<div className="text-sm text-gray-800">
|
||||
<i className={`${emojiConfigs[emoji].icon} text-sm`} />
|
||||
|
||||
{ userEmojisInPeriod[emoji] }
|
||||
|
||||
<FormattedMessage
|
||||
id={emojiConfigs[emoji].intlId}
|
||||
defaultMessage={emojiConfigs[emoji].defaultMessage}
|
||||
/>
|
||||
</div>
|
||||
)) }
|
||||
{
|
||||
user.leftOn > period && user.leftOn < period + spanMinutes
|
||||
? (
|
||||
<span title={intl.formatDate(user.leftOn, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
) : 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`} />
|
||||
|
||||
{ userEmojisInPeriod[emoji] }
|
||||
|
||||
<FormattedMessage
|
||||
id={emojiConfigs[emoji].intlId}
|
||||
defaultMessage={emojiConfigs[emoji].defaultMessage}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
}()) }
|
||||
{ leftOn >= period && leftOn < period + spanMinutes ? (
|
||||
<span title={intl.formatDate(leftOn, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
</td>
|
||||
);
|
||||
}) }
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
) : null }
|
||||
</>
|
||||
)) }
|
||||
</td>
|
||||
)) }
|
||||
</tr>
|
||||
))) : null }
|
||||
</tbody>
|
||||
|
@ -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,35 +202,36 @@ class UsersTable extends React.Component {
|
||||
</div>
|
||||
|
||||
<div className="inline-block">
|
||||
<p className="font-semibold">
|
||||
<p className="font-semibold truncate xl:max-w-sm max-w-xs">
|
||||
{user.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedDate
|
||||
value={user.registeredOn}
|
||||
month="short"
|
||||
day="numeric"
|
||||
hour="2-digit"
|
||||
minute="2-digit"
|
||||
second="2-digit"
|
||||
/>
|
||||
</p>
|
||||
{
|
||||
user.leftOn > 0
|
||||
{ Object.values(user.intIds || {}).map((intId, index) => (
|
||||
<>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 inline"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedDate
|
||||
value={intId.registeredOn}
|
||||
month="short"
|
||||
day="numeric"
|
||||
hour="2-digit"
|
||||
minute="2-digit"
|
||||
second="2-digit"
|
||||
/>
|
||||
</p>
|
||||
{ intId.leftOn > 0
|
||||
? (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<svg
|
||||
@ -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>
|
||||
|
||||
{ tsToHHmmss(
|
||||
(user.leftOn > 0
|
||||
? user.leftOn
|
||||
: (new Date()).getTime()) - user.registeredOn,
|
||||
) }
|
||||
{ tsToHHmmss(Object.values(user.intIds).reduce((prev, intId) => (
|
||||
prev + ((intId.leftOn > 0
|
||||
? intId.leftOn
|
||||
: (new Date()).getTime()) - intId.registeredOn)
|
||||
), 0)) }
|
||||
<br />
|
||||
<div
|
||||
className="bg-gray-200 transition-colors duration-500 rounded-full overflow-hidden"
|
||||
title={`${getOnlinePercentage(user.registeredOn, user.leftOn).toString()}%`}
|
||||
>
|
||||
<div
|
||||
aria-label=" "
|
||||
className="bg-gradient-to-br from-green-100 to-green-600 transition-colors duration-900 h-1.5"
|
||||
style={{ width: `${getOnlinePercentage(user.registeredOn, user.leftOn).toString()}%` }}
|
||||
role="progressbar"
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
(function getPercentage() {
|
||||
const { intIds } = user;
|
||||
const percentage = Object.values(intIds || {}).reduce((prev, intId) => (
|
||||
prev + getOnlinePercentage(intId.registeredOn, intId.leftOn)
|
||||
), 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-gray-200 transition-colors duration-500 rounded-full overflow-hidden"
|
||||
title={`${percentage.toString()}%`}
|
||||
>
|
||||
<div
|
||||
aria-label=" "
|
||||
className="bg-gradient-to-br from-green-100 to-green-600 transition-colors duration-900 h-1.5"
|
||||
style={{ width: `${percentage.toString()}%` }}
|
||||
role="progressbar"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}())
|
||||
}
|
||||
</td>
|
||||
<td className="px-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`} />
|
||||
|
||||
{ usersEmojisSummary[user.intId][emoji] }
|
||||
{ usersEmojisSummary[user.userKey][emoji] }
|
||||
|
||||
<FormattedMessage
|
||||
id={emojiConfigs[emoji].intlId}
|
||||
@ -405,37 +423,37 @@ class UsersTable extends React.Component {
|
||||
) : null }
|
||||
</td>
|
||||
{
|
||||
!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'} />
|
||||
</svg>
|
||||
|
||||
<span className="text-xs bg-gray-200 rounded-full px-2">
|
||||
<FormattedNumber value={usersActivityScore[user.intId]} minimumFractionDigits="0" maximumFractionDigits="1" />
|
||||
</span>
|
||||
</td>
|
||||
) : <td />
|
||||
}
|
||||
!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.userKey] > 0 ? '#A7F3D0' : '#e4e4e7'} />
|
||||
<rect width="12" height="12" x="14" fill={usersActivityScore[user.userKey] > 2 ? '#6EE7B7' : '#e4e4e7'} />
|
||||
<rect width="12" height="12" x="28" fill={usersActivityScore[user.userKey] > 4 ? '#34D399' : '#e4e4e7'} />
|
||||
<rect width="12" height="12" x="42" fill={usersActivityScore[user.userKey] > 6 ? '#10B981' : '#e4e4e7'} />
|
||||
<rect width="12" height="12" x="56" fill={usersActivityScore[user.userKey] > 8 ? '#059669' : '#e4e4e7'} />
|
||||
<rect width="12" height="12" x="70" fill={usersActivityScore[user.userKey] === 10 ? '#047857' : '#e4e4e7'} />
|
||||
</svg>
|
||||
|
||||
<span className="text-xs bg-gray-200 rounded-full px-2">
|
||||
<FormattedNumber value={usersActivityScore[user.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
|
||||
? (
|
||||
<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" />
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span className="px-2 py-1 font-semibold leading-tight text-green-700 bg-green-100 rounded-full">
|
||||
<FormattedMessage id="app.learningDashboard.usersTable.userStatusOnline" defaultMessage="Online" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
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" />
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span className="px-2 py-1 font-semibold leading-tight text-green-700 bg-green-100 rounded-full">
|
||||
<FormattedMessage id="app.learningDashboard.usersTable.userStatusOnline" defaultMessage="Online" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
|
@ -1 +1 @@
|
||||
BIGBLUEBUTTON_RELEASE=2.4-rc-7
|
||||
BIGBLUEBUTTON_RELEASE=2.4.1
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 © 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>
|
||||
|
@ -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 © 2018 BigBlueButton Inc.<br>
|
||||
<small>Version <a href="http://docs.bigbluebutton.org/">2.0-RC1</a></small>
|
||||
<p>Copyright © 2021 BigBlueButton Inc.<br>
|
||||
<small>Version <a href="https://docs.bigbluebutton.org/">2.4</a></small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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";
|
||||
}
|
||||
|
@ -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(),
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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}`);
|
||||
|
@ -61,6 +61,7 @@ export default function addMeeting(meeting) {
|
||||
authenticatedGuest: Boolean,
|
||||
maxUsers: Number,
|
||||
allowModsToUnmuteUsers: Boolean,
|
||||
allowModsToEjectCameras: Boolean,
|
||||
meetingLayout: String,
|
||||
},
|
||||
durationProps: {
|
||||
|
@ -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: '' });
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
@ -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,
|
||||
});
|
||||
|
@ -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}`);
|
||||
}
|
||||
}
|
@ -24,6 +24,7 @@ export default function addDialInUser(meetingId, voiceUser) {
|
||||
presenter: false,
|
||||
locked: false, // TODO
|
||||
avatar: '',
|
||||
pin: false,
|
||||
clientType: 'dial-in-user',
|
||||
};
|
||||
|
||||
|
@ -33,6 +33,7 @@ export default function addUser(meetingId, userData) {
|
||||
presenter: Boolean,
|
||||
locked: Boolean,
|
||||
avatar: String,
|
||||
pin: Boolean,
|
||||
clientType: String,
|
||||
});
|
||||
|
||||
|
@ -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}`);
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
@ -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,
|
||||
});
|
||||
|
@ -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}`);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -282,7 +282,6 @@ class ActionsDropdown extends PureComponent {
|
||||
}
|
||||
actions={children}
|
||||
opts={{
|
||||
disablePortal: true,
|
||||
id: "default-dropdown-menu",
|
||||
keepMounted: true,
|
||||
transitionDuration: 0,
|
||||
|
@ -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()}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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;
|
||||
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -287,6 +287,7 @@ class SettingsDropdown extends PureComponent {
|
||||
<Button
|
||||
label={intl.formatMessage(intlMessages.optionsLabel)}
|
||||
icon="more"
|
||||
data-test="optionsButton"
|
||||
ghost
|
||||
circle
|
||||
hideLabel
|
||||
|
@ -854,6 +854,7 @@ class Presentation extends PureComponent {
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
ref={(ref) => { this.refPresentationContainer = ref; }}
|
||||
className={styles.presentationContainer}
|
||||
style={{
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -1,10 +1,34 @@
|
||||
@import "/imports/ui/components/media/styles";
|
||||
@import '/imports/ui/components/loading-screen/styles';
|
||||
|
||||
.screenshareContainer {
|
||||
.hoverToolbar {
|
||||
display: none;
|
||||
|
||||
:hover > & {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.mobileControlsOverlay {
|
||||
position: absolute;
|
||||
top:0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.showMobileHoverToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.dontShowMobileHoverToolbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.screenshareContainer {
|
||||
position: relative;
|
||||
background-color: var(--color-content-background);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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),
|
||||
};
|
||||
|
@ -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,
|
||||
}));
|
||||
};
|
||||
|
@ -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);
|
||||
|
@ -305,4 +305,53 @@
|
||||
order: 1;
|
||||
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;
|
||||
}
|
||||
|
@ -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;
|
||||
menuItems.push({
|
||||
key: `${cameraId}-${a?.actionName}`,
|
||||
label: a?.label,
|
||||
description: a?.description,
|
||||
onClick: a?.onClick,
|
||||
dividerTop: topDivider,
|
||||
});
|
||||
});
|
||||
return menuItems
|
||||
|
||||
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}-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;
|
||||
}
|
||||
|
||||
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
|
||||
&& (
|
||||
@ -259,7 +356,7 @@ class VideoListItem extends Component {
|
||||
anchorOrigin: { vertical: 'bottom', horizontal: 'left' },
|
||||
transformorigin: { vertical: 'bottom', horizontal: 'left' },
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className={isFirefox ? styles.dropdownFireFox
|
||||
@ -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,
|
||||
};
|
||||
|
@ -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);
|
||||
|
||||
|
@ -249,6 +249,7 @@ const WebcamComponent = ({
|
||||
>
|
||||
<div
|
||||
id="cameraDock"
|
||||
role="region"
|
||||
className={draggableClassName}
|
||||
draggable={cameraDock.isDraggable && !isFullscreen ? 'true' : undefined}
|
||||
style={{
|
||||
|
@ -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 {
|
||||
|
@ -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})`;
|
||||
|
@ -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": {
|
||||
|
@ -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
|
||||
|
Binary file not shown.
@ -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": "المستخدمين النشطين",
|
||||
|
@ -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": "Açı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
Loading…
Reference in New Issue
Block a user