Merge pull request #16599 from antobinary/merge-26rc1

chore: Merge 2.6.0-rc.1 into develop
This commit is contained in:
Anton Georgiev 2023-01-30 09:45:32 -05:00 committed by GitHub
commit c77bec1954
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
182 changed files with 4841 additions and 671 deletions

View File

@ -33,6 +33,7 @@ jobs:
- run: ./build/setup.sh bbb-playback-podcast
- run: ./build/setup.sh bbb-playback-presentation
- run: ./build/setup.sh bbb-playback-screenshare
- run: ./build/setup.sh bbb-playback-video
- run: ./build/setup.sh bbb-record-core
- run: ./build/setup.sh bbb-web
- run: ./build/setup.sh bbb-webrtc-sfu

View File

@ -16,8 +16,8 @@ object Dependencies {
val akkaVersion = "2.6.17"
val akkaHttpVersion = "10.2.7"
val gson = "2.8.9"
val jackson = "2.13.0"
val logback = "1.2.10"
val jackson = "2.13.5"
val logback = "1.2.11"
val quicklens = "1.7.5"
val spray = "1.3.6"

View File

@ -108,16 +108,17 @@ case class EjectUserFromBreakoutInternalMsg(parentId: String, breakoutId: String
* Sent by parent meeting to breakout room to import annotated slides.
* @param userId
* @param parentMeetingId
* @param filename
* @param allPages
*/
case class CapturePresentationReqInternalMsg(userId: String, parentMeetingId: String, allPages: Boolean = true) extends InMessage
case class CapturePresentationReqInternalMsg(userId: String, parentMeetingId: String, filename: String, allPages: Boolean = true) extends InMessage
/**
* Sent by breakout room to parent meeting to obtain padId
* @param breakoutId
* @param meetingName
* @param filename
*/
case class CaptureSharedNotesReqInternalMsg(breakoutId: String, meetingName: String) extends InMessage
case class CaptureSharedNotesReqInternalMsg(breakoutId: String, filename: String) extends InMessage
// DeskShare
case class DeskShareStartedRequest(conferenceName: String, callerId: String, callerIdName: String) extends InMessage

View File

@ -16,8 +16,10 @@ object BreakoutModel {
assignedUsers: Vector[String],
captureNotes: Boolean,
captureSlides: Boolean,
captureNotesFilename: String,
captureSlidesFilename: String,
): BreakoutRoom2x = {
new BreakoutRoom2x(id, externalId, name, parentId, sequence, shortName, isDefaultName, freeJoin, voiceConf, assignedUsers, Vector(), Vector(), None, false, captureNotes, captureSlides)
new BreakoutRoom2x(id, externalId, name, parentId, sequence, shortName, isDefaultName, freeJoin, voiceConf, assignedUsers, Vector(), Vector(), None, false, captureNotes, captureSlides, captureNotesFilename, captureSlidesFilename)
}
}

View File

@ -52,7 +52,10 @@ trait CreateBreakoutRoomsCmdMsgHdlr extends RightsManagementTrait {
val (internalId, externalId) = BreakoutRoomsUtil.createMeetingIds(liveMeeting.props.meetingProp.intId, i)
val voiceConf = BreakoutRoomsUtil.createVoiceConfId(liveMeeting.props.voiceProp.voiceConf, i)
val breakout = BreakoutModel.create(parentId, internalId, externalId, room.name, room.sequence, room.shortName, room.isDefaultName, room.freeJoin, voiceConf, room.users, msg.body.captureNotes, msg.body.captureSlides)
val breakout = BreakoutModel.create(parentId, internalId, externalId, room.name, room.sequence, room.shortName,
room.isDefaultName, room.freeJoin, voiceConf, room.users, msg.body.captureNotes,
msg.body.captureSlides, room.captureNotesFilename, room.captureSlidesFilename)
rooms = rooms + (breakout.id -> breakout)
}
@ -73,6 +76,8 @@ trait CreateBreakoutRoomsCmdMsgHdlr extends RightsManagementTrait {
liveMeeting.props.breakoutProps.privateChatEnabled,
breakout.captureNotes,
breakout.captureSlides,
breakout.captureNotesFilename,
breakout.captureSlidesFilename,
)
val event = buildCreateBreakoutRoomSysCmdMsg(liveMeeting.props.meetingProp.intId, roomDetail)

View File

@ -13,13 +13,14 @@ trait EndBreakoutRoomInternalMsgHdlr extends HandlerHelpers {
def handleEndBreakoutRoomInternalMsg(msg: EndBreakoutRoomInternalMsg): Unit = {
if (liveMeeting.props.breakoutProps.captureSlides) {
val captureSlidesEvent = BigBlueButtonEvent(msg.breakoutId, CapturePresentationReqInternalMsg("system", msg.parentId))
val filename = liveMeeting.props.breakoutProps.captureSlidesFilename
val captureSlidesEvent = BigBlueButtonEvent(msg.breakoutId, CapturePresentationReqInternalMsg("system", msg.parentId, filename))
eventBus.publish(captureSlidesEvent)
}
if (liveMeeting.props.breakoutProps.captureNotes) {
val meetingName: String = liveMeeting.props.meetingProp.name
val captureNotesEvent = BigBlueButtonEvent(msg.parentId, CaptureSharedNotesReqInternalMsg(msg.breakoutId, meetingName))
val filename = liveMeeting.props.breakoutProps.captureNotesFilename
val captureNotesEvent = BigBlueButtonEvent(msg.parentId, CaptureSharedNotesReqInternalMsg(msg.breakoutId, filename))
eventBus.publish(captureNotesEvent)
}

View File

@ -3,6 +3,7 @@ package org.bigbluebutton.core.apps.presentationpod
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.api.{ CapturePresentationReqInternalMsg, CaptureSharedNotesReqInternalMsg }
import org.bigbluebutton.core.apps.{ PermissionCheck, RightsManagementTrait }
import org.bigbluebutton.core.apps.presentationpod.PresentationSender
import org.bigbluebutton.core.bus.MessageBus
import org.bigbluebutton.core.domain.MeetingState2x
import org.bigbluebutton.core.running.LiveMeeting
@ -39,6 +40,13 @@ trait PresentationWithAnnotationsMsgHdlr extends RightsManagementTrait {
BbbCommonEnvCoreMsg(envelope, event)
}
def buildNewPresAnnFileAvailable(fileURI: String, presId: String): NewPresAnnFileAvailableMsg = {
val header = BbbClientMsgHeader(NewPresAnnFileAvailableMsg.NAME, "not-used", "not-used")
val body = NewPresAnnFileAvailableMsgBody(fileURI, presId)
NewPresAnnFileAvailableMsg(header, body)
}
def buildBroadcastNewPresAnnFileAvailable(newPresAnnFileAvailableMsg: NewPresAnnFileAvailableMsg, liveMeeting: LiveMeeting): BbbCommonEnvCoreMsg = {
val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, liveMeeting.props.meetingProp.intId, "not-used")
val envelope = BbbCoreEnvelope(PresentationPageConvertedEventMsg.NAME, routing)
@ -49,6 +57,16 @@ trait PresentationWithAnnotationsMsgHdlr extends RightsManagementTrait {
BbbCommonEnvCoreMsg(envelope, event)
}
def buildBroadcastPresentationConversionUpdateEvtMsg(parentMeetingId: String, status: String, presentationId: String, filename: String, temporaryPresentationId: String): BbbCommonEnvCoreMsg = {
val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, parentMeetingId, "not-used")
val envelope = BbbCoreEnvelope(PresentationPageConvertedEventMsg.NAME, routing)
val header = BbbClientMsgHeader(PresentationConversionUpdateEvtMsg.NAME, parentMeetingId, "not-used")
val body = PresentationConversionUpdateEvtMsgBody("DEFAULT_PRESENTATION_POD", status, "not-used", presentationId, filename, temporaryPresentationId)
val event = PresentationConversionUpdateEvtMsg(header, body)
BbbCommonEnvCoreMsg(envelope, event)
}
def buildBroadcastPresAnnStatusMsg(presAnnStatusMsg: PresAnnStatusMsg, liveMeeting: LiveMeeting): BbbCommonEnvCoreMsg = {
val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, liveMeeting.props.meetingProp.intId, "not-used")
val envelope = BbbCoreEnvelope(PresentationPageConvertedEventMsg.NAME, routing)
@ -116,7 +134,6 @@ trait PresentationWithAnnotationsMsgHdlr extends RightsManagementTrait {
} else if (currentPres.isEmpty) {
log.error(s"Presentation ${presId} not found in meeting ${meetingId}")
} else {
val jobId: String = RandomStringGenerator.randomAlphanumericString(16);
val allPages: Boolean = m.body.allPages
val pageCount = currentPres.get.pages.size
@ -128,56 +145,79 @@ trait PresentationWithAnnotationsMsgHdlr extends RightsManagementTrait {
val exportJob: ExportJob = new ExportJob(jobId, JobTypes.DOWNLOAD, "annotated_slides", presId, presLocation, allPages, pagesRange, meetingId, "");
val storeAnnotationPages: List[PresentationPageForExport] = getPresentationPagesForExport(pagesRange, pageCount, presId, currentPres, liveMeeting);
// Send Export Job to Redis
val job = buildStoreExportJobInRedisSysMsg(exportJob, liveMeeting)
bus.outGW.send(job)
val annotationCount: Int = storeAnnotationPages.map(_.annotations.size).sum
// Send Annotations to Redis
val annotations = new StoredAnnotations(jobId, presId, storeAnnotationPages)
bus.outGW.send(buildStoreAnnotationsInRedisSysMsg(annotations, liveMeeting))
if (annotationCount > 0) {
// Send Export Job to Redis
val job = buildStoreExportJobInRedisSysMsg(exportJob, liveMeeting)
bus.outGW.send(job)
// Send Annotations to Redis
val annotations = StoredAnnotations(jobId, presId, storeAnnotationPages)
bus.outGW.send(buildStoreAnnotationsInRedisSysMsg(annotations, liveMeeting))
} else {
// Return existing uploaded file directly
val filename = currentPres.get.name
val presFilenameExt = filename.split("\\.").last
PresentationSender.broadcastSetPresentationDownloadableEvtMsg(bus, meetingId, "DEFAULT_PRESENTATION_POD", "not-used", presId, true, filename)
val fileURI = List("bigbluebutton", "presentation", "download", meetingId, s"${presId}?presFilename=${presId}.${presFilenameExt}").mkString(File.separator, File.separator, "")
val event = buildNewPresAnnFileAvailable(fileURI, presId)
handle(event, liveMeeting, bus)
}
}
}
def handle(m: CapturePresentationReqInternalMsg, state: MeetingState2x, liveMeeting: LiveMeeting, bus: MessageBus): Unit = {
val parentMeetingId: String = m.parentMeetingId
val meetingId = liveMeeting.props.meetingProp.intId
val jobId = s"${meetingId}-slides" // Used as the temporaryPresentationId upon upload
val userId = m.userId
val presentationPods: Vector[PresentationPod] = state.presentationPodManager.getAllPresentationPodsInMeeting()
val currentPres: Option[PresentationInPod] = presentationPods.flatMap(_.getCurrentPresentation()).headOption
val filename = m.filename
val presentationUploadToken: String = PresentationPodsApp.generateToken("DEFAULT_PRESENTATION_POD", userId)
// Informs bbb-web about the token so that when we use it to upload the presentation, it is able to look it up in the list of tokens
bus.outGW.send(buildPresentationUploadTokenSysPubMsg(parentMeetingId, userId, presentationUploadToken, filename))
if (liveMeeting.props.meetingProp.disabledFeatures.contains("importPresentationWithAnnotationsFromBreakoutRooms")) {
log.error(s"Capturing breakout rooms slides disabled in meeting ${meetingId}.")
} else if (currentPres.isEmpty) {
log.error(s"No presentation set in meeting ${meetingId}")
bus.outGW.send(buildBroadcastPresentationConversionUpdateEvtMsg(parentMeetingId, "204", jobId, filename, presentationUploadToken))
} else {
val jobId: String = s"${meetingId}-slides" // Used as the temporaryPresentationId upon upload
val allPages: Boolean = m.allPages
val pageCount = currentPres.get.pages.size
val presId: String = PresentationPodsApp.getAllPresentationPodsInMeeting(state).flatMap(_.getCurrentPresentation.map(_.id)).mkString
val presLocation = List("var", "bigbluebutton", meetingId, meetingId, presId).mkString(File.separator, File.separator, "");
val parentMeetingId: String = m.parentMeetingId
val currentPage: PresentationPage = PresentationInPod.getCurrentPage(currentPres.get).get
val pagesRange: List[Int] = if (allPages) (1 to pageCount).toList else List(currentPage.num)
val presentationUploadToken: String = PresentationPodsApp.generateToken("DEFAULT_PRESENTATION_POD", userId)
val filename: String = liveMeeting.props.meetingProp.name
// Informs bbb-web about the token so that when we use it to upload the presentation, it is able to look it up in the list of tokens
bus.outGW.send(buildPresentationUploadTokenSysPubMsg(parentMeetingId, userId, presentationUploadToken, filename))
val exportJob: ExportJob = new ExportJob(jobId, JobTypes.CAPTURE_PRESENTATION, filename, presId, presLocation, allPages, pagesRange, parentMeetingId, presentationUploadToken)
val storeAnnotationPages: List[PresentationPageForExport] = getPresentationPagesForExport(pagesRange, pageCount, presId, currentPres, liveMeeting);
// Send Export Job to Redis
val job = buildStoreExportJobInRedisSysMsg(exportJob, liveMeeting)
bus.outGW.send(job)
val annotationCount: Int = storeAnnotationPages.map(_.annotations.size).sum
// Send Annotations to Redis
val annotations = new StoredAnnotations(jobId, presId, storeAnnotationPages)
bus.outGW.send(buildStoreAnnotationsInRedisSysMsg(annotations, liveMeeting))
if (annotationCount > 0) {
// Send Export Job to Redis
val job = buildStoreExportJobInRedisSysMsg(exportJob, liveMeeting)
bus.outGW.send(job)
// Send Annotations to Redis
val annotations = new StoredAnnotations(jobId, presId, storeAnnotationPages)
bus.outGW.send(buildStoreAnnotationsInRedisSysMsg(annotations, liveMeeting))
} else {
// Notify that no content is available to capture
bus.outGW.send(buildBroadcastPresentationConversionUpdateEvtMsg(parentMeetingId, "204", jobId, filename, presentationUploadToken))
}
}
}
@ -191,7 +231,7 @@ trait PresentationWithAnnotationsMsgHdlr extends RightsManagementTrait {
val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, parentMeetingId, "not-used")
val envelope = BbbCoreEnvelope(PresentationPageConversionStartedEventMsg.NAME, routing)
val header = BbbClientMsgHeader(CaptureSharedNotesReqEvtMsg.NAME, parentMeetingId, "not-used")
val body = CaptureSharedNotesReqEvtMsgBody(m.breakoutId)
val body = CaptureSharedNotesReqEvtMsgBody(m.breakoutId, m.filename)
val event = CaptureSharedNotesReqEvtMsg(header, body)
bus.outGW.send(BbbCommonEnvCoreMsg(envelope, event))

View File

@ -5,7 +5,7 @@ import org.bigbluebutton.core.bus.InternalEventBus
import org.bigbluebutton.core.domain.MeetingState2x
import org.bigbluebutton.core.models._
import org.bigbluebutton.core.running.{ HandlerHelpers, LiveMeeting, OutMsgRouter }
import org.bigbluebutton.core2.message.senders.{ MsgBuilder }
import org.bigbluebutton.core2.message.senders.MsgBuilder
trait ValidateAuthTokenReqMsgHdlr extends HandlerHelpers {
this: UsersApp =>
@ -20,6 +20,7 @@ trait ValidateAuthTokenReqMsgHdlr extends HandlerHelpers {
var failReason = "Invalid auth token."
var failReasonCode = EjectReasonCode.VALIDATE_TOKEN
log.info("Number of registered users [{}]", RegisteredUsers.numRegisteredUsers(liveMeeting.registeredUsers))
val regUser = RegisteredUsers.getRegisteredUserWithToken(msg.body.authToken, msg.body.userId,
liveMeeting.registeredUsers)
regUser match {

View File

@ -1,22 +1,24 @@
package org.bigbluebutton.core.domain
case class BreakoutRoom2x(
id: String,
externalId: String,
name: String,
parentId: String,
sequence: Int,
shortName: String,
isDefaultName: Boolean,
freeJoin: Boolean,
voiceConf: String,
assignedUsers: Vector[String],
users: Vector[BreakoutUser],
voiceUsers: Vector[BreakoutVoiceUser],
startedOn: Option[Long],
started: Boolean,
captureNotes: Boolean,
captureSlides: Boolean,
id: String,
externalId: String,
name: String,
parentId: String,
sequence: Int,
shortName: String,
isDefaultName: Boolean,
freeJoin: Boolean,
voiceConf: String,
assignedUsers: Vector[String],
users: Vector[BreakoutUser],
voiceUsers: Vector[BreakoutVoiceUser],
startedOn: Option[Long],
started: Boolean,
captureNotes: Boolean,
captureSlides: Boolean,
captureNotesFilename: String,
captureSlidesFilename: String
) {
}

View File

@ -72,6 +72,10 @@ object RegisteredUsers {
regUsers.toVector.filter(_.joined).map(_.externId).distinct.length
}
def numRegisteredUsers(regUsers: RegisteredUsers): Int = {
regUsers.toVector.size
}
def add(users: RegisteredUsers, user: RegisteredUser): Vector[RegisteredUser] = {
findWithExternUserId(user.externId, users) match {

View File

@ -45,9 +45,14 @@ trait AppsTestFixtures {
val allowModsToEjectCameras = false
val authenticatedGuest = false
val meetingLayout = ""
val captureNotesFilename = s"Room 0${sequence} (Notes)"
val captureSlidesFilename = s"Room 0${sequence} (Whiteboard)"
val metadata: collection.immutable.Map[String, String] = Map("foo" -> "bar", "bar" -> "baz", "baz" -> "foo")
val breakoutProps = BreakoutProps(parentId = parentMeetingId, sequence = sequence, freeJoin = false, captureNotes = false, captureSlides = false, breakoutRooms = Vector())
val breakoutProps = BreakoutProps(parentId = parentMeetingId, sequence = sequence,
freeJoin = false, captureNotes = false, captureSlides = false,
breakoutRooms = Vector(), captureNotesFilename = captureNotesFilename,
captureSlidesFilename = captureSlidesFilename)
val meetingProp = MeetingProp(name = meetingName, extId = externalMeetingId, intId = meetingId,
meetingCameraCap = meetingCameraCap,

View File

@ -15,7 +15,7 @@ object Dependencies {
// Libraries
val akkaVersion = "2.6.17"
val gson = "2.8.9"
val jackson = "2.13.0"
val jackson = "2.13.5"
val sl4j = "1.7.32"
val pool = "2.11.1"
val codec = "1.15"

View File

@ -30,6 +30,8 @@ case class BreakoutProps(
privateChatEnabled: Boolean,
captureNotes: Boolean,
captureSlides: Boolean,
captureNotesFilename: String,
captureSlidesFilename: String,
)
case class PasswordProp(moderatorPass: String, viewerPass: String, learningDashboardAccessToken: String)

View File

@ -61,6 +61,8 @@ case class BreakoutRoomDetail(
privateChatEnabled: Boolean,
captureNotes: Boolean,
captureSlides: Boolean,
captureNotesFilename: String,
captureSlidesFilename: String,
)
/**
@ -69,7 +71,7 @@ case class BreakoutRoomDetail(
object CreateBreakoutRoomsCmdMsg { val NAME = "CreateBreakoutRoomsCmdMsg" }
case class CreateBreakoutRoomsCmdMsg(header: BbbClientMsgHeader, body: CreateBreakoutRoomsCmdMsgBody) extends StandardMsg
case class CreateBreakoutRoomsCmdMsgBody(meetingId: String, durationInMinutes: Int, record: Boolean, captureNotes: Boolean, captureSlides: Boolean, rooms: Vector[BreakoutRoomMsgBody])
case class BreakoutRoomMsgBody(name: String, sequence: Int, shortName: String, isDefaultName: Boolean, freeJoin: Boolean, users: Vector[String])
case class BreakoutRoomMsgBody(name: String, sequence: Int, shortName: String, captureNotesFilename: String, captureSlidesFilename: String, isDefaultName: Boolean, freeJoin: Boolean, users: Vector[String])
// Sent by user to request ending all the breakout rooms
object EndAllBreakoutRoomsMsg { val NAME = "EndAllBreakoutRoomsMsg" }

View File

@ -47,6 +47,6 @@ case class PresAnnStatusEvtMsgBody(presId: String, pageNumber: Int, totalPages:
object CaptureSharedNotesReqEvtMsg { val NAME = "CaptureSharedNotesReqEvtMsg" }
case class CaptureSharedNotesReqEvtMsg(header: BbbClientMsgHeader, body: CaptureSharedNotesReqEvtMsgBody) extends BbbCoreMsg
case class CaptureSharedNotesReqEvtMsgBody(breakoutId: String)
case class CaptureSharedNotesReqEvtMsgBody(breakoutId: String, filename: String)
// ------------ akka-apps to client ------------

View File

@ -44,12 +44,17 @@ trait TestFixtures {
val guestPolicy = "ALWAYS_ASK"
val authenticatedGuest = false
val metadata: collection.immutable.Map[String, String] = Map("foo" -> "bar", "bar" -> "baz", "baz" -> "foo")
val captureNotesFilename = s"Room 0${sequence} (Notes)"
val captureSlidesFilename = s"Room 0${sequence} (Whiteboard)"
val meetingProp = MeetingProp(name = meetingName, extId = externalMeetingId, intId = meetingId,
meetingCameraCap = meetingCameraCap,
maxPinnedCameras = maxPinnedCameras,
isBreakout = isBreakout.booleanValue())
val breakoutProps = BreakoutProps(parentId = parentMeetingId, sequence = sequence, freeJoin = false, captureNotes = false, captureSlides = false, breakoutRooms = Vector())
val breakoutProps = BreakoutProps(parentId = parentMeetingId, sequence = sequence, freeJoin = false, captureNotes = false,
captureSlides = false, breakoutRooms = Vector(),
endWhenNoModerator = endWhenNoModerator, endWhenNoModeratorDelayInMinutes = endWhenNoModeratorDelayInMinutes,
captureNotesFilename = captureNotesFilename, captureSlidesFilename = captureSlidesFilename)
val durationProps = DurationProps(duration = durationInMinutes, createdTime = createTime, createdDate = createDate,
meetingExpireIfNoUserJoinedInMinutes = meetingExpireIfNoUserJoinedInMinutes, meetingExpireWhenLastUserLeftInMinutes = meetingExpireWhenLastUserLeftInMinutes,

View File

@ -107,7 +107,7 @@ libraryDependencies ++= Seq(
"org.springframework.data" % "spring-data-commons" % "2.7.6",
"org.glassfish" % "javax.el" % "3.0.1-b12",
"org.apache.httpcomponents" % "httpclient" % "4.5.13",
"org.postgresql" % "postgresql" % "42.2.16",
"org.postgresql" % "postgresql" % "42.4.3",
"org.hibernate" % "hibernate-core" % "5.6.1.Final",
"org.flywaydb" % "flyway-core" % "7.8.2",
"com.zaxxer" % "HikariCP" % "4.0.3"

0
bbb-common-web/docker-clean.sh Normal file → Executable file
View File

View File

@ -15,14 +15,14 @@ object Dependencies {
// Libraries
val akkaVersion = "2.6.17"
val gson = "2.8.9"
val jackson = "2.13.0"
val jackson = "2.13.5"
val freemarker = "2.3.31"
val apacheHttp = "4.5.13"
val apacheHttpAsync = "4.1.4"
// Office and document conversion
val apachePoi = "5.1.0"
val nuProcess = "2.0.2"
val nuProcess = "2.0.6"
// Server
val servlet = "4.0.1"

0
bbb-common-web/psql.sh Normal file → Executable file
View File

View File

@ -78,6 +78,8 @@ public class ApiParams {
public static final String BREAKOUT_ROOMS_CAPTURE_SLIDES = "breakoutRoomsCaptureSlides";
public static final String BREAKOUT_ROOMS_CAPTURE_NOTES = "breakoutRoomsCaptureNotes";
public static final String BREAKOUT_ROOMS_CAPTURE_SLIDES_FILENAME = "breakoutRoomsCaptureSlidesFilename";
public static final String BREAKOUT_ROOMS_CAPTURE_NOTES_FILENAME = "breakoutRoomsCaptureNotesFilename";
public static final String BREAKOUT_ROOMS_ENABLED = "breakoutRoomsEnabled";
public static final String BREAKOUT_ROOMS_RECORD = "breakoutRoomsRecord";
public static final String BREAKOUT_ROOMS_PRIVATE_CHAT_ENABLED = "breakoutRoomsPrivateChatEnabled";

View File

@ -263,7 +263,9 @@ public class MeetingService implements MessageListener {
RegisteredUser ru = registeredUser.getValue();
long elapsedTime = now - ru.getGuestWaitedOn();
log.info("Determining if user [{}] should be purged. Elapsed time waiting [{}] with guest status [{}]", registeredUserID, elapsedTime, ru.getGuestStatus());
if (elapsedTime >= waitingGuestUsersTimeout && ru.getGuestStatus() == GuestPolicy.WAIT) {
log.info("Purging user [{}]", registeredUserID);
if (meeting.userUnregistered(registeredUserID) != null) {
gw.guestWaitingLeft(meeting.getInternalId(), registeredUserID);
meeting.setLeftGuestLobby(registeredUserID, true);
@ -547,26 +549,29 @@ public class MeetingService implements MessageListener {
return recordingService.isRecordingExist(recordId);
}
public String getRecordings2x(List<String> idList, List<String> states, Map<String, String> metadataFilters, String page, String size) {
int p;
int s;
public String getRecordings2x(List<String> idList, List<String> states, Map<String, String> metadataFilters, String offset, String limit) {
Pageable pageable = null;
int o = -1;
int l = -1;
try {
p = Integer.parseInt(page);
o = Integer.parseInt(offset);
if(o < 0) o = 0;
} catch(NumberFormatException e) {
p = 0;
log.info("Invalid offset parameter {}", offset);
o = 0;
}
try {
s = Integer.parseInt(size);
l = Integer.parseInt(limit);
if(l < 1) l = 1;
else if(l > 100) l = 100;
} catch(NumberFormatException e) {
s = 25;
log.info("Invalid limit parameter {}", limit);
}
log.info("{} {}", p, s);
Pageable pageable = PageRequest.of(p, s);
return recordingService.getRecordings2x(idList, states, metadataFilters, pageable);
if(l != -1) pageable = PageRequest.ofSize(l);
return recordingService.getRecordings2x(idList, states, metadataFilters, o, pageable);
}
public boolean existsAnyRecording(List<String> idList) {
@ -633,6 +638,8 @@ public class MeetingService implements MessageListener {
params.put(ApiParams.FREE_JOIN, message.freeJoin.toString());
params.put(ApiParams.BREAKOUT_ROOMS_CAPTURE_SLIDES, message.captureSlides.toString());
params.put(ApiParams.BREAKOUT_ROOMS_CAPTURE_NOTES, message.captureNotes.toString());
params.put(ApiParams.BREAKOUT_ROOMS_CAPTURE_NOTES_FILENAME, message.captureNotesFilename.toString());
params.put(ApiParams.BREAKOUT_ROOMS_CAPTURE_SLIDES_FILENAME, message.captureSlidesFilename.toString());
params.put(ApiParams.ATTENDEE_PW, message.viewerPassword);
params.put(ApiParams.MODERATOR_PW, message.moderatorPassword);
params.put(ApiParams.DIAL_NUMBER, message.dialNumber);

View File

@ -112,6 +112,8 @@ public class ParamsProcessorUtil {
private boolean defaultBreakoutRoomsRecord;
private boolean defaultBreakoutRoomsCaptureSlides = false;
private boolean defaultBreakoutRoomsCaptureNotes = false;
private String defaultBreakoutRoomsCaptureSlidesFilename = CONF_NAME;
private String defaultBreakoutRoomsCaptureNotesFilename = CONF_NAME;
private boolean defaultbreakoutRoomsPrivateChatEnabled;
private boolean defaultLockSettingsDisableCam;
@ -295,7 +297,19 @@ public class ParamsProcessorUtil {
breakoutRoomsCaptureNotes = Boolean.parseBoolean(breakoutRoomsCaptureNotesParam);
}
return new BreakoutRoomsParams(breakoutRoomsRecord, breakoutRoomsPrivateChatEnabled, breakoutRoomsCaptureNotes, breakoutRoomsCaptureSlides);
String breakoutRoomsCaptureNotesFilename = defaultBreakoutRoomsCaptureNotesFilename;
String breakoutRoomsCaptureNotesFilenameParam = params.get(ApiParams.BREAKOUT_ROOMS_CAPTURE_NOTES_FILENAME);
if (!StringUtils.isEmpty(breakoutRoomsCaptureNotesFilenameParam)) {
breakoutRoomsCaptureNotesFilename = breakoutRoomsCaptureNotesFilenameParam;
}
String breakoutRoomsCaptureSlidesFilename = defaultBreakoutRoomsCaptureSlidesFilename;
String breakoutRoomsCaptureSlidesFilenameParam = params.get(ApiParams.BREAKOUT_ROOMS_CAPTURE_SLIDES_FILENAME);
if (!StringUtils.isEmpty(breakoutRoomsCaptureSlidesFilenameParam)) {
breakoutRoomsCaptureSlidesFilename = breakoutRoomsCaptureSlidesFilenameParam;
}
return new BreakoutRoomsParams(breakoutRoomsRecord, breakoutRoomsPrivateChatEnabled, breakoutRoomsCaptureNotes, breakoutRoomsCaptureSlides, breakoutRoomsCaptureNotesFilename, breakoutRoomsCaptureSlidesFilename);
}
private LockSettingsParams processLockSettingsParams(Map<String, String> params) {
@ -769,6 +783,8 @@ public class ParamsProcessorUtil {
meeting.setFreeJoin(Boolean.parseBoolean(params.get(ApiParams.FREE_JOIN)));
meeting.setCaptureSlides(Boolean.parseBoolean(params.get(ApiParams.BREAKOUT_ROOMS_CAPTURE_SLIDES)));
meeting.setCaptureNotes(Boolean.parseBoolean(params.get(ApiParams.BREAKOUT_ROOMS_CAPTURE_NOTES)));
meeting.setCaptureNotesFilename(params.get(ApiParams.BREAKOUT_ROOMS_CAPTURE_NOTES_FILENAME));
meeting.setCaptureSlidesFilename(params.get(ApiParams.BREAKOUT_ROOMS_CAPTURE_SLIDES_FILENAME));
meeting.setParentMeetingId(parentMeetingId);
}

View File

@ -37,7 +37,7 @@ public interface RecordingService {
String getCaptionTrackInboxDir();
String getCaptionsDir();
boolean isRecordingExist(String recordId);
String getRecordings2x(List<String> idList, List<String> states, Map<String, String> metadataFilters, Pageable pageable);
String getRecordings2x(List<String> idList, List<String> states, Map<String, String> metadataFilters, int offset, Pageable pageable);
boolean existAnyRecording(List<String> idList);
boolean changeState(String recordingId, String state);
void updateMetaParams(List<String> recordIDs, Map<String,String> metaParams);
@ -47,9 +47,9 @@ public interface RecordingService {
void processMakePresentationDownloadableMsg(MakePresentationDownloadableMsg msg);
File getDownloadablePresentationFile(String meetingId, String presId, String presFilename);
default <T> Page<T> listToPage(List<T> list, Pageable pageable) {
int start = (int) pageable.getOffset();
int end = (int) (Math.min((start + pageable.getPageSize()), list.size()));
return new PageImpl<>(list.subList(start, end), pageable, list.size());
// Construct page using offset and limit parameters
default <T> Page<T> listToPage(List<T> list, int offset, Pageable pageable) {
int end = (int) (Math.min((offset + pageable.getPageSize()), list.size()));
return new PageImpl<>(list.subList(offset, end), pageable, list.size());
}
}

View File

@ -119,14 +119,8 @@ public final class Util {
boolean downloadable
) throws IOException {
File downloadMarker = Util.getPresFileDownloadMarker(presFileDir, presId);
if (downloadable) {
if (downloadMarker != null && ! downloadMarker.exists()) {
downloadMarker.createNewFile();
}
} else {
if (downloadMarker != null && downloadMarker.exists()) {
downloadMarker.delete();
}
if (downloadable && downloadMarker != null && ! downloadMarker.exists()) {
downloadMarker.createNewFile();
}
}
}

View File

@ -5,11 +5,15 @@ public class BreakoutRoomsParams {
public final Boolean privateChatEnabled;
public final Boolean captureNotes;
public final Boolean captureSlides;
public final String captureNotesFilename;
public final String captureSlidesFilename;
public BreakoutRoomsParams(Boolean record, Boolean privateChatEnabled, Boolean captureNotes, Boolean captureSlides) {
public BreakoutRoomsParams(Boolean record, Boolean privateChatEnabled, Boolean captureNotes, Boolean captureSlides, String captureNotesFilename, String captureSlidesFilename) {
this.record = record;
this.privateChatEnabled = privateChatEnabled;
this.captureNotes = captureNotes;
this.captureSlides = captureSlides;
this.captureNotesFilename = captureNotesFilename;
this.captureSlidesFilename = captureSlidesFilename;
}
}

View File

@ -44,6 +44,8 @@ public class Meeting {
private Boolean freeJoin = false;
private Boolean captureSlides = false;
private Boolean captureNotes = false;
private String captureSlidesFilename = "bbb-none";
private String captureNotesFilename = "bbb-none";
private Integer duration = 0;
private long createdTime = 0;
private long startTime = 0;
@ -328,6 +330,14 @@ public class Meeting {
this.captureNotes = captureNotes;
}
public void setCaptureNotesFilename(String filename) {
this.captureNotesFilename = filename;
}
public void setCaptureSlidesFilename(String filename) {
this.captureSlidesFilename = filename;
}
public Integer getDuration() {
return duration;
}

View File

@ -21,6 +21,8 @@ public class CreateBreakoutRoom implements IMessage {
public final Boolean privateChatEnabled;
public final Boolean captureNotes; // Upload shared notes to main room after breakout room end
public final Boolean captureSlides; // Upload annotated breakout slides to main room after breakout room end
public final String captureNotesFilename;
public final String captureSlidesFilename;
public CreateBreakoutRoom(String meetingId,
String parentMeetingId,
@ -39,7 +41,9 @@ public class CreateBreakoutRoom implements IMessage {
Boolean record,
Boolean privateChatEnabled,
Boolean captureNotes,
Boolean captureSlides) {
Boolean captureSlides,
String captureNotesFilename,
String captureSlidesFilename) {
this.meetingId = meetingId;
this.parentMeetingId = parentMeetingId;
this.name = name;
@ -58,5 +62,7 @@ public class CreateBreakoutRoom implements IMessage {
this.privateChatEnabled = privateChatEnabled;
this.captureNotes = captureNotes;
this.captureSlides = captureSlides;
this.captureNotesFilename = captureNotesFilename;
this.captureSlidesFilename = captureSlidesFilename;
}
}

View File

@ -5,6 +5,7 @@ import java.util.Objects;
@Entity
@Table(name = "metadata")
public class Metadata {
@Id

View File

@ -14,6 +14,7 @@ public interface XmlService {
String thumbnailToXml(Thumbnail thumbnail);
String callbackDataToXml(CallbackData callbackData);
String constructResponseFromRecordingsXml(String xml);
String constructPaginatedResponse(Page<?> page, String response);
String constructPaginatedResponse(Page<?> page, int offset, String response);
Recording xmlToRecording(String recordId, String xml);
String noRecordings();
}

View File

@ -72,65 +72,87 @@ public class RecordingServiceDbImpl implements RecordingService {
}
@Override
public String getRecordings2x(List<String> idList, List<String> states, Map<String, String> metadataFilters, Pageable pageable) {
public String getRecordings2x(List<String> idList, List<String> states, Map<String, String> metadataFilters, int offset, Pageable pageable) {
// If no IDs or limit were provided return no recordings instead of every recording
if((idList == null || idList.isEmpty()) && pageable == null) return xmlService.noRecordings();
logger.info("Retrieving all recordings");
Set<Recording> recordings = new HashSet<>();
recordings.addAll(dataStore.findAll(Recording.class));
Set<Recording> recordings = new HashSet<>(dataStore.findAll(Recording.class));
logger.info("{} recordings found", recordings.size());
Set<Recording> recordingsById = new HashSet<>();
for(String id: idList) {
List<Recording> r = dataStore.findRecordingsByMeetingId(id);
if(idList != null && !idList.isEmpty()) {
Set<Recording> recordingsById = new HashSet<>();
if(r == null || r.size() == 0) {
Recording recording = dataStore.findRecordingByRecordId(id);
if(recording != null) {
r = new ArrayList<>();
r.add(recording);
for(String id: idList) {
logger.info("Finding recordings using meeting ID with value {}", id);
List<Recording> recordingsByMeetingId = dataStore.findRecordingsByMeetingId(id);
if(recordingsByMeetingId == null || recordingsByMeetingId.isEmpty()) {
logger.info("Finding recordings using recording ID with value {}", id);
Recording recording = dataStore.findRecordingByRecordId(id);
if(recording != null) {
logger.info("Recording found");
recordingsById.add(recording);
}
} else {
logger.info("{} recordings found", recordingsByMeetingId.size());
recordingsById.addAll(recordingsByMeetingId);
}
}
if(r != null) recordingsById.addAll(r);
}
logger.info("Filtering recordings by meeting ID");
if(recordingsById.size() > 0) {
logger.info("Filtering recordings by ID");
recordings.retainAll(recordingsById);
}
logger.info("{} recordings remaining", recordings.size());
Set<Recording> recordingsByState = new HashSet<>();
for(String state: states) {
List<Recording> r = dataStore.findRecordingsByState(state);
if(r != null) recordingsByState.addAll(r);
logger.info("{} recordings remain", recordings.size());
}
logger.info("Filtering recordings by state");
if(recordingsByState.size() > 0) {
if(states != null && !states.isEmpty()) {
Set<Recording> recordingsByState = new HashSet<>();
for(String state: states) {
logger.info("Finding recordings by state {}", state);
List<Recording> r = dataStore.findRecordingsByState(state);
if(state != null && !state.isEmpty()) {
logger.info("{} recordings found", r.size());
recordingsByState.addAll(r);
}
}
logger.info("Filtering recordings by state");
recordings.retainAll(recordingsByState);
}
logger.info("{} recordings remaining", recordings.size());
List<Metadata> metadata = new ArrayList<>();
for(Map.Entry<String, String> metadataFilter: metadataFilters.entrySet()) {
List<Metadata> m = dataStore.findMetadataByFilter(metadataFilter.getKey(), metadataFilter.getValue());
if(m != null) metadata.addAll(m);
logger.info("{} recordings remain", recordings.size());
}
Set<Recording> recordingsByMetadata = new HashSet<>();
for(Metadata m: metadata) {
recordingsByMetadata.add(m.getRecording());
}
if(metadataFilters != null && !metadataFilters.isEmpty()) {
List<Metadata> metadata = new ArrayList<>();
logger.info("Filtering recordings by metadata");
if(recordingsByMetadata.size() > 0) {
for(Map.Entry<String, String> filter: metadataFilters.entrySet()) {
logger.info("Finding metadata using filter {} {}", filter.getKey(), filter.getValue());
List<Metadata> metadataByFilter = dataStore.findMetadataByFilter(filter.getKey(), filter.getValue());
if(metadataByFilter != null) {
logger.info("{} metadata found", metadataByFilter.size());
metadata.addAll(metadataByFilter);
}
}
Set<Recording> recordingsByMetadata = new HashSet<>();
for(Metadata m: metadata) recordingsByMetadata.add(m.getRecording());
logger.info("Filtering recordings by metadata");
recordings.retainAll(recordingsByMetadata);
logger.info("{} recordings remain", recordings.size());
}
logger.info("{} recordings remaining", recordings.size());
Page<Recording> recordingsPage = listToPage(new ArrayList<>(recordings), pageable);
// If no/invalid pagination parameters were given do not paginate the response
if(pageable == null) {
String recordingsXml = xmlService.recordingsToXml(recordings);
return xmlService.constructResponseFromRecordingsXml(recordingsXml);
}
Page<Recording> recordingsPage = listToPage(new ArrayList<>(recordings), offset, pageable);
String recordingsXml = xmlService.recordingsToXml(recordingsPage.getContent());
String response = xmlService.constructResponseFromRecordingsXml(recordingsXml);
return xmlService.constructPaginatedResponse(recordingsPage, response);
return xmlService.constructPaginatedResponse(recordingsPage, offset, response);
}
@Override

View File

@ -201,12 +201,19 @@ public class RecordingServiceFileImpl implements RecordingService {
return recordingServiceHelper.putRecordingTextTrack(track);
}
public String getRecordings2x(List<String> idList, List<String> states, Map<String, String> metadataFilters, Pageable pageable) {
public String getRecordings2x(List<String> idList, List<String> states, Map<String, String> metadataFilters, int offset, Pageable pageable) {
// If no IDs or limit were provided return no recordings instead of every recording
if(idList.isEmpty() && pageable == null) return xmlService.noRecordings();
List<RecordingMetadata> recsList = getRecordingsMetadata(idList, states);
ArrayList<RecordingMetadata> recs = filterRecordingsByMetadata(recsList, metadataFilters);
Page<RecordingMetadata> recordingsPage = listToPage(recs, pageable);
String response = recordingServiceHelper.getRecordings2x(recs);
return xmlService.constructPaginatedResponse(recordingsPage, response);
// If no/invalid pagination parameters were given do not paginate the response
if(pageable == null) return recordingServiceHelper.getRecordings2x(recs);
Page<RecordingMetadata> recordingsPage = listToPage(recs, offset, pageable);
String response = recordingServiceHelper.getRecordings2x(new ArrayList<RecordingMetadata>(recordingsPage.getContent()));
return xmlService.constructPaginatedResponse(recordingsPage, offset, response);
}
private RecordingMetadata getRecordingMetadata(File dir) {

View File

@ -72,7 +72,7 @@ public class XmlServiceImpl implements XmlService {
@Override
public String recordingToXml(Recording recording) {
logger.info("Converting {} to xml", recording);
// logger.info("Converting {} to xml", recording);
try {
setup();
Document document = builder.newDocument();
@ -125,7 +125,7 @@ public class XmlServiceImpl implements XmlService {
@Override
public String metadataToXml(Metadata metadata) {
logger.info("Converting {} to xml", metadata);
// logger.info("Converting {} to xml", metadata);
try {
setup();
@ -148,7 +148,7 @@ public class XmlServiceImpl implements XmlService {
@Override
public String playbackFormatToXml(PlaybackFormat playbackFormat) {
logger.info("Converting {} to xml", playbackFormat);
// logger.info("Converting {} to xml", playbackFormat);
try {
setup();
@ -187,7 +187,7 @@ public class XmlServiceImpl implements XmlService {
@Override
public String thumbnailToXml(Thumbnail thumbnail) {
logger.info("Converting {} to xml", thumbnail);
// logger.info("Converting {} to xml", thumbnail);
try {
setup();
@ -211,7 +211,7 @@ public class XmlServiceImpl implements XmlService {
@Override
public String callbackDataToXml(CallbackData callbackData) {
logger.info("Converting {} to xml", callbackData);
// logger.info("Converting {} to xml", callbackData);
try {
setup();
@ -264,7 +264,39 @@ public class XmlServiceImpl implements XmlService {
}
@Override
public String constructPaginatedResponse(Page<?> page, String response) {
public String noRecordings() {
logger.info("Constructing no recordings response");
try {
setup();
Document document = builder.newDocument();
Element rootElement = createElement(document, "response", null);
document.appendChild(rootElement);
Element returnCode = createElement(document, "returncode", "SUCCESS");
rootElement.appendChild(returnCode);
Element messageKey = createElement(document, "messageKey", "noRecordings");
rootElement.appendChild(messageKey);
Element message = createElement(document, "message", "No recordings found. This may occur if you attempt to retrieve all recordings.");
rootElement.appendChild(message);
String result = documentToString(document);
// logger.info("========== Result ==========");
// logger.info("{}", result);
// logger.info("============================");
return result;
} catch(Exception e) {
e.printStackTrace();
}
return null;
}
@Override
public String constructPaginatedResponse(Page<?> page, int offset, String response) {
logger.info("Constructing paginated response");
try {
@ -283,7 +315,7 @@ public class XmlServiceImpl implements XmlService {
Document secondDoc;
Node node;
xml = pageableToXml(page.getPageable());
xml = pageableToXml(page.getPageable(), offset);
secondDoc = builder.parse(new ByteArrayInputStream(xml.getBytes()));
node = document.importNode(secondDoc.getDocumentElement(), true);
pagination.appendChild(node);
@ -291,14 +323,14 @@ public class XmlServiceImpl implements XmlService {
Element totalElements = createElement(document, "totalElements", String.valueOf(page.getTotalElements()));
pagination.appendChild(totalElements);
Element last = createElement(document, "last", String.valueOf(page.isLast()));
pagination.appendChild(last);
// Element last = createElement(document, "last", String.valueOf(page.isLast()));
// pagination.appendChild(last);
Element totalPages = createElement(document, "totalPages", String.valueOf(page.getTotalPages()));
pagination.appendChild(totalPages);
// Element totalPages = createElement(document, "totalPages", String.valueOf(page.getTotalPages()));
// pagination.appendChild(totalPages);
Element first = createElement(document, "first", String.valueOf(page.isFirst()));
pagination.appendChild(first);
// Element first = createElement(document, "first", String.valueOf(page.isFirst()));
// pagination.appendChild(first);
Element empty = createElement(document, "empty", String.valueOf(!page.hasContent()));
pagination.appendChild(empty);
@ -317,7 +349,7 @@ public class XmlServiceImpl implements XmlService {
return null;
}
private String pageableToXml(Pageable pageable) {
private String pageableToXml(Pageable pageable, int o) {
logger.info("Converting {} to xml", pageable);
try {
@ -327,28 +359,28 @@ public class XmlServiceImpl implements XmlService {
Element rootElement = createElement(document, "pageable", null);
document.appendChild(rootElement);
Sort sort = pageable.getSort();
Element sortElement = createElement(document, "sort", null);
// Sort sort = pageable.getSort();
// Element sortElement = createElement(document, "sort", null);
//
// Element unsorted = createElement(document, "unsorted", String.valueOf(sort.isUnsorted()));
// sortElement.appendChild(unsorted);
//
// Element sorted = createElement(document, "sorted", String.valueOf(sort.isSorted()));
// sortElement.appendChild(sorted);
//
// Element empty = createElement(document, "empty", String.valueOf(sort.isEmpty()));
// sortElement.appendChild(empty);
//
// rootElement.appendChild(sortElement);
Element unsorted = createElement(document, "unsorted", String.valueOf(sort.isUnsorted()));
sortElement.appendChild(unsorted);
Element sorted = createElement(document, "sorted", String.valueOf(sort.isSorted()));
sortElement.appendChild(sorted);
Element empty = createElement(document, "empty", String.valueOf(sort.isEmpty()));
sortElement.appendChild(empty);
rootElement.appendChild(sortElement);
Element offset = createElement(document, "offset", String.valueOf(pageable.getOffset()));
Element offset = createElement(document, "offset", String.valueOf(o));
rootElement.appendChild(offset);
Element pageSize = createElement(document, "pageSize", String.valueOf(pageable.getPageSize()));
rootElement.appendChild(pageSize);
Element limit = createElement(document, "limit", String.valueOf(pageable.getPageSize()));
rootElement.appendChild(limit);
Element pageNumber = createElement(document, "pageNumber", String.valueOf(pageable.getPageNumber()));
rootElement.appendChild(pageNumber);
// Element pageNumber = createElement(document, "pageNumber", String.valueOf(pageable.getPageNumber()));
// rootElement.appendChild(pageNumber);
Element paged = createElement(document, "paged", String.valueOf(pageable.isPaged()));
rootElement.appendChild(paged);

View File

@ -193,6 +193,8 @@ class BbbWebApiGWApp(
privateChatEnabled = breakoutParams.privateChatEnabled.booleanValue(),
captureNotes = breakoutParams.captureNotes.booleanValue(),
captureSlides = breakoutParams.captureSlides.booleanValue(),
captureNotesFilename = breakoutParams.captureNotesFilename,
captureSlidesFilename = breakoutParams.captureSlidesFilename,
)
val welcomeProp = WelcomeProp(welcomeMsgTemplate = welcomeMsgTemplate, welcomeMsg = welcomeMsg,

View File

@ -12,7 +12,7 @@ case class CreateBreakoutRoomMsg(meetingId: String, parentMeetingId: String,
name: String, sequence: Integer, freeJoin: Boolean, dialNumber: String,
voiceConfId: String, viewerPassword: String, moderatorPassword: String, duration: Int,
sourcePresentationId: String, sourcePresentationSlide: Int,
record: Boolean, captureNotes: Boolean, captureSlides: Boolean) extends ApiMsg
record: Boolean, captureNotes: Boolean, captureSlides: Boolean, captureNotesFilename: String, captureSlidesFilename: String) extends ApiMsg
case class AddUserSession(token: String, session: UserSession)
case class RegisterUser(meetingId: String, intUserId: String, name: String, role: String,

View File

@ -105,6 +105,8 @@ class OldMeetingMsgHdlrActor(val olgMsgGW: OldMessageReceivedGW)
msg.body.room.privateChatEnabled,
msg.body.room.captureNotes,
msg.body.room.captureSlides,
msg.body.room.captureNotesFilename,
msg.body.room.captureSlidesFilename,
))
}

View File

@ -302,7 +302,11 @@ class StatusTable extends React.Component {
return (
<div
className="flex absolute p-1 border-white border-2 rounded-full text-sm z-20 bg-purple-500 text-purple-200 timeline-emoji"
role="status"
role="generic"
aria-label={intl.formatMessage({
id: emojiConfigs[emoji.name].intlId,
defaultMessage: emojiConfigs[emoji.name].defaultMessage,
})}
style={{
top: `calc(50% - ${redress})`,
[origin]: `calc(${offset}% - ${redress})`,

View File

@ -205,10 +205,10 @@ const UserDatailsComponent = (props) => {
);
return (
<div className="p-6 flex flex-row justify-between items-center">
<div className="min-w-[40%] text-ellipsis">{question}</div>
<tr className="p-6 flex flex-row justify-between items-center">
<td className="min-w-[40%] text-ellipsis">{question}</td>
{ isAnonymous ? (
<div
<td
className="min-w-[20%] grow text-center mx-3"
>
<span
@ -232,11 +232,11 @@ const UserDatailsComponent = (props) => {
/>
</svg>
</span>
</div>
</td>
) : (
<div className="min-w-[20%] grow text-center mx-3">{answers.map((answer) => <p title={answer} className="overflow-hidden text-ellipsis">{answer}</p>)}</div>
<td className="min-w-[20%] grow text-center mx-3">{answers.map((answer) => <p title={answer} className="overflow-hidden text-ellipsis">{answer}</p>)}</td>
) }
<div
<td
className="min-w-[40%] text-ellipsis text-center overflow-hidden"
title={mostCommonAnswer
? capitalizeFirstLetter(mostCommonAnswer)
@ -248,8 +248,8 @@ const UserDatailsComponent = (props) => {
id: 'app.learningDashboard.usersTable.notAvailable',
defaultMessage: 'N/A',
}) }
</div>
</div>
</td>
</tr>
);
}
@ -257,14 +257,14 @@ const UserDatailsComponent = (props) => {
category, average, activityPoints, totalOfActivity,
) {
return (
<div className="p-6 flex flex-row justify-between items-end">
<div className="min-w-[20%] text-ellipsis overflow-hidden">
<tr className="p-6 flex flex-row justify-between items-end">
<td className="min-w-[20%] text-ellipsis overflow-hidden">
<FormattedMessage
id={`app.learningDashboard.userDetails.${toCamelCase(category)}`}
defaultMessage={category}
/>
</div>
<div className="min-w-[60%] grow text-center text-sm">
</td>
<td className="min-w-[60%] grow text-center text-sm">
<div className="mb-2">
{ (function getAverage() {
if (average >= 0 && category === 'Talk Time') return tsToHHmmss(average);
@ -284,13 +284,13 @@ const UserDatailsComponent = (props) => {
: null }
</div>
</div>
</div>
<div className="min-w-[20%] text-sm text-ellipsis overflow-hidden text-right rtl:text-left">
</td>
<td className="min-w-[20%] text-sm text-ellipsis overflow-hidden text-right rtl:text-left">
{ activityPoints >= 0
? <FormattedNumber value={activityPoints} minimumFractionDigits="0" maximumFractionDigits="1" />
: <FormattedMessage id="app.learningDashboard.usersTable.notAvailable" defaultMessage="N/A" /> }
</div>
</div>
</td>
</tr>
);
}
@ -332,7 +332,7 @@ const UserDatailsComponent = (props) => {
aria-hidden="true"
/>
</div>
<p className="break-words text-center">{user.name}</p>
<h3 className="break-words text-center">{user.name}</h3>
</div>
<div className="bg-white shadow rounded mb-4">
<div className="p-6 text-lg flex items-center">
@ -341,7 +341,7 @@ const UserDatailsComponent = (props) => {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="ltr:ml-2 rtl:mr-2"><FormattedMessage id="app.learningDashboard.usersTable.title" defaultMessage="Overview" /></p>
<h3 className="ltr:ml-2 rtl:mr-2"><FormattedMessage id="app.learningDashboard.usersTable.title" defaultMessage="Overview" /></h3>
</div>
<div className="p-6 m-px bg-gray-100">
<div className="h-6 relative before:bg-gray-500 before:absolute before:w-[10px] before:h-[10px] before:rounded-full before:left-0 before:top-[calc(50%-5px)] after:bg-gray-500 after:absolute after:w-[10px] after:h-[10px] after:rounded-full after:right-0 after:top-[calc(50%-5px)]">
@ -420,7 +420,7 @@ const UserDatailsComponent = (props) => {
</div>
{ !user.isModerator && (
<>
<div className="bg-white shadow rounded mb-4 table w-full">
<div className="bg-white shadow rounded mb-4 w-full">
<div className="p-6 text-lg flex items-center">
<div className="p-2 rounded-full bg-green-100 text-green-700">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -428,48 +428,50 @@ const UserDatailsComponent = (props) => {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z" />
</svg>
</div>
<p className="ltr:ml-2 rtl:mr-2">
<h3 className="ltr:ml-2 rtl:mr-2">
<FormattedMessage id="app.learningDashboard.indicators.activityScore" defaultMessage="Activity Score" />
:&nbsp;
<span className="font-bold">
<FormattedNumber value={getActivityScore(user, users, totalPolls)} minimumFractionDigits="0" maximumFractionDigits="1" />
</span>
</p>
</h3>
</div>
<div className="p-6 py-2 m-px bg-gray-200 flex flex-row justify-between text-xs text-gray-700">
<div className="min-w-[20%] text-ellipsis"><FormattedMessage id="app.learningDashboard.userDetails.category" defaultMessage="Category" /></div>
<div className="grow text-center"><FormattedMessage id="app.learningDashboard.userDetails.average" defaultMessage="Average" /></div>
<div className="min-w-[20%] text-ellipsis text-right rtl:text-left"><FormattedMessage id="app.learningDashboard.userDetails.activityPoints" defaultMessage="Activity Points" /></div>
</div>
{ ['Talk Time', 'Messages', 'Emojis', 'Raise Hands', 'Poll Votes'].map((category) => {
let totalOfActivity = 0;
<table className="bg-white shadow rounded mb-4 table w-full">
<tr className="p-6 py-2 m-px bg-gray-200 flex flex-row justify-between text-xs text-gray-700">
<th aria-label="Category" className="min-w-[20%] text-ellipsis font-normal text-left"><FormattedMessage id="app.learningDashboard.userDetails.category" defaultMessage="Category" /></th>
<th aria-label="Average" className="grow text-center font-normal"><FormattedMessage id="app.learningDashboard.userDetails.average" defaultMessage="Average" /></th>
<th aria-label="Activity Points" className="min-w-[20%] text-ellipsis text-right rtl:text-left font-normal"><FormattedMessage id="app.learningDashboard.userDetails.activityPoints" defaultMessage="Activity Points" /></th>
</tr>
{ ['Talk Time', 'Messages', 'Emojis', 'Raise Hands', 'Poll Votes'].map((category) => {
let totalOfActivity = 0;
switch (category) {
case 'Talk Time':
totalOfActivity = user.talk.totalTime;
break;
case 'Messages':
totalOfActivity = user.totalOfMessages;
break;
case 'Emojis':
totalOfActivity = user.emojis.filter((emoji) => emoji.name !== 'raiseHand').length;
break;
case 'Raise Hands':
totalOfActivity = user.emojis.filter((emoji) => emoji.name === 'raiseHand').length;
break;
case 'Poll Votes':
totalOfActivity = Object.values(user.answers).length;
break;
default:
}
switch (category) {
case 'Talk Time':
totalOfActivity = user.talk.totalTime;
break;
case 'Messages':
totalOfActivity = user.totalOfMessages;
break;
case 'Emojis':
totalOfActivity = user.emojis.filter((emoji) => emoji.name !== 'raiseHand').length;
break;
case 'Raise Hands':
totalOfActivity = user.emojis.filter((emoji) => emoji.name === 'raiseHand').length;
break;
case 'Poll Votes':
totalOfActivity = Object.values(user.answers).length;
break;
default:
}
return renderActivityScoreItem(
category,
averages[category],
activityPointsFunctions[category](user),
totalOfActivity,
);
}) }
return renderActivityScoreItem(
category,
averages[category],
activityPointsFunctions[category](user),
totalOfActivity,
);
}) }
</table>
</div>
<div className="bg-white shadow rounded">
<div className="p-6 text-lg flex items-center">
@ -478,18 +480,20 @@ const UserDatailsComponent = (props) => {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
</div>
<p className="ltr:ml-2 rtl:mr-2"><FormattedMessage id="app.learningDashboard.indicators.polls" defaultMessage="Polls" /></p>
<h3 className="ltr:ml-2 rtl:mr-2"><FormattedMessage id="app.learningDashboard.indicators.polls" defaultMessage="Polls" /></h3>
</div>
<div className="p-6 py-2 m-px bg-gray-200 flex flex-row justify-between text-xs text-gray-700">
<div className="min-w-[40%] text-ellipsis"><FormattedMessage id="app.learningDashboard.userDetails.poll" defaultMessage="Poll" /></div>
<div className="grow text-center"><FormattedMessage id="app.learningDashboard.userDetails.response" defaultMessage="Response" /></div>
<div className="min-w-[40%] text-ellipsis text-center"><FormattedMessage id="app.learningDashboard.userDetails.mostCommonAnswer" defaultMessage="Most Common Answer" /></div>
</div>
{ Object.values(polls || {})
.map((poll) => renderPollItem(
poll,
getUserAnswer(poll),
)) }
<table className="w-full">
<tr className="p-6 py-2 m-px bg-gray-200 flex flex-row justify-between text-xs text-gray-700">
<th aria-label="Poll" className="min-w-[40%] text-ellipsis font-normal text-left"><FormattedMessage id="app.learningDashboard.userDetails.poll" defaultMessage="Poll" /></th>
<th aria-label="Response" className="grow text-center font-normal"><FormattedMessage id="app.learningDashboard.userDetails.response" defaultMessage="Response" /></th>
<th aria-label="Most Common Answer" className="min-w-[40%] text-ellipsis text-center font-normal"><FormattedMessage id="app.learningDashboard.userDetails.mostCommonAnswer" defaultMessage="Most Common Answer" /></th>
</tr>
{ Object.values(polls || {})
.map((poll) => renderPollItem(
poll,
getUserAnswer(poll),
)) }
</table>
</div>
</>
) }
@ -500,7 +504,6 @@ const UserDatailsComponent = (props) => {
const UserDetailsContainer = (props) => {
const { isOpen, dispatch, user } = useContext(UserDetailsContext);
return ReactDOM.createPortal(
<UserDatailsComponent
{...{

4
bbb-recording-imex/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
logs/
src/metadata
src/metadata-export/*

View File

@ -5,16 +5,30 @@ Imports and parses recording metadata.xml files and stores the data in a Postgre
## How to use
0. Ensure the required software is installed
- Install `docker` (if you're using `docker-dev` development environment for BBB this is already installed)
- You would either need to add your user to the docker group or you might have to prepend your docker-compose command with `sudo `
```
sudo usermod -aG docker `whoami`
sudo reboot
```
- Install `docker compose` - a sample set of steps are listed below but could likely be installed in a different way too:
- `sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose`
- `sudo chmod +x /usr/local/bin/docker-compose`
- `docker-compose --version`
- Note that you do _not_ need to install Postgres DB on your own
1. In bbb-common-web
- Edit the .env file and set the environment variables
- Run the hibernate.cfg script to generate the hibernate config file
- Run "docker-compose up" to start up the docker container containing the Postgresql database
- `cd bbb-common-web`
- Edit the `./.env` file and set the environment variables (the default values should work fine)
- Run the `./hibernate.cfg.sh` script to generate the hibernate config file
- Run `docker-compose up` to start up the docker container containing the Postgresql database
- Interact with the database using the psql script
2. In bbb-recording-imex
- Unit tests for parsing and persisting recording metadata can be found in src/test/java/org/bigbluebutton/recording/
- Edit the "metadataDirectory" variables in the test files to point to where the recording metadata can be found
- Run the unit tests using the command "mvn test"
- Use the deploy.sh script to compile the program
- Edit the `metadataDirectory` variables in the test files to point to where the recording metadata can be found. The default value "metadata" refers to `./src/metadata` and should work too.
- Run the unit tests using the command `mvn test`
- Use the `deploy.sh` script to compile the program
- Run the program with the recording-imex.sh script found in ~/usr/local/bin
- Use the --help option to see the usage

View File

@ -1,11 +1,13 @@
#!/bin/bash
while getopts i:r:s:m: flag
while getopts i:r:s:m:o:l: flag
do
case "${flag}" in
i) MEETING_ID=${OPTARG};;
r) RECORD_ID=${OPTARG};;
s) STATE=${OPTARG};;
m) META=${OPTARG};;
o) OFFSET=${OPTARG};;
l) LIMIT=${OPTARG};;
esac
done
@ -17,7 +19,9 @@ QUERY=""
if ! [[ -z ${MEETING_ID+x} ]]; then QUERY+="meetingID=$MEETING_ID&"; fi
if ! [[ -z ${RECORD_ID+x} ]]; then QUERY+="recordID=$RECORD_ID&"; fi
if ! [[ -z ${STATE+x} ]]; then QUERY+="state=$STATE&"; fi
if ! [[ -z ${META+x} ]]; then QUERY+="meta=$META"; fi
if ! [[ -z ${META+x} ]]; then QUERY+="meta=$META&"; fi
if ! [[ -z ${OFFSET+x} ]]; then QUERY+="offset=$OFFSET&"; fi
if ! [[ -z ${LIMIT+x} ]]; then QUERY+="limit=$LIMIT"; fi
echo "query: $QUERY"
@ -26,7 +30,7 @@ if [ "${QUERY:$INDEX:1}" = "&" ]; then QUERY=${QUERY:0:$INDEX}; fi
echo "query: $QUERY"
SALT=
SALT=""
DATA="$ENDPOINT$QUERY$SALT"
echo "data: $DATA"

View File

@ -75,7 +75,7 @@
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-core</artifactId>
<version>1.2.3</version>
<version>1.2.11</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
@ -85,7 +85,7 @@
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
<version>1.2.11</version>
</dependency>
<dependency>

View File

@ -21,7 +21,7 @@ public class RecordingImportHandlerTest {
@Test
@DisplayName("RecordIDs should be properly parsed")
public void testParseRecordId() {
String metadataDirectory = "metadata";
String metadataDirectory = "src/metadata";
String[] entries = new File(metadataDirectory).list();
Set<String> ids = new HashSet<>();

View File

@ -17,7 +17,7 @@ public class RecordingStoreTest {
private static final Logger logger = LoggerFactory.getLogger(RecordingStoreTest.class);
private String metadataDirectory = "metadata";
private String metadataDirectory = "src/metadata";
private final RecordingImportHandler importHandler = RecordingImportHandler.getInstance();
private final RecordingExportHandler exportHandler = RecordingExportHandler.getInstance();
private DataStore dataStore;
@ -68,7 +68,7 @@ public class RecordingStoreTest {
@Order(3)
public void testExportRecording() {
dataStore = DataStore.getInstance();
String metadataDirectory = "metadata-export";
String metadataDirectory = "src/metadata-export";
exportHandler.exportRecordings(metadataDirectory);

View File

@ -1 +1 @@
BIGBLUEBUTTON_RELEASE=2.6.0-beta.6
BIGBLUEBUTTON_RELEASE=2.6.0-rc.1

View File

@ -5,19 +5,19 @@
meteor-base@1.5.1
mobile-experience@1.1.0
mongo@1.16.3
mongo@1.16.4
reactive-var@1.0.12
standard-minifier-css@1.8.3
standard-minifier-js@2.8.1
es5-shim@4.8.0
ecmascript@0.16.4
ecmascript@0.16.5
shell-server@0.5.0
static-html@1.3.2
react-meteor-data
session@1.2.1
tracker@1.2.1
tracker@1.3.0
check@1.3.2
rocketchat:streamer

View File

@ -1 +1 @@
METEOR@2.9.0
METEOR@2.10.0

View File

@ -1,6 +1,6 @@
allow-deny@1.1.1
autoupdate@1.8.0
babel-compiler@7.10.1
babel-compiler@7.10.2
babel-runtime@1.5.1
base64@1.0.12
binary-heap@1.0.11
@ -8,7 +8,7 @@ blaze-tools@1.1.3
boilerplate-generator@1.7.1
caching-compiler@1.2.2
caching-html-compiler@1.2.1
callback-hook@1.4.0
callback-hook@1.5.0
check@1.3.2
ddp@1.4.1
ddp-client@2.6.1
@ -16,13 +16,13 @@ ddp-common@1.4.0
ddp-server@2.6.0
diff-sequence@1.1.2
dynamic-import@0.7.2
ecmascript@0.16.4
ecmascript@0.16.5
ecmascript-runtime@0.8.0
ecmascript-runtime-client@0.12.1
ecmascript-runtime-server@0.11.0
ejson@1.1.3
es5-shim@4.8.0
fetch@0.1.2
fetch@0.1.3
geojson-utils@1.0.11
hot-code-push@1.0.4
html-tools@1.1.3
@ -33,7 +33,7 @@ inter-process-messaging@0.1.1
launch-screen@1.3.0
lmieulet:meteor-coverage@4.1.0
logging@1.3.1
meteor@1.10.3
meteor@1.11.0
meteor-base@1.5.1
meteortesting:browser-tests@1.3.5
meteortesting:mocha@2.0.3
@ -46,7 +46,7 @@ mobile-status-bar@1.1.0
modern-browsers@0.1.9
modules@0.19.0
modules-runtime@0.13.1
mongo@1.16.3
mongo@1.16.4
mongo-decimal@0.1.3
mongo-dev-server@1.1.0
mongo-id@1.0.8
@ -54,7 +54,7 @@ npm-mongo@4.12.1
ordered-dict@1.1.0
promise@0.12.2
random@1.2.1
react-fast-refresh@0.2.3
react-fast-refresh@0.2.5
react-meteor-data@2.5.1
reactive-dict@1.3.1
reactive-var@1.0.12
@ -70,9 +70,9 @@ standard-minifier-css@1.8.3
standard-minifier-js@2.8.1
static-html@1.3.2
templating-tools@1.2.2
tracker@1.2.1
typescript@4.6.4
tracker@1.3.0
typescript@4.7.4
underscore@1.0.11
url@1.3.2
webapp@1.13.2
webapp@1.13.3
webapp-hashing@1.1.1

View File

@ -49,10 +49,7 @@ export default function addConnectionStatus(status, type, value) {
if (STATS.log) logConnectionStatus(meetingId, requesterUserId, status, type, value);
// Avoid storing recoveries
if (status !== 'normal') {
updateConnectionStatus(meetingId, requesterUserId, status);
}
updateConnectionStatus(meetingId, requesterUserId, status);
} catch (err) {
Logger.error(`Exception while invoking method addConnectionStatus ${err.stack}`);
}

View File

@ -3,11 +3,11 @@ import Logger from '/imports/startup/server/logger';
import { check } from 'meteor/check';
import changeHasConnectionStatus from '/imports/api/users-persistent-data/server/modifiers/changeHasConnectionStatus';
export default function updateConnectionStatus(meetingId, userId, level) {
export default function updateConnectionStatus(meetingId, userId, status) {
check(meetingId, String);
check(userId, String);
const timestamp = new Date().getTime();
const now = new Date().getTime();
const selector = {
meetingId,
@ -17,16 +17,20 @@ export default function updateConnectionStatus(meetingId, userId, level) {
const modifier = {
meetingId,
userId,
level,
timestamp,
connectionAliveAt: now,
};
try {
const { numberAffected } = ConnectionStatus.upsert(selector, modifier);
// Store last not-normal status
if (status !== 'normal') {
modifier.status = status;
modifier.statusUpdatedAt = now;
}
if (numberAffected) {
try {
const { numberAffected } = ConnectionStatus.upsert(selector, { $set: modifier });
if (numberAffected && status !== 'normal') {
changeHasConnectionStatus(true, userId, meetingId);
Logger.verbose(`Updated connection status meetingId=${meetingId} userId=${userId} level=${level}`);
Logger.verbose(`Updated connection status meetingId=${meetingId} userId=${userId} status=${status}`);
}
} catch (err) {
Logger.error(`Updating connection status meetingId=${meetingId} userId=${userId}: ${err}`);

View File

@ -76,6 +76,8 @@ export default function addMeeting(meeting) {
privateChatEnabled: Boolean,
captureNotes: Boolean,
captureSlides: Boolean,
captureNotesFilename: String,
captureSlidesFilename: String,
},
meetingProp: {
intId: String,

View File

@ -11,10 +11,12 @@ export default function captureSharedNotes({ header, body }) {
const {
breakoutId,
filename,
} = body;
check(breakoutId, String);
check(parentMeetingId, String);
check(filename, String);
padCapture(breakoutId, parentMeetingId);
padCapture(breakoutId, parentMeetingId, filename);
}

View File

@ -1,12 +1,12 @@
import Pads from '/imports/api/pads';
import Breakouts from '/imports/api/breakouts';
import Pads, { PadsUpdates } from '/imports/api/pads';
import RedisPubSub from '/imports/startup/server/redis';
import Logger from '/imports/startup/server/logger';
export default function padCapture(breakoutId, parentMeetingId) {
export default function padCapture(breakoutId, parentMeetingId, filename) {
const REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
const EVENT_NAME = 'PadCapturePubMsg';
const EVENT_NAME_ERROR = 'PresentationConversionUpdateSysPubMsg';
const EXTERNAL_ID = Meteor.settings.public.notes.id;
try {
@ -22,21 +22,41 @@ export default function padCapture(breakoutId, parentMeetingId) {
},
);
const breakout = Breakouts.findOne({ breakoutId });
const update = PadsUpdates.findOne(
{
meetingId: breakoutId,
externalId: EXTERNAL_ID,
}, {
fields: {
rev: 1,
},
},
);
if (pad?.padId && breakout?.shortName) {
if (pad?.padId && update?.rev > 0) {
const payload = {
parentMeetingId,
breakoutId,
padId: pad.padId,
filename: `${breakout.shortName}-notes`,
filename,
};
Logger.info(`Sending PadCapturePubMsg for meetingId=${breakoutId} parentMeetingId=${parentMeetingId} padId=${pad.padId}`);
return RedisPubSub.publishMeetingMessage(CHANNEL, EVENT_NAME, parentMeetingId, payload);
}
return null;
// Notify that no content is available
const temporaryPresentationId = `${breakoutId}-notes`;
const payload = {
podId: 'DEFAULT_PRESENTATION_POD',
messageKey: '204',
code: 'not-used',
presentationId: temporaryPresentationId,
presName: filename,
temporaryPresentationId,
};
Logger.info(`No notes available for capture in meetingId=${breakoutId} parentMeetingId=${parentMeetingId} padId=${pad.padId}`);
return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME_ERROR, parentMeetingId, 'system', payload);
} catch (err) {
Logger.error(`Exception while invoking method padCapture ${err.stack}`);
return null;

View File

@ -14,6 +14,7 @@ const GENERATED_SLIDE_KEY = 'GENERATED_SLIDE';
const FILE_TOO_LARGE_KEY = 'FILE_TOO_LARGE';
const CONVERSION_TIMEOUT_KEY = "CONVERSION_TIMEOUT";
const IVALID_MIME_TYPE_KEY = "IVALID_MIME_TYPE";
const NO_CONTENT = '204';
// const GENERATING_THUMBNAIL_KEY = 'GENERATING_THUMBNAIL';
// const GENERATED_THUMBNAIL_KEY = 'GENERATED_THUMBNAIL';
// const GENERATING_TEXTFILES_KEY = 'GENERATING_TEXTFILES';
@ -76,6 +77,13 @@ export default function handlePresentationConversionUpdate({ body }, meetingId)
statusModifier['conversion.numPages'] = body.numberOfPages;
break;
case NO_CONTENT:
statusModifier['conversion.done'] = false;
statusModifier['conversion.error'] = true;
statusModifier.id = presentationId;
statusModifier.name = presentationName;
break;
default:
break;
}

View File

@ -16,6 +16,12 @@ export default function zoomSlide(slideNumber, podId, widthRatio, heightRatio, x
check(meetingId, String);
check(requesterUserId, String);
check(slideNumber, Number);
check(podId, String);
check(widthRatio, Number);
check(heightRatio, Number);
check(x, Number);
check(y, Number);
const selector = {
meetingId,

View File

@ -261,7 +261,15 @@ class Base extends Component {
if ((loading || !subscriptionsReady) && !meetingHasEnded && meetingExist) {
return (<LoadingScreen>{loading}</LoadingScreen>);
}
if (( meetingHasEnded || ejected || userRemoved ) && meetingIsBreakout) {
Base.setExitReason('breakoutEnded').finally(() => {
Meteor.disconnect();
window.close();
});
return null;
}
if (ejected) {
return (
<MeetingEnded
@ -272,14 +280,6 @@ class Base extends Component {
);
}
if (meetingHasEnded && meetingIsBreakout) {
Base.setExitReason('breakoutEnded').finally(() => {
Meteor.disconnect();
window.close();
});
return null;
}
if (meetingHasEnded && !meetingIsBreakout) {
return (
<MeetingEnded

View File

@ -12,6 +12,7 @@ import Styled from './styles';
import Icon from '/imports/ui/components/common/icon/component.jsx';
import { isImportSharedNotesFromBreakoutRoomsEnabled, isImportPresentationWithAnnotationsFromBreakoutRoomsEnabled } from '/imports/ui/services/features';
import { addNewAlert } from '/imports/ui/components/screenreader-alert/service';
import PresentationUploaderService from '/imports/ui/components/presentation/presentation-uploader/service';
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
@ -92,6 +93,14 @@ const intlMessages = defineMessages({
id: 'app.createBreakoutRoom.captureSlides',
description: 'capture slides label',
},
captureNotesType: {
id: 'app.notes.label',
description: 'indicates notes have been captured',
},
captureSlidesType: {
id: 'app.shortcut-help.whiteboard',
description: 'indicates the whiteboard has been captured',
},
roomLabel: {
id: 'app.createBreakoutRoom.room',
description: 'Room label',
@ -183,7 +192,7 @@ const propTypes = {
getBreakouts: PropTypes.func.isRequired,
sendInvitation: PropTypes.func.isRequired,
mountModal: PropTypes.func.isRequired,
isBreakoutRecordable: PropTypes.bool.isRequired,
isBreakoutRecordable: PropTypes.bool,
};
class BreakoutRoom extends PureComponent {
@ -251,6 +260,8 @@ class BreakoutRoom extends PureComponent {
componentDidMount() {
const {
breakoutJoinedUsers, getLastBreakouts, groups, isUpdate,
allowUserChooseRoomByDefault, captureSharedNotesByDefault,
captureWhiteboardByDefault,
} = this.props;
this.setRoomUsers();
if (isUpdate) {
@ -276,6 +287,11 @@ class BreakoutRoom extends PureComponent {
};
});
}
this.setState({
freeJoin: allowUserChooseRoomByDefault,
captureSlides: captureWhiteboardByDefault,
captureNotes: captureSharedNotesByDefault,
});
const lastBreakouts = getLastBreakouts();
if (lastBreakouts.length > 0) {
@ -445,6 +461,8 @@ class BreakoutRoom extends PureComponent {
const rooms = _.range(1, numberOfRooms + 1).map((seq) => ({
users: this.getUserByRoom(seq).map((u) => u.userId),
name: this.getFullName(seq),
captureNotesFilename: this.getCaptureFilename(seq, false),
captureSlidesFilename: this.getCaptureFilename(seq),
shortName: this.getRoomName(seq),
isDefaultName: !this.hasNameChanged(seq),
freeJoin,
@ -607,7 +625,7 @@ class BreakoutRoom extends PureComponent {
return breakoutJoinedUsers.filter((room) => room.sequence === sequence)[0].joinedUsers || [];
}
getRoomName(position) {
getRoomName(position, padWithZeroes = false) {
const { intl } = this.props;
const { roomNamesChanged } = this.state;
@ -615,7 +633,9 @@ class BreakoutRoom extends PureComponent {
return roomNamesChanged[position];
}
return intl.formatMessage(intlMessages.breakoutRoom, { 0: position });
return intl.formatMessage(intlMessages.breakoutRoom, {
0: padWithZeroes ? `${position}`.padStart(2, '0') : position
});
}
getFullName(position) {
@ -624,6 +644,22 @@ class BreakoutRoom extends PureComponent {
return `${meetingName} (${this.getRoomName(position)})`;
}
getCaptureFilename(position, slides = true) {
const { intl } = this.props;
const presentations = PresentationUploaderService.getPresentations();
const captureType = slides
? intl.formatMessage(intlMessages.captureSlidesType)
: intl.formatMessage(intlMessages.captureNotesType);
const fileName = `${this.getRoomName(position,true)}_${captureType}`.replace(/ /g, '_');
const fileNameDuplicatedCount = presentations.filter((pres) => pres.filename?.startsWith(fileName)
|| pres.name?.startsWith(fileName)).length;
return fileNameDuplicatedCount === 0 ? fileName : `${fileName}(${fileNameDuplicatedCount + 1})`;
}
resetUserWhenRoomsChange(rooms) {
const { users } = this.state;
const filtredUsers = users.filter((u) => u.room > rooms);

View File

@ -2,15 +2,29 @@ import React from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import ActionsBarService from '/imports/ui/components/actions-bar/service';
import BreakoutRoomService from '/imports/ui/components/breakout-room/service';
import CreateBreakoutRoomModal from './component';
import { isImportSharedNotesFromBreakoutRoomsEnabled, isImportPresentationWithAnnotationsFromBreakoutRoomsEnabled } from '/imports/ui/services/features';
const METEOR_SETTINGS_APP = Meteor.settings.public.app;
const CreateBreakoutRoomContainer = (props) => {
const { allowUserChooseRoomByDefault } = METEOR_SETTINGS_APP.breakouts;
const captureWhiteboardByDefault = METEOR_SETTINGS_APP.breakouts.captureWhiteboardByDefault
&& isImportPresentationWithAnnotationsFromBreakoutRoomsEnabled();
const captureSharedNotesByDefault = METEOR_SETTINGS_APP.breakouts.captureSharedNotesByDefault
&& isImportSharedNotesFromBreakoutRoomsEnabled();
const { amIModerator } = props;
return (
amIModerator
&& (
<CreateBreakoutRoomModal {...props} />
<CreateBreakoutRoomModal
{...props}
{...{
allowUserChooseRoomByDefault,
captureWhiteboardByDefault,
captureSharedNotesByDefault,
}}
/>
)
);
};

View File

@ -559,7 +559,13 @@ class App extends Component {
? <ExternalVideoContainer isLayoutSwapped={!presentationIsOpen} isPresenter={isPresenter} />
: null
}
{shouldShowSharedNotes ? <NotesContainer area="media" layoutType={selectedLayout} /> : null}
{shouldShowSharedNotes
? (
<NotesContainer
area="media"
layoutType={selectedLayout}
/>
) : null}
{this.renderCaptions()}
<AudioCaptionsSpeechContainer />
{this.renderAudioCaptions()}

View File

@ -64,7 +64,8 @@ const Select = ({
locale,
voices,
}) => {
if (!enabled || SpeechService.useFixedLocale()) return null
const useLocaleHook = SpeechService.useFixedLocale();
if (!enabled || useLocaleHook) return null;
if (voices.length === 0) {
return (

View File

@ -3,12 +3,56 @@ import browserInfo from '/imports/utils/browserInfo';
const MEDIA_TAG = Meteor.settings.public.media.mediaTag;
const USE_RTC_LOOPBACK_CHR = Meteor.settings.public.media.localEchoTest.useRtcLoopbackInChromium;
const {
enabled: DELAY_ENABLED = true,
delayTime = 0.5,
maxDelayTime = 2,
} = Meteor.settings.public.media.localEchoTest.delay;
let audioContext = null;
let sourceContext = null;
let delayNode = null;
const useRTCLoopback = () => (browserInfo.isChrome || browserInfo.isEdge) && USE_RTC_LOOPBACK_CHR;
const createAudioRTCLoopback = () => new LocalPCLoopback({ audio: true });
const cleanupDelayNode = () => {
if (delayNode) {
delayNode.disconnect();
delayNode = null;
}
if (sourceContext) {
sourceContext.disconnect();
sourceContext = null;
}
if (audioContext) {
audioContext.close();
audioContext = null;
}
};
const addDelayNode = (stream) => {
if (stream) {
if (delayNode || audioContext || sourceContext) cleanupDelayNode();
audioContext = new AudioContext();
sourceContext = audioContext.createMediaStreamSource(stream);
delayNode = new DelayNode(audioContext, { delayTime, maxDelayTime });
sourceContext.connect(delayNode);
delayNode.connect(audioContext.destination);
delayNode.delayTime.setValueAtTime(delayTime, audioContext.currentTime);
}
};
const deattachEchoStream = () => {
const audioElement = document.querySelector(MEDIA_TAG);
if (DELAY_ENABLED) {
audioElement.muted = false;
cleanupDelayNode();
}
audioElement.pause();
audioElement.srcObject = null;
};
@ -28,6 +72,11 @@ const playEchoStream = async (stream, loopbackAgent = null) => {
}
}
if (DELAY_ENABLED) {
// Start muted to avoid weird artifacts and prevent playing the stream twice (Chromium)
audioElement.muted = true;
addDelayNode(streamToPlay);
}
audioElement.srcObject = streamToPlay;
audioElement.play();
}

View File

@ -430,7 +430,7 @@ class BreakoutRoom extends PureComponent {
const { animations } = Settings.application;
const roomItems = breakoutRooms.map((breakout) => (
<Styled.BreakoutItems key={`breakoutRoomItems-${breakout.breakoutId}`} >
<Styled.BreakoutItems key={`breakoutRoomItems-${breakout.breakoutId}`}>
<Styled.Content key={`breakoutRoomList-${breakout.breakoutId}`}>
<Styled.BreakoutRoomListNameLabel data-test={breakout.shortName} aria-hidden>
{breakout.isDefaultName

View File

@ -7,7 +7,6 @@ import UserListService from '/imports/ui/components/user-list/service';
import fp from 'lodash/fp';
import UsersPersistentData from '/imports/api/users-persistent-data';
import { UploadingPresentations } from '/imports/api/presentations';
import _ from 'lodash';
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
@ -43,7 +42,7 @@ const upsertCapturedContent = (filename, temporaryPresentationId) => {
temporaryPresentationId,
}, {
$set: {
id: _.uniqueId(filename),
id: temporaryPresentationId,
temporaryPresentationId,
progress: 0,
filename,
@ -62,6 +61,7 @@ const setCapturedContentUploading = () => {
breakoutRooms.forEach((breakout) => {
const filename = breakout.shortName;
const temporaryPresentationId = breakout.breakoutId;
if (breakout.captureNotes) {
upsertCapturedContent(filename, `${temporaryPresentationId}-notes`);
}

View File

@ -331,8 +331,8 @@ const removePackagedClassAttribute = (classnames, attribute) => {
const getExportedPresentationString = (fileURI, filename, intl) => {
const warningIcon = `<i class="icon-bbb-warning"></i>`;
const label = `<span>${intl.formatMessage(intlMessages.download)}</span>`;
const notAccessibleWarning = `<span>(${warningIcon} ${intl.formatMessage(intlMessages.notAccessibleWarning)})</span>`;
const link = `<a href=${fileURI} type="application/pdf" rel="noopener, noreferrer" download>${label}&nbsp;${notAccessibleWarning}</a>`;
const notAccessibleWarning = `<span title="${intl.formatMessage(intlMessages.notAccessibleWarning)}">${warningIcon}</span>`;
const link = `<a aria-label="${intl.formatMessage(intlMessages.notAccessibleWarning)}" href=${fileURI} type="application/pdf" rel="noopener, noreferrer" download>${label}&nbsp;${notAccessibleWarning}</a>`;
const name = `<span>${filename}</span>`;
return `${name}</br>${link}`;
};

View File

@ -20,6 +20,12 @@ export const getModal = () => {
export const withModalMounter = ComponentToWrap =>
class ModalMounterWrapper extends PureComponent {
componentDidMount(){
// needs to ne executed as initialization
currentModal.tracker.changed();
}
static mount(modalComponent) {
showModal(null);
// defer the execution to a subsequent event loop

View File

@ -77,12 +77,12 @@ class ConnectionStatusButton extends PureComponent {
color = 'success';
}
const level = stats ? stats : 'normal';
const currentStatus = stats ? stats : 'normal';
return (
<Styled.ButtonWrapper>
<Button
customIcon={this.renderIcon(level)}
customIcon={this.renderIcon(currentStatus)}
label={intl.formatMessage(intlMessages.label)}
hideLabel
aria-label={intl.formatMessage(intlMessages.description)}

View File

@ -8,7 +8,7 @@ import Service from '../service';
import Styled from './styles';
import ConnectionStatusHelper from '../status-helper/container';
const NETWORK_MONITORING_INTERVAL_MS = 2000;
const NETWORK_MONITORING_INTERVAL_MS = 2000;
const MIN_TIMEOUT = 3000;
const intlMessages = defineMessages({
@ -128,6 +128,10 @@ const intlMessages = defineMessages({
id: 'app.connection-status.prev',
description: 'Label for the previous page of the connection stats tab',
},
clientNotResponding: {
id: 'app.connection-status.clientNotRespondingWarning',
description: 'Text for Client not responding warning',
},
});
const propTypes = {
@ -345,7 +349,6 @@ class ConnectionStatusComponent extends PureComponent {
return connections.map((conn, index) => {
const dateTime = new Date(conn.timestamp);
return (
<Styled.Item
key={`${conn?.name}-${dateTime}`}
@ -373,11 +376,17 @@ class ConnectionStatusComponent extends PureComponent {
{conn.offline ? ` (${intl.formatMessage(intlMessages.offline)})` : null}
</Styled.Text>
</Styled.Name>
<Styled.Status aria-label={`${intl.formatMessage(intlMessages.title)} ${conn.level}`}>
<Styled.Status aria-label={`${intl.formatMessage(intlMessages.title)} ${conn.status}`}>
<Styled.Icon>
<Icon level={conn.level} />
<Icon level={conn.status} />
</Styled.Icon>
</Styled.Status>
{ conn.notResponding && !conn.offline
? (
<Styled.ClientNotRespondingText>
{intl.formatMessage(intlMessages.clientNotResponding)}
</Styled.ClientNotRespondingText>
) : null }
</Styled.Left>
<Styled.Right>
<Styled.Time>

View File

@ -9,6 +9,7 @@ import {
colorPrimary,
colorWhite,
btnPrimaryActiveBg,
colorDanger,
} from '/imports/ui/stylesheets/styled-components/palette';
import {
smPaddingX,
@ -70,6 +71,20 @@ const FullName = styled(Name)`
width: 100%;
`;
const ClientNotRespondingText = styled.div`
display: flex;
width: 27.5%;
height: 100%;
align-items: center;
justify-content: flex-start;
color: ${colorDanger};
@media ${hasPhoneDimentions} {
width: 100%;
}
`;
const Text = styled.div`
padding-left: .5rem;
white-space: nowrap;
@ -454,6 +469,7 @@ export default {
NetworkData,
CopyContainer,
ConnectionStatusModal,
ClientNotRespondingText,
Container,
Header,
Title,

View File

@ -28,9 +28,9 @@ const intlMessages = defineMessages({
},
});
let stats = -1;
let lastLevel = -1;
let lastRtt = null;
const statsDep = new Tracker.Dependency();
const levelDep = new Tracker.Dependency();
let statsTimeout = null;
@ -42,22 +42,16 @@ const getHelp = () => {
};
const getStats = () => {
statsDep.depend();
return STATS.level[stats];
levelDep.depend();
return STATS.level[lastLevel];
};
const setStats = (level = -1, type = 'recovery', value = {}) => {
if (stats !== level) {
stats = level;
statsDep.changed();
addConnectionStatus(level, type, value);
}
};
const handleStats = (level, type, value) => {
if (level > stats) {
setStats(level, type, value);
if (lastLevel !== level) {
lastLevel = level;
levelDep.changed();
}
addConnectionStatus(level, type, value);
};
const handleAudioStatsEvent = (event) => {
@ -69,7 +63,7 @@ const handleAudioStatsEvent = (event) => {
for (let i = STATS.level.length - 1; i >= 0; i--) {
if (loss >= STATS.loss[i] || jitter >= STATS.jitter[i]) {
active = true;
handleStats(i, 'audio', { loss, jitter });
setStats(i, 'audio', { loss, jitter });
break;
}
}
@ -83,15 +77,18 @@ const handleSocketStatsEvent = (event) => {
if (detail) {
const { rtt } = detail;
let active = false;
let level = -1;
// From higher to lower
for (let i = STATS.level.length - 1; i >= 0; i--) {
if (rtt >= STATS.rtt[i]) {
active = true;
handleStats(i, 'socket', { rtt });
level = i;
break;
}
}
setStats(level, 'socket', { rtt });
if (active) startStatsTimeout();
}
};
@ -100,7 +97,7 @@ const startStatsTimeout = () => {
if (statsTimeout !== null) clearTimeout(statsTimeout);
statsTimeout = setTimeout(() => {
setStats();
setStats(-1, 'recovery', {});
}, STATS.timeout);
};
@ -108,16 +105,34 @@ const addConnectionStatus = (level, type, value) => {
const status = level !== -1 ? STATS.level[level] : 'normal';
makeCall('addConnectionStatus', status, type, value);
}
};
let rttCalcStartedAt = 0;
const fetchRoundTripTime = () => {
// if client didn't receive response from last "voidConnection"
// calculate the rtt from last call time and notify user of connection loss
if (rttCalcStartedAt !== 0) {
const tf = Date.now();
const rtt = tf - rttCalcStartedAt;
if (rtt > STATS.rtt[STATS.rtt.length - 1]) {
const event = new CustomEvent('socketstats', { detail: { rtt } });
window.dispatchEvent(event);
}
}
const t0 = Date.now();
rttCalcStartedAt = t0;
makeCall('voidConnection', lastRtt).then(() => {
const tf = Date.now();
const rtt = tf - t0;
const event = new CustomEvent('socketstats', { detail: { rtt } });
window.dispatchEvent(event);
lastRtt = rtt;
rttCalcStartedAt = 0;
});
};
@ -136,67 +151,34 @@ const sortOffline = (a, b) => {
if (!a.offline && b.offline) return -1;
};
const getMyConnectionStatus = () => {
const myConnectionStatus = ConnectionStatus.findOne(
{
meetingId: Auth.meetingID,
userId: Auth.userID,
},
{
fields:
{
level: 1,
timestamp: 1,
},
},
);
const getConnectionStatus = () => {
const connectionLossTimeThreshold = new Date().getTime() - (STATS_INTERVAL);
const me = Users.findOne(
{
meetingId: Auth.meetingID,
userId: Auth.userID,
},
{
fields:
{
avatar: 1,
color: 1,
},
},
);
const selector = {
meetingId: Auth.meetingID,
$or: [
{ status: { $exists: true } },
{ connectionAliveAt: { $lte: connectionLossTimeThreshold } },
],
};
if (myConnectionStatus) {
return [{
name: Auth.fullname,
avatar: me.avatar,
offline: false,
you: true,
moderator: false,
color: me.color,
level: myConnectionStatus.level,
timestamp: myConnectionStatus.timestamp,
}];
if (!isModerator()) {
selector.userId = Auth.userID;
}
return [];
};
const getConnectionStatus = () => {
if (!isModerator()) return getMyConnectionStatus();
const connectionStatus = ConnectionStatus.find(
{ meetingId: Auth.meetingID },
).fetch().map((status) => {
const connectionStatus = ConnectionStatus.find(selector).fetch().map((userStatus) => {
const {
userId,
level,
timestamp,
} = status;
status,
statusUpdatedAt,
connectionAliveAt,
} = userStatus;
return {
userId,
level,
timestamp,
status,
statusUpdatedAt,
connectionAliveAt,
};
});
@ -223,19 +205,24 @@ const getConnectionStatus = () => {
loggedOut,
} = user;
const status = connectionStatus.find(status => status.userId === userId);
const userStatus = connectionStatus.find((userConnStatus) => userConnStatus.userId === userId);
if (status) {
result.push({
name,
avatar,
offline: loggedOut,
you: Auth.userID === userId,
moderator: role === ROLE_MODERATOR,
color,
level: status.level,
timestamp: status.timestamp,
});
if (userStatus) {
const notResponding = userStatus.connectionAliveAt < connectionLossTimeThreshold;
if (userStatus.status || (!loggedOut && notResponding)) {
result.push({
name,
avatar,
offline: loggedOut,
notResponding,
you: Auth.userID === userId,
moderator: role === ROLE_MODERATOR,
color,
status: notResponding ? 'critical' : userStatus.status,
timestamp: notResponding ? userStatus.connectionAliveAt : userStatus.statusUpdatedAt,
});
}
}
return result;

View File

@ -37,6 +37,9 @@ const intlMessages = defineMessages({
400: {
id: 'app.error.400',
},
meeting_ended: {
id: 'app.meeting.endedMessage',
},
user_logged_out_reason: {
id: 'app.error.userLoggedOut',
},
@ -58,6 +61,9 @@ const intlMessages = defineMessages({
max_participants_reason: {
id: 'app.meeting.logout.maxParticipantsReached',
},
guest_deny: {
id: 'app.guest.guestDeny',
},
duplicate_user_in_meeting_eject_reason: {
id: 'app.meeting.logout.duplicateUserEjectReason',
},

View File

@ -210,14 +210,26 @@ class JoinHandler extends Component {
},
}, 'User successfully went through main.joinRouteHandler');
} else {
const e = new Error(response.message);
JoinHandler.setError('401');
Session.set('errorMessageDescription', response.message);
if(['missingSession','meetingForciblyEnded','notFound'].includes(response.messageKey)) {
JoinHandler.setError('410');
Session.set('errorMessageDescription', 'meeting_ended');
} else if(response.messageKey == "guestDeny") {
JoinHandler.setError('401');
Session.set('errorMessageDescription', 'guest_deny');
} else if(response.messageKey == "maxParticipantsReached") {
JoinHandler.setError('401');
Session.set('errorMessageDescription', 'max_participants_reason');
} else {
JoinHandler.setError('401');
Session.set('errorMessageDescription', response.message);
}
logger.error({
logCode: 'joinhandler_component_joinroutehandler_error',
extraInfo: {
response,
error: e,
error: new Error(response.message),
},
}, 'User faced an error on main.joinRouteHandler.');
}

View File

@ -100,7 +100,7 @@ const intlMessage = defineMessages({
},
user_inactivity_eject_reason: {
id: 'app.meeting.logout.userInactivityEjectReason',
description: 'message for whom was kicked by inactivity',
description: 'message to whom was kicked by inactivity',
},
open_activity_report_btn: {
id: 'app.learning-dashboard.clickHereToOpen',
@ -142,7 +142,7 @@ class MeetingEnded extends PureComponent {
this.localUserRole = user.role;
}
const meeting = Meetings.findOne({ id: user.meetingID });
const meeting = Meetings.findOne({ id: user?.meetingID });
if (meeting) {
this.endWhenNoModeratorMinutes = meeting.durationProps.endWhenNoModeratorDelayInMinutes;

View File

@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component';
@ -12,7 +12,7 @@ import NotesDropdown from '/imports/ui/components/notes/notes-dropdown/container
const CHAT_CONFIG = Meteor.settings.public.chat;
const PUBLIC_CHAT_ID = CHAT_CONFIG.public_id;
const DELAY_UNMOUNT_SHARED_NOTES = Meteor.settings.public.app.delayForUnmountOfSharedNote;
const intlMessages = defineMessages({
hide: {
id: 'app.notes.hide',
@ -47,6 +47,8 @@ const defaultProps = {
layoutType: null,
};
let timoutRef = null;
const sidebarContentToIgnoreDelay = ['captions'];
const Notes = ({
hasPermission,
intl,
@ -58,18 +60,40 @@ const Notes = ({
sidebarContent,
sharedNotesOutput,
amIPresenter,
isToSharedNotesBeShow,
shouldShowSharedNotesOnPresentationArea,
}) => {
useEffect(() => () => Service.setLastRev(), []);
const [shouldRenderNotes, setShouldRenderNotes] = useState(false);
const { isChrome } = browserInfo;
const isOnMediaArea = area === 'media';
const style = isOnMediaArea ? {
position: 'absolute',
...sharedNotesOutput,
} : {};
const isHidden = isOnMediaArea && (style.width === 0 || style.height === 0);
if (isHidden) style.padding = 0;
const isHidden = (isOnMediaArea && (style.width === 0 || style.height === 0))
|| (!isToSharedNotesBeShow
&& !sidebarContentToIgnoreDelay.includes(sidebarContent.sidebarContentPanel))
|| shouldShowSharedNotesOnPresentationArea;
if (isHidden) {
style.padding = 0;
style.display = 'none';
}
useEffect(() => {
if (isToSharedNotesBeShow) {
setShouldRenderNotes(true);
clearTimeout(timoutRef);
} else {
timoutRef = setTimeout(() => {
setShouldRenderNotes(false);
}, (sidebarContentToIgnoreDelay.includes(sidebarContent.sidebarContentPanel)
|| shouldShowSharedNotesOnPresentationArea)
? 0 : DELAY_UNMOUNT_SHARED_NOTES);
}
return () => clearTimeout(timoutRef);
}, [isToSharedNotesBeShow, sidebarContent.sidebarContentPanel]);
useEffect(() => {
if (
isOnMediaArea
@ -128,7 +152,7 @@ const Notes = ({
) : null;
};
return (
return (shouldRenderNotes || shouldShowSharedNotesOnPresentationArea) && (
<Styled.Notes data-test="notes" isChrome={isChrome} style={style}>
{!isOnMediaArea ? (
<Header

View File

@ -3,6 +3,7 @@ import { withTracker } from 'meteor/react-meteor-data';
import Notes from './component';
import Service from './service';
import Auth from '/imports/ui/services/auth';
import MediaService from '/imports/ui/components/media/service';
import { layoutSelectInput, layoutDispatch, layoutSelectOutput } from '../layout/context';
import { UsersContext } from '../components-data/users-context/context';
@ -29,9 +30,10 @@ const Container = ({ ...props }) => {
export default withTracker(() => {
const hasPermission = Service.hasPermission();
const isRTL = document.documentElement.getAttribute('dir') === 'rtl';
const shouldShowSharedNotesOnPresentationArea = MediaService.shouldShowSharedNotes();
return {
hasPermission,
isRTL,
shouldShowSharedNotesOnPresentationArea,
};
})(Container);

View File

@ -16,7 +16,7 @@ import AnnotationGroupContainer from '../whiteboard/annotation-group/container';
import PresentationOverlayContainer from './presentation-overlay/container';
import Slide from './slide/component';
import Styled from './styles';
import MediaService, { shouldEnableSwapLayout } from '../media/service';
import MediaService from '../media/service';
// import PresentationCloseButton from './presentation-close-button/component';
import DownloadPresentationButton from './download-presentation-button/component';
import FullscreenService from '/imports/ui/components/common/fullscreen-button/service';
@ -891,7 +891,6 @@ class Presentation extends PureComponent {
tldrawAPI={this.state.tldrawAPI}
elementName={intl.formatMessage(intlMessages.presentationLabel)}
elementId={fullscreenElementId}
toggleSwapLayout={MediaService.toggleSwapLayout}
layoutContextDispatch={layoutContextDispatch}
/>
);
@ -913,7 +912,6 @@ class Presentation extends PureComponent {
layoutType,
numCameras,
currentPresentation,
layoutSwapped,
podId,
intl,
isViewersCursorLocked,
@ -993,7 +991,7 @@ class Presentation extends PureComponent {
right: presentationBounds.right,
width: presentationBounds.width,
height: presentationBounds.height,
display: layoutSwapped ? 'none' : 'flex',
display: !presentationIsOpen ? 'none' : 'flex',
overflow: 'hidden',
zIndex: fullscreenContext ? presentationBounds.zIndex : undefined,
background: layoutType === LAYOUT_TYPE.VIDEO_FOCUS && numCameras > 0 && !fullscreenContext
@ -1029,6 +1027,8 @@ class Presentation extends PureComponent {
intl={intl}
presentationWidth={svgWidth}
presentationHeight={svgHeight}
presentationAreaHeight={presentationBounds?.height}
presentationAreaWidth={presentationBounds?.width}
isViewersCursorLocked={isViewersCursorLocked}
isPanning={this.state.isPanning}
zoomChanger={this.zoomChanger}
@ -1113,6 +1113,7 @@ Presentation.propTypes = {
}),
// current multi-user status
multiUser: PropTypes.bool.isRequired,
setPresentationIsOpen: PropTypes.func.isRequired,
};
Presentation.defaultProps = {

View File

@ -18,6 +18,7 @@ import {
import lockContextContainer from "/imports/ui/components/lock-viewers/context/container";
import WhiteboardService from '/imports/ui/components/whiteboard/service';
import { DEVICE_TYPE } from '../layout/enums';
import MediaService from '../media/service';
const PresentationContainer = ({ presentationIsOpen, presentationPodIds, mountPresentation, ...props }) => {
@ -139,5 +140,6 @@ export default lockContextContainer(
multiUserSize: WhiteboardService.getMultiUserSize(currentSlide?.id),
isViewersCursorLocked,
clearFakeAnnotations: WhiteboardService.clearFakeAnnotations,
setPresentationIsOpen: MediaService.setPresentationIsOpen,
};
})(PresentationContainer));

View File

@ -293,7 +293,7 @@ const PresentationMenu = (props) => {
transformOrigin: { vertical: 'top', horizontal: isRTL ? 'right' : 'left' },
container: fullscreenRef
}}
actions={getAvailableOptions()}
actions={options}
/>
</Styled.Right>
);

View File

@ -81,6 +81,10 @@ const intlMessages = defineMessages({
id: 'app.presentationUploder.conversion.unsupportedDocument',
description: 'warns the user that the file extension is not supported',
},
204: {
id: 'app.presentationUploder.conversion.204',
description: 'error indicating that the file has no content to capture',
},
fileToUpload: {
id: 'app.presentationUploder.fileToUpload',
description: 'message used in the file selected for upload',

View File

@ -483,6 +483,7 @@ class ApplicationMenu extends BaseMenu {
label={intl.formatMessage(intlMessages.decreaseFontBtnLabel)}
aria-label={`${intl.formatMessage(intlMessages.decreaseFontBtnLabel)}, ${ariaValueLabel}`}
disabled={isSmallestFontSize}
data-test="decreaseFontSize"
/>
</Styled.Col>
<Styled.Col>
@ -495,6 +496,7 @@ class ApplicationMenu extends BaseMenu {
label={intl.formatMessage(intlMessages.increaseFontBtnLabel)}
aria-label={`${intl.formatMessage(intlMessages.increaseFontBtnLabel)}, ${ariaValueLabel}`}
disabled={isLargestFontSize}
data-test="increaseFontSize"
/>
</Styled.Col>
</Styled.PullContentRight>

View File

@ -134,10 +134,12 @@ const SidebarContent = (props) => {
<ErrorBoundary
Fallback={FallbackView}
>
<ChatContainer width={width}/>
<ChatContainer width={width} />
</ErrorBoundary>
)}
{sidebarContentPanel === PANELS.SHARED_NOTES && <NotesContainer />}
<NotesContainer
isToSharedNotesBeShow={sidebarContentPanel === PANELS.SHARED_NOTES}
/>
{sidebarContentPanel === PANELS.CAPTIONS && <CaptionsContainer />}
{sidebarContentPanel === PANELS.BREAKOUT && <BreakoutRoomContainer />}
{sidebarContentPanel === PANELS.WAITING_USERS && <WaitingUsersPanel />}

View File

@ -13,8 +13,6 @@ const SidebarContentContainer = () => {
const { users } = usingUsersContext;
const amIPresenter = users[Auth.meetingID][Auth.userID].presenter;
if (sidebarContentOutput.display === false) return null;
return (
<SidebarContent
{...sidebarContentOutput}

View File

@ -524,12 +524,14 @@ class UserListItem extends PureComponent {
checkboxMessageId="app.userlist.menu.removeConfirmation.desc"
confirmParam={user.userId}
onConfirm={removeUser}
confirmButtonDataTest="removeUserConfirmation"
/>,
));
this.handleClose();
},
icon: 'circle_close',
dataTest: 'removeUser'
},
{
allowed: allowedToEjectCameras

View File

@ -838,7 +838,7 @@ class VideoPreview extends Component {
min={0}
max={200}
value={brightness}
aria-describedBy={'brightness-slider-desc'}
aria-describedby={'brightness-slider-desc'}
onChange={(e) => {
const brightness = e.target.valueAsNumber;
this.currentVideoStream.changeCameraBrightness(brightness);

View File

@ -6,6 +6,7 @@ import { TldrawApp, Tldraw } from "@tldraw/tldraw";
import SlideCalcUtil, {HUNDRED_PERCENT} from '/imports/utils/slideCalcUtils';
import { Utils } from "@tldraw/core";
import Settings from '/imports/ui/services/settings';
import logger from '/imports/startup/client/logger';
function usePrevious(value) {
const ref = React.useRef();
@ -23,25 +24,20 @@ const findRemoved = (A, B) => {
// map different localeCodes from bbb to tldraw
const mapLanguage = (language) => {
switch(language) {
case 'fa-ir':
return 'fa';
case 'it-it':
return 'it';
// bbb has xx-xx but in tldraw it's only xx
if (['es', 'fa', 'it', 'pl', 'sv', 'uk'].some((lang) => language.startsWith(lang))) {
return language.substring(0, 2);
}
// exceptions
switch (language) {
case 'nb-no':
return 'no';
case 'pl-pl':
return 'pl';
case 'sv-se':
return 'sv';
case 'uk-ua':
return 'uk';
case 'zh-cn':
return 'zh-ch';
default:
return language;
}
}
};
const SMALL_HEIGHT = 435;
const SMALLEST_HEIGHT = 363;
@ -60,10 +56,28 @@ const TldrawGlobalStyle = createGlobalStyle`
#TD-PrimaryTools-Image {
display: none;
}
#slide-background-shape div {
pointer-events: none;
}
[aria-expanded*="false"][aria-controls*="radix-"] {
display: none;
}
${({ hasWBAccess, isPresenter, size }) => (hasWBAccess || isPresenter) && `
#TD-Tools-Dots {
height: ${size}px;
width: ${size}px;
}
#TD-Delete {
& button {
height: ${size}px;
width: ${size}px;
}
}
#TD-PrimaryTools button {
height: ${size}px;
width: ${size}px;
}
`}
`;
const EditableWBWrapper = styled.div`
@ -102,6 +116,10 @@ export default function Whiteboard(props) {
intl,
svgUri,
maxStickyNoteLength,
fontFamily,
hasShapeAccess,
presentationAreaHeight,
presentationAreaWidth,
} = props;
const { pages, pageStates } = initDefaultPages(curPres?.pages.length || 1);
@ -118,6 +136,8 @@ export default function Whiteboard(props) {
const [history, setHistory] = React.useState(null);
const [forcePanning, setForcePanning] = React.useState(false);
const [zoom, setZoom] = React.useState(HUNDRED_PERCENT);
const [tldrawZoom, setTldrawZoom] = React.useState(1);
const [enable, setEnable] = React.useState(true);
const [isMounting, setIsMounting] = React.useState(true);
const prevShapes = usePrevious(shapes);
const prevSlidePosition = usePrevious(slidePosition);
@ -126,28 +146,72 @@ export default function Whiteboard(props) {
const language = mapLanguage(Settings?.application?.locale?.toLowerCase() || 'en');
const [currentTool, setCurrentTool] = React.useState(null);
const calculateZoom = (width, height) => {
let zoom = fitToWidth
? presentationWidth / width
: Math.min(
(presentationWidth) / width,
(presentationHeight) / height
);
const throttledResetCurrentPoint = React.useRef(_.throttle(() => {
setEnable(false);
setEnable(true);
}, 1000, { trailing: true }));
return zoom;
const calculateZoom = (width, height) => {
const calcedZoom = fitToWidth ? (presentationWidth / width) : Math.min(
(presentationWidth) / width,
(presentationHeight) / height
);
return (calcedZoom === 0 || calcedZoom === Infinity) ? HUNDRED_PERCENT : calcedZoom;
}
const hasShapeAccess = (id) => {
const owner = shapes[id]?.userId;
const isBackgroundShape = id?.includes('slide-background');
const hasShapeAccess = !isBackgroundShape && ((owner && owner === currentUser?.userId) || !owner || isPresenter || isModerator);
return hasShapeAccess;
const isValidShapeType = (shape) => {
const invalidTypes = ['image', 'video'];
return !invalidTypes.includes(shape?.type);
}
const filterInvalidShapes = (shapes) => {
const keys = Object.keys(shapes);
const removedChildren = [];
const removedParents = [];
keys.forEach((shape) => {
if (shapes[shape].parentId !== curPageId) {
if(!keys.includes(shapes[shape].parentId)) {
delete shapes[shape];
}
}else{
if (shapes[shape].type === "group") {
const groupChildren = shapes[shape].children;
groupChildren.forEach((child) => {
if (!keys.includes(child)) {
removedChildren.push(child);
}
});
shapes[shape].children = groupChildren.filter((child) => !removedChildren.includes(child));
if (shapes[shape].children.length < 2) {
removedParents.push(shape);
delete shapes[shape];
}
}
}
});
// remove orphaned children
Object.keys(shapes).forEach((shape) => {
if (shapes[shape] && shapes[shape].parentId !== curPageId) {
if (removedParents.includes(shapes[shape].parentId)) {
delete shapes[shape];
}
}
});
return shapes;
}
const sendShapeChanges= (app, changedShapes, redo = false) => {
const invalidChange = Object.keys(changedShapes)
.find(id => !hasShapeAccess(id));
if (invalidChange) {
.find(id => !hasShapeAccess(id));
const invalidShapeType = Object.keys(changedShapes)
.find(id => !isValidShapeType(changedShapes[id]));
if (invalidChange || invalidShapeType) {
notifyNotAllowedChange(intl);
// undo last command without persisting to not generate the onUndo/onRedo callback
if (!redo) {
@ -196,13 +260,55 @@ export default function Whiteboard(props) {
persistShape(shape, whiteboardId);
}
});
removeShapes(deletedShapes, whiteboardId);
//order the ids of shapes being deleted to prevent crash when removing a group shape before its children
const orderedDeletedShapes = [];
deletedShapes.forEach(eid => {
if (shapes[eid]?.type !== 'group') {
orderedDeletedShapes.unshift(eid);
} else {
orderedDeletedShapes.push(eid)
}
});
if (orderedDeletedShapes.length > 0) {
removeShapes(orderedDeletedShapes, whiteboardId);
}
}
React.useEffect(() => {
props.setTldrawIsMounting(true);
}, []);
const checkClientBounds = (e) => {
if (
e.clientX > document.documentElement.clientWidth ||
e.clientX < 0 ||
e.clientY > document.documentElement.clientHeight ||
e.clientY < 0
) {
if (tldrawAPI?.session) {
tldrawAPI?.completeSession?.();
}
}
};
const checkVisibility = () => {
if (document.visibilityState === 'hidden' && tldrawAPI?.session) {
tldrawAPI?.completeSession?.();
}
};
React.useEffect(() => {
document.addEventListener('mouseup', checkClientBounds);
document.addEventListener('visibilitychange', checkVisibility);
return () => {
document.removeEventListener('mouseup', checkClientBounds);
document.removeEventListener('visibilitychange', checkVisibility);
};
}, [tldrawAPI]);
const doc = React.useMemo(() => {
const currentDoc = rDocument.current;
@ -216,33 +322,38 @@ export default function Whiteboard(props) {
if (editingShape) {
shapes[editingShape?.id] = editingShape;
}
// set shapes as locked for those who aren't allowed to edit it
Object.entries(shapes).forEach(([shapeId, shape]) => {
if (!shape.isLocked && !hasShapeAccess(shapeId)) {
shape.isLocked = true;
}
});
const removed = prevShapes && findRemoved(Object.keys(prevShapes),Object.keys((shapes)))
const removed = prevShapes && findRemoved(Object.keys(prevShapes),Object.keys((shapes)));
if (removed && removed.length > 0) {
tldrawAPI?.patchState(
{
document: {
pageStates: {
[curPageId]: {
selectedIds: tldrawAPI?.selectedIds?.filter(id => !removed.includes(id)) || [],
const patchedShapes = Object.fromEntries(removed.map((id) => [id, undefined]));
try {
tldrawAPI?.patchState(
{
document: {
pageStates: {
[curPageId]: {
selectedIds: tldrawAPI?.selectedIds?.filter(id => !removed.includes(id)) || [],
},
},
},
pages: {
[curPageId]: {
shapes: Object.fromEntries(removed.map((id) => [id, undefined])),
pages: {
[curPageId]: {
shapes: patchedShapes,
},
},
},
},
},
);
);
} catch (error) {
logger.error({
logCode: 'whiteboard_shapes_remove_error',
extraInfo: { error },
}, 'Whiteboard catch error on removing shapes');
}
}
next.pages[curPageId].shapes = shapes;
next.pages[curPageId].shapes = filterInvalidShapes(shapes);
changed = true;
}
@ -255,6 +366,7 @@ export default function Whiteboard(props) {
},
},
);
changed = true;
}
if (changed && tldrawAPI) {
@ -262,19 +374,30 @@ export default function Whiteboard(props) {
const patch = {
document: {
pages: {
[curPageId]: { shapes: shapes }
[curPageId]: { shapes: filterInvalidShapes(shapes) }
},
},
};
const prevState = tldrawAPI._state;
const nextState = Utils.deepMerge(tldrawAPI._state, patch);
if(nextState.document.pages[curPageId].shapes) {
filterInvalidShapes(nextState.document.pages[curPageId].shapes);
}
const final = tldrawAPI.cleanup(nextState, prevState, patch, '');
tldrawAPI._state = final;
tldrawAPI?.forceUpdate();
try {
tldrawAPI?.forceUpdate();
} catch (e) {
logger.error({
logCode: 'whiteboard_shapes_update_error',
extraInfo: { error },
}, 'Whiteboard catch error on updating shapes');
}
}
// move poll result text to bottom right
if (next.pages[curPageId]) {
if (next.pages[curPageId] && slidePosition) {
const pollResults = Object.entries(next.pages[curPageId].shapes)
.filter(([id, shape]) => shape.name?.includes("poll-result"))
for (const [id, shape] of pollResults) {
@ -323,7 +446,7 @@ export default function Whiteboard(props) {
}, [presentationWidth, presentationHeight, curPageId, document?.documentElement?.dir]);
React.useEffect(() => {
if (presentationWidth > 0 && presentationHeight > 0) {
if (presentationWidth > 0 && presentationHeight > 0 && slidePosition) {
const cameraZoom = tldrawAPI?.getPageState()?.camera?.zoom;
const newzoom = calculateZoom(slidePosition.viewBoxWidth, slidePosition.viewBoxHeight);
if (cameraZoom && cameraZoom === 1) {
@ -348,7 +471,7 @@ export default function Whiteboard(props) {
// change tldraw page when presentation page changes
React.useEffect(() => {
if (tldrawAPI && curPageId) {
if (tldrawAPI && curPageId && slidePosition) {
tldrawAPI.changePage(curPageId);
let zoom = prevSlidePosition
? calculateZoom(prevSlidePosition.viewBoxWidth, prevSlidePosition.viewBoxHeight)
@ -411,43 +534,6 @@ export default function Whiteboard(props) {
const hasWBAccess = props?.hasMultiUserAccess(props.whiteboardId, props.currentUser.userId);
React.useEffect(() => {
if (hasWBAccess || isPresenter) {
tldrawAPI?.setSetting('dockPosition', isRTL ? 'left' : 'right');
const tdToolsDots = document.getElementById("TD-Tools-Dots");
const tdDelete = document.getElementById("TD-Delete");
const tdPrimaryTools = document.getElementById("TD-PrimaryTools");
const tdTools = document.getElementById("TD-Tools");
if (tdToolsDots && tdDelete && tdPrimaryTools) {
const size = ((props.height < SMALL_HEIGHT) || (props.width < SMALL_WIDTH))
? TOOLBAR_SMALL : TOOLBAR_LARGE;
tdToolsDots.style.height = `${size}px`;
tdToolsDots.style.width = `${size}px`;
const delButton = tdDelete.getElementsByTagName('button')[0];
delButton.style.height = `${size}px`;
delButton.style.width = `${size}px`;
const primaryBtns = tdPrimaryTools?.getElementsByTagName('button');
for (let item of primaryBtns) {
item.style.height = `${size}px`;
item.style.width = `${size}px`;
}
}
if (((props.height < SMALLEST_HEIGHT) || (props.width < SMALLEST_WIDTH)) && tdTools) {
tldrawAPI?.setSetting('dockPosition', 'bottom');
tdTools.parentElement.style.bottom = `${TOOLBAR_OFFSET}px`;
}
// removes tldraw native help menu button
tdTools?.parentElement?.nextSibling?.remove();
// removes image tool from the tldraw toolbar
document.getElementById("TD-PrimaryTools-Image").style.display = 'none';
}
if (tldrawAPI) {
tldrawAPI.isForcePanning = isPanning;
}
});
React.useEffect(() => {
if (tldrawAPI) {
tldrawAPI.isForcePanning = isPanning;
@ -461,11 +547,20 @@ export default function Whiteboard(props) {
// Reset zoom to default when current presentation changes.
React.useEffect(() => {
if (isPresenter && slidePosition && tldrawAPI) {
const zoom = calculateZoom(slidePosition.width, slidePosition.height);
tldrawAPI.zoomTo(zoom);
tldrawAPI.zoomTo(0);
}
}, [curPres?.id]);
React.useEffect(() => {
const currentZoom = tldrawAPI?.getPageState()?.camera?.zoom;
if(currentZoom !== tldrawZoom) {
setTldrawZoom(currentZoom);
}else{
throttledResetCurrentPoint.current();
}
}, [presentationAreaHeight, presentationAreaWidth]);
const onMount = (app) => {
const menu = document.getElementById("TD-Styles")?.parentElement;
if (menu) {
@ -488,6 +583,7 @@ export default function Whiteboard(props) {
appState: {
currentStyle: {
textAlign: isRTL ? "end" : "start",
font: fontFamily,
},
},
}
@ -513,6 +609,8 @@ export default function Whiteboard(props) {
};
const onPatch = (e, t, reason) => {
if (!e?.pageState) return;
// don't allow select others shapes for editing if don't have permission
if (reason && reason.includes("set_editing_id")) {
if (!hasShapeAccess(e.pageState.editingId)) {
@ -552,7 +650,7 @@ export default function Whiteboard(props) {
}
}
if (reason && isPresenter && (reason.includes("zoomed") || reason.includes("panned"))) {
if (reason && isPresenter && slidePosition && (reason.includes("zoomed") || reason.includes("panned"))) {
const camera = tldrawAPI.getPageState()?.camera;
// limit bounds
@ -572,7 +670,7 @@ export default function Whiteboard(props) {
if (camera.zoom < zoomFitSlide) {
camera.zoom = zoomFitSlide;
}
tldrawAPI?.setCamera([camera.point[0], camera.point[1]], camera.zoom);
const zoomToolbar = Math.round((HUNDRED_PERCENT * camera.zoom) / zoomFitSlide * 100) / 100;
@ -704,22 +802,13 @@ export default function Whiteboard(props) {
}
};
const onPaste = (e) => {
// disable file pasting
const clipboardData = e.clipboardData || window.clipboardData;
const { types } = clipboardData;
const hasFiles = types && types.indexOf && types.indexOf('Files') !== -1;
if (hasFiles) {
e.preventDefault();
e.stopPropagation();
}
};
const webcams = document.getElementById('cameraDock');
const dockPos = webcams?.getAttribute("data-position");
if(currentTool) tldrawAPI?.selectTool(currentTool);
const editableWB = (
<EditableWBWrapper onPaste={onPaste}>
<EditableWBWrapper>
<Tldraw
key={`wb-${isRTL}-${dockPos}-${forcePanning}`}
document={doc}
@ -765,6 +854,21 @@ export default function Whiteboard(props) {
/>
);
const size = ((props.height < SMALL_HEIGHT) || (props.width < SMALL_WIDTH))
? TOOLBAR_SMALL : TOOLBAR_LARGE;
if (isPanning && tldrawAPI) {
tldrawAPI.isForcePanning = isPanning;
}
if (hasWBAccess || isPresenter) {
if (((props.height < SMALLEST_HEIGHT) || (props.width < SMALLEST_WIDTH))) {
tldrawAPI?.setSetting('dockPosition', 'bottom');
} else {
tldrawAPI?.setSetting('dockPosition', isRTL ? 'left' : 'right');
}
}
return (
<>
<Cursors
@ -777,9 +881,12 @@ export default function Whiteboard(props) {
isPanning={isPanning}
currentTool={currentTool}
>
{hasWBAccess || isPresenter ? editableWB : readOnlyWB}
<TldrawGlobalStyle
hideContextMenu={!hasWBAccess && !isPresenter}
{enable && (hasWBAccess || isPresenter) ? editableWB : readOnlyWB}
<TldrawGlobalStyle
hasWBAccess={hasWBAccess}
isPresenter={isPresenter}
hideContextMenu={!hasWBAccess && !isPresenter}
size={size}
/>
</Cursors>
</>

View File

@ -1,43 +1,92 @@
import { withTracker } from "meteor/react-meteor-data";
import Service from "./service";
import Whiteboard from "./component";
import React, { useContext } from "react";
import { UsersContext } from "../components-data/users-context/context";
import Auth from "/imports/ui/services/auth";
import PresentationToolbarService from '../presentation/presentation-toolbar/service';
import { layoutSelect } from '../layout/context';
import { withTracker } from 'meteor/react-meteor-data';
import React, { useContext } from 'react';
import {
ColorStyle,
DashStyle,
SizeStyle,
TDShapeType,
} from "@tldraw/tldraw";
} from '@tldraw/tldraw';
import {
getShapes,
getCurrentPres,
initDefaultPages,
persistShape,
removeShapes,
isMultiUserActive,
hasMultiUserAccess,
changeCurrentSlide,
notifyNotAllowedChange,
} from './service';
import Whiteboard from './component';
import { UsersContext } from '../components-data/users-context/context';
import Auth from '/imports/ui/services/auth';
import PresentationToolbarService from '../presentation/presentation-toolbar/service';
import { layoutSelect } from '../layout/context';
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
const WHITEBOARD_CONFIG = Meteor.settings.public.whiteboard;
const WhiteboardContainer = (props) => {
const usingUsersContext = useContext(UsersContext);
const isRTL = layoutSelect((i) => i.isRTL);
const width = layoutSelect((i) => i?.output?.presentation?.width);
const height = layoutSelect((i) => i?.output?.presentation?.height);
const { users } = usingUsersContext;
const currentUser = users[Auth.meetingID][Auth.userID];
const isPresenter = currentUser.presenter;
const isModerator = currentUser.role === ROLE_MODERATOR;
const maxStickyNoteLength = WHITEBOARD_CONFIG.maxStickyNoteLength;
return <Whiteboard {...{ isPresenter, isModerator, currentUser, isRTL, width, height, maxStickyNoteLength }} {...props} meetingId={Auth.meetingID} />
const usingUsersContext = useContext(UsersContext);
const isRTL = layoutSelect((i) => i.isRTL);
const width = layoutSelect((i) => i?.output?.presentation?.width);
const height = layoutSelect((i) => i?.output?.presentation?.height);
const { users } = usingUsersContext;
const currentUser = users[Auth.meetingID][Auth.userID];
const isPresenter = currentUser.presenter;
const isModerator = currentUser.role === ROLE_MODERATOR;
const { maxStickyNoteLength } = WHITEBOARD_CONFIG;
const fontFamily = WHITEBOARD_CONFIG.styles.text.family;
const { shapes } = props;
const hasShapeAccess = (id) => {
const owner = shapes[id]?.userId;
const isBackgroundShape = id?.includes('slide-background');
const hasAccess = !isBackgroundShape
&& ((owner && owner === currentUser?.userId) || !owner || isPresenter || isModerator);
return hasAccess;
};
// set shapes as locked for those who aren't allowed to edit it
Object.entries(shapes).forEach(([shapeId, shape]) => {
if (!shape.isLocked && !hasShapeAccess(shapeId)) {
shape.isLocked = true;
}
});
return (
<Whiteboard
{... {
isPresenter,
isModerator,
currentUser,
isRTL,
width,
height,
maxStickyNoteLength,
fontFamily,
hasShapeAccess,
}}
{...props}
meetingId={Auth.meetingID}
/>
);
};
export default withTracker(({ whiteboardId, curPageId, intl, zoomChanger, slidePosition, svgUri }) => {
const shapes = Service.getShapes(whiteboardId, curPageId, intl);
const curPres = Service.getCurrentPres();
export default withTracker(({
whiteboardId,
curPageId,
intl,
slidePosition,
svgUri,
}) => {
const shapes = getShapes(whiteboardId, curPageId, intl);
const curPres = getCurrentPres();
shapes["slide-background-shape"] = {
shapes['slide-background-shape'] = {
assetId: `slide-background-asset-${curPageId}`,
childIndex: -1,
id: "slide-background-shape",
name: "Image",
id: 'slide-background-shape',
name: 'Image',
type: TDShapeType.Image,
parentId: `${curPageId}`,
point: [0, 0],
@ -50,27 +99,26 @@ export default withTracker(({ whiteboardId, curPageId, intl, zoomChanger, slideP
},
};
const assets = {}
const assets = {};
assets[`slide-background-asset-${curPageId}`] = {
id: `slide-background-asset-${curPageId}`,
size: [slidePosition?.width || 0, slidePosition?.height || 0],
src: svgUri,
type: "image",
type: 'image',
};
return {
initDefaultPages: Service.initDefaultPages,
persistShape: Service.persistShape,
isMultiUserActive: Service.isMultiUserActive,
hasMultiUserAccess: Service.hasMultiUserAccess,
changeCurrentSlide: Service.changeCurrentSlide,
shapes: shapes,
assets: assets,
initDefaultPages,
persistShape,
isMultiUserActive,
hasMultiUserAccess,
changeCurrentSlide,
shapes,
assets,
curPres,
removeShapes: Service.removeShapes,
removeShapes,
zoomSlide: PresentationToolbarService.zoomSlide,
skipToSlide: PresentationToolbarService.skipToSlide,
zoomChanger: zoomChanger,
notifyNotAllowedChange: Service.notifyNotAllowedChange,
notifyNotAllowedChange,
};
})(WhiteboardContainer);

View File

@ -8,6 +8,7 @@ import allowRedirectToLogoutURL from '/imports/ui/components/meeting-ended/servi
import { initCursorStreamListener } from '/imports/ui/components/cursor/service';
import SubscriptionRegistry from '/imports/ui/services/subscription-registry/subscriptionRegistry';
import { ValidationStates } from '/imports/api/auth-token-validation';
import logger from '/imports/startup/client/logger';
const CONNECTION_TIMEOUT = Meteor.settings.public.app.connectionTimeout;
@ -217,6 +218,9 @@ class Auth {
this.loggedIn = true;
this.uniqueClientSession = `${this.sessionToken}-${Math.random().toString(36).substring(6)}`;
})
.catch((err) => {
logger.error(`Failed to validate token: ${err.description}`);
})
.finally(() => {
this.isAuthenticating = false;
});

View File

@ -11,7 +11,7 @@ class SubscriptionRegistry {
const opt = { ...options };
opt.onStop = () => {
subscriptionReactivity.changed();
if (options.onStop) options.onStop();
if (options?.onStop) options.onStop();
this.registry[subscription] = null;
};
this.registry[subscription] = Meteor.subscribe(subscription, ...params, opt);

View File

@ -456,9 +456,9 @@
"integrity": "sha512-4L7B+woKIWiwOQrCvvpAQeEob3xoq5fVLTyt0YhXVafy2KWBTCcFVo/Y9n0UpDtGso9jN8pMo3SrYa3V6IayVw=="
},
"@fontsource/recursive": {
"version": "4.5.11",
"resolved": "https://registry.npmjs.org/@fontsource/recursive/-/recursive-4.5.11.tgz",
"integrity": "sha512-pAb+Qk1J0BysC6NTaaGDnhoorT4F8ZZKScWRUfqLpXP/wqH/lp9ccUcTxpS/pMawvmjZyNoKvQucvOvZNscUgw=="
"version": "4.5.12",
"resolved": "https://registry.npmjs.org/@fontsource/recursive/-/recursive-4.5.12.tgz",
"integrity": "sha512-cQxQ73p9GLolyiT3DXdFakrm5pSDZuctjVrODjFTTHu16jNgYzfRoMDi+fbCckDz3ur+WN2yXswrMDnZfNpQ6g=="
},
"@fontsource/source-code-pro": {
"version": "4.5.12",
@ -1404,9 +1404,9 @@
"integrity": "sha512-9g9dWI4gsSVe8bNLlb+lMkBYsnIKCZTmvqvDG+Avnn69XfmHZKiaMrx7cgTaddq7aTPPmXiTsbFcUy0xgI4+wA=="
},
"@tldraw/core": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/@tldraw/core/-/core-1.20.3.tgz",
"integrity": "sha512-R/HqtQOg8yedcN70m75ekdm2u6dAHQpg3uxmzd7/TsdUnbfZc5UMp3TakYlTD6JwSSRV0n2huI4AD8D/NJ5jEw==",
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/@tldraw/core/-/core-1.21.0.tgz",
"integrity": "sha512-3hvYLR/XwpMI9GvHIHGfYNmkiaKlokx8vekx0Eq/o/NAkqGkGwnZcTG5shbetWzwQTTi5F4vWInJqA/BfX+OCA==",
"requires": {
"@tldraw/intersect": "^1.8.0",
"@tldraw/vec": "^1.8.0",
@ -1423,9 +1423,9 @@
}
},
"@tldraw/tldraw": {
"version": "1.26.4",
"resolved": "https://registry.npmjs.org/@tldraw/tldraw/-/tldraw-1.26.4.tgz",
"integrity": "sha512-hD1lyM+TvaWkAtDKRXHu3Vt2a7OhJOlyEeaNoe0Nhgxb8BbrcdS8sTPKYL4CWjKmdFjYJ2ZfPlaiF2Dzvwk3JA==",
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/@tldraw/tldraw/-/tldraw-1.27.0.tgz",
"integrity": "sha512-ukN1EzLJxDutoo3ZOPZEfEgKUOt+St5yO8P/HSpJF+/QWDEzVrEttZGXOpny02R30tb96rdjtNQEex8TzUjzIQ==",
"requires": {
"@fontsource/caveat-brush": "^4.5.9",
"@fontsource/crimson-pro": "^4.5.10",
@ -1440,7 +1440,7 @@
"@radix-ui/react-popover": "^1.0.0",
"@radix-ui/react-tooltip": "^1.0.0",
"@stitches/react": "^1.2.8",
"@tldraw/core": "^1.20.3",
"@tldraw/core": "^1.21.0",
"@tldraw/intersect": "^1.8.0",
"@tldraw/vec": "^1.8.0",
"browser-fs-access": "^0.31.0",
@ -2168,9 +2168,9 @@
}
},
"browser-fs-access": {
"version": "0.31.1",
"resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.31.1.tgz",
"integrity": "sha512-jMz9f56DkLM7LyA8wZYO7CtpoF3RdUk1/FXrnRNybgV0R5eqk/fgFWR0k5IMjPYgK4jmZecytP/UDO5WBi9Dhg=="
"version": "0.31.2",
"resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.31.2.tgz",
"integrity": "sha512-wZSA7UgKMwR6oxddFQeSIoD7cxiNiaZT+iuVJw4/avr9t2ROwu80gxENT0YJChsLxJ7xBbLZDGHTAXfAg3Pq5Q=="
},
"browserslist": {
"version": "4.20.2",
@ -7095,9 +7095,9 @@
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="
},
"zustand": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.2.0.tgz",
"integrity": "sha512-eNwaDoD2FYVnMgtNxiMUhTJO780wonZUzJrPQTLYI0erSIMZF8cniWFW22kGQUECd8rdHRJ/ZJL2XO54c9Ttuw==",
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.3.2.tgz",
"integrity": "sha512-rd4haDmlwMTVWVqwvgy00ny8rtti/klRoZjFbL/MAcDnmD5qSw/RZc+Vddstdv90M5Lv6RPgWvm1Hivyn0QgJw==",
"requires": {
"use-sync-external-store": "1.2.0"
}

View File

@ -34,8 +34,7 @@
"@jitsi/sdp-interop": "0.1.14",
"@material-ui/core": "^4.12.4",
"@mconf/bbb-diff": "^1.2.0",
"@tldraw/core": "1.20.3",
"@tldraw/tldraw": "1.26.4",
"@tldraw/tldraw": "^1.27.0",
"autoprefixer": "^10.4.4",
"axios": "^0.21.3",
"babel-runtime": "~6.26.0",
@ -55,8 +54,8 @@
"lodash": "^4.17.21",
"makeup-screenreader-trap": "0.0.5",
"meteor-node-stubs": "^1.2.1",
"postcss-nested": "^5.0.6",
"mobx": "6.4.2",
"postcss-nested": "^5.0.6",
"probe-image-size": "^7.2.3",
"prom-client": "^13.2.0",
"prop-types": "^15.8.1",

View File

@ -33,6 +33,7 @@ public:
copyright: '©2023 BigBlueButton Inc.'
html5ClientBuild: HTML5_CLIENT_VERSION
helpLink: https://bigbluebutton.org/html5/
delayForUnmountOfSharedNote: 120000
bbbTabletApp:
enabled: false
iosAppStoreUrl: 'https://apps.apple.com/us/app/bigbluebutton-tablet/id1641156756'
@ -121,6 +122,9 @@ public:
# can generate excessive overhead to the server. We recommend
# this value to be kept under 16.
breakouts:
allowUserChooseRoomByDefault: false
captureWhiteboardByDefault: false
captureSharedNotesByDefault: false
breakoutRoomLimit: 16
sendInvitationToIncludedModerators: false
# https://github.com/bigbluebutton/bigbluebutton/pull/10826
@ -642,6 +646,11 @@ public:
enabled: true
initialHearingState: true
useRtcLoopbackInChromium: true
# delay: delay (seconds) to be added to the audio feedback return
delay:
enabled: true
delayTime: 0.5
maxDelayTime: 2
# showVolumeMeter: shows an energy bar for microphones in the AudioSettings view
showVolumeMeter: true
# networkPriorities: DSCP markings for each media type. Chromium only, applies
@ -744,6 +753,11 @@ public:
start: DRAW_START
update: DRAW_UPDATE
end: DRAW_END
styles:
text:
# Initial font family.
# family: mono|sans|script|serif
family: script
toolbar:
multiUserPenOnly: false
colors:

View File

@ -13,7 +13,7 @@
"app.chat.partnerDisconnected": "{0} hat die Konferenz verlassen",
"app.chat.closeChatLabel": "Schließen {0}",
"app.chat.hideChatLabel": "Verbergen {0}",
"app.chat.moreMessages": "Weitere Nachrichten",
"app.chat.moreMessages": "Weitere Nachrichten unten",
"app.chat.dropdown.options": "Chatoptionen",
"app.chat.dropdown.clear": "Löschen",
"app.chat.dropdown.copy": "Kopieren",
@ -22,7 +22,7 @@
"app.chat.offline": "Offline",
"app.chat.pollResult": "Umfrageergebnisse",
"app.chat.breakoutDurationUpdated": "Gruppenzeit beträgt jetzt {0} Minuten",
"app.chat.breakoutDurationUpdatedModerator": "Gruppenraumzeit beträgt nun {0} und eine Benachrichtigung wurde versendet.",
"app.chat.breakoutDurationUpdatedModerator": "Gruppenraumzeit beträgt nun {0} Minuten und eine Benachrichtigung wurde versendet.",
"app.chat.emptyLogLabel": "Chatprotokoll ist leer",
"app.chat.clearPublicChatMessage": "Der öffentliche Chatverlauf wurde durch einen Moderator gelöscht",
"app.chat.multi.typing": "Mehrere Teilnehmer tippen",
@ -89,7 +89,7 @@
"app.notes.disabled": "Im Medienbereich angeheftet",
"app.notes.notesDropdown.covertAndUpload": "Notizen in Präsentation umwandeln",
"app.notes.notesDropdown.pinNotes": "Notizen an das Whiteboard heften",
"app.notes.notesDropdown.unpinNotes": "Notizen loslösen",
"app.notes.notesDropdown.unpinNotes": "Notizen lösen",
"app.notes.notesDropdown.notesOptions": "Notizoptionen",
"app.pads.hint": "Esc drücken, um die Symbolleiste des Pads auszuwählen",
"app.user.activityCheck": "Teilnehmeraktivitätsprüfung",
@ -246,6 +246,8 @@
"app.presentationUploader.sent": "Gesendet",
"app.presentationUploader.exportingTimeout": "Der Export dauert zu lange…",
"app.presentationUploader.export": "An Chat senden",
"app.presentationUploader.export.linkAvailable": "Der Link zum Download von {0} steht im allgemeinen Chat zur Verfügung.",
"app.presentationUploader.export.notAccessibleWarning": "ist möglicherweise nicht barrierefrei",
"app.presentationUploader.currentPresentationLabel": "Aktuelle Präsentation",
"app.presentationUploder.extraHint": "WICHTIG: Jede Datei darf {0} MB und {1} Seiten nicht überschreiten.",
"app.presentationUploder.uploadLabel": "Hochladen",
@ -264,6 +266,7 @@
"app.presentationUploder.rejectedError": "Die ausgewählten Dateien wurden zurückgewiesen. Bitte die zulässigen Dateitypen prüfen.",
"app.presentationUploder.connectionClosedError": "Durch schlechte Verbindung unterbrochen. Bitte erneut versuchen.",
"app.presentationUploder.upload.progress": "Hochladen ({0}%)",
"app.presentationUploder.conversion.204": "Kein Inhalt festzustellen",
"app.presentationUploder.upload.413": "Datei ist zu groß, hat die Maximalgröße {0} MB überschritten",
"app.presentationUploder.genericError": "Ups, irgendwas ist schiefgelaufen ...",
"app.presentationUploder.upload.408": "Zeitüberschreitung des Upload-Token anfordern.",
@ -454,6 +457,68 @@
"app.submenu.application.paginationEnabledLabel": "Seitenweises Anzeigen von Webcams",
"app.submenu.application.layoutOptionLabel": "Layout-Modus",
"app.submenu.application.pushLayoutLabel": "Layout verteilen",
"app.submenu.application.localeDropdown.af": "Afrikaans",
"app.submenu.application.localeDropdown.ar": "Arabisch",
"app.submenu.application.localeDropdown.az": "Aserbaidschanisch",
"app.submenu.application.localeDropdown.bg-BG": "Bulgarisch",
"app.submenu.application.localeDropdown.bn": "Bengalisch",
"app.submenu.application.localeDropdown.ca": "Katalanisch",
"app.submenu.application.localeDropdown.cs-CZ": "Tschechisch",
"app.submenu.application.localeDropdown.da": "Dänisch",
"app.submenu.application.localeDropdown.de": "Deutsch",
"app.submenu.application.localeDropdown.dv": "Dhivehi",
"app.submenu.application.localeDropdown.el-GR": "Griechisch (Griechenland)",
"app.submenu.application.localeDropdown.en": "Englisch",
"app.submenu.application.localeDropdown.eo": "Esperanto",
"app.submenu.application.localeDropdown.es": "Spanisch",
"app.submenu.application.localeDropdown.es-419": "Spanisch (Lateinamerika)",
"app.submenu.application.localeDropdown.es-ES": "Spanisch (Spanien)",
"app.submenu.application.localeDropdown.es-MX": "Spanisch (Mexiko)",
"app.submenu.application.localeDropdown.et": "Estnisch",
"app.submenu.application.localeDropdown.eu": "Baskisch",
"app.submenu.application.localeDropdown.fa-IR": "Persisch",
"app.submenu.application.localeDropdown.fi": "Finnisch",
"app.submenu.application.localeDropdown.fr": "Französisch",
"app.submenu.application.localeDropdown.gl": "Galizisch",
"app.submenu.application.localeDropdown.he": "Hebräisch",
"app.submenu.application.localeDropdown.hi-IN": "Hindi",
"app.submenu.application.localeDropdown.hr": "Kroatisch",
"app.submenu.application.localeDropdown.hu-HU": "Ungarisch",
"app.submenu.application.localeDropdown.hy": "Armenisch",
"app.submenu.application.localeDropdown.id": "Indonesisch",
"app.submenu.application.localeDropdown.it-IT": "Italienisch",
"app.submenu.application.localeDropdown.ja": "Japanisch",
"app.submenu.application.localeDropdown.ka": "Georgisch",
"app.submenu.application.localeDropdown.km": "Khmer",
"app.submenu.application.localeDropdown.kn": "Kannada",
"app.submenu.application.localeDropdown.ko-KR": "Koreanisch (Korea)",
"app.submenu.application.localeDropdown.lo-LA": "Laotisch",
"app.submenu.application.localeDropdown.lt-LT": "Litauisch",
"app.submenu.application.localeDropdown.lv": "Lettisch",
"app.submenu.application.localeDropdown.ml": "Malaiisch",
"app.submenu.application.localeDropdown.mn-MN": "Mongolisch",
"app.submenu.application.localeDropdown.nb-NO": "Norwegisch (Bokmal)",
"app.submenu.application.localeDropdown.nl": "Niederländisch",
"app.submenu.application.localeDropdown.oc": "Okzitanisch",
"app.submenu.application.localeDropdown.pl-PL": "Polnisch",
"app.submenu.application.localeDropdown.pt": "Portugiesisch",
"app.submenu.application.localeDropdown.pt-BR": "Portugiesisch (Brasilien)",
"app.submenu.application.localeDropdown.ro-RO": "Rumänisch",
"app.submenu.application.localeDropdown.ru": "Russisch",
"app.submenu.application.localeDropdown.sk-SK": "Slowakisch (Slowakei)",
"app.submenu.application.localeDropdown.sl": "Slowenisch",
"app.submenu.application.localeDropdown.sr": "Serbisch",
"app.submenu.application.localeDropdown.sv-SE": "Schwedisch",
"app.submenu.application.localeDropdown.ta": "Tamil",
"app.submenu.application.localeDropdown.te": "Telugu",
"app.submenu.application.localeDropdown.th": "Thailändisch",
"app.submenu.application.localeDropdown.tr": "Türkisch",
"app.submenu.application.localeDropdown.tr-TR": "Türkisch (Türkei)",
"app.submenu.application.localeDropdown.uk-UA": "Ukrainisch",
"app.submenu.application.localeDropdown.vi": "Vietnamesisch",
"app.submenu.application.localeDropdown.vi-VN": "Vietnamesisch (Vietnam)",
"app.submenu.application.localeDropdown.zh-CN": "Vereinfachtes Chinesisch (China)",
"app.submenu.application.localeDropdown.zh-TW": "Traditionelles Chinesisch (Taiwan)",
"app.submenu.notification.SectionTitle": "Benachrichtigungen",
"app.submenu.notification.Desc": "Bitte definieren, wie und was mitgeteilt werden soll.",
"app.submenu.notification.audioAlertLabel": "Audio-Hinweise",
@ -851,6 +916,7 @@
"app.connection-status.no": "Nein",
"app.connection-status.notification": "Abbruch der Verbindung wurde erkannt",
"app.connection-status.offline": "Offline",
"app.connection-status.clientNotRespondingWarning": "Client antwortet nicht",
"app.connection-status.audioUploadRate": "Audio-Uploadrate",
"app.connection-status.audioDownloadRate": "Audio-Downloadrate",
"app.connection-status.videoUploadRate": "Video-Uploadrate",
@ -1062,7 +1128,7 @@
"app.createBreakoutRoom.addParticipantLabel": "+ Teilnehmer hinzufügen",
"app.createBreakoutRoom.freeJoin": "Den Teilnehmern erlauben, sich selbst einen Gruppenraum auszusuchen.",
"app.createBreakoutRoom.captureNotes": "Übertragen der gemeinsamen Notizen nach Beendigung der Arbeitsgruppenräume",
"app.createBreakoutRoom.captureSlides": "Whiteboard erfassen, wenn Breakout-Räume enden",
"app.createBreakoutRoom.captureSlides": "Whiteboard aufnehmen, wenn Gruppenräume enden",
"app.createBreakoutRoom.leastOneWarnBreakout": "Jedem Gruppenraum muss wenigstens ein Teilnehmer zugeordnet sein.",
"app.createBreakoutRoom.minimumDurationWarnBreakout": "Die Mindestdauer für einen Gruppenraum beträgt {0} Minuten.",
"app.createBreakoutRoom.modalDesc": "Tipp: Sie können die Teilnehmer per Drag-and-Drop einem bestimmten Gruppenraum zuweisen.",

View File

@ -247,7 +247,7 @@
"app.presentationUploader.exportingTimeout": "The export is taking too long...",
"app.presentationUploader.export": "Send to chat",
"app.presentationUploader.export.linkAvailable": "Link for downloading {0} available on the public chat.",
"app.presentationUploader.export.notAccessibleWarning": "may be not accessible",
"app.presentationUploader.export.notAccessibleWarning": "may not be accessibility compliant",
"app.presentationUploader.currentPresentationLabel": "Current presentation",
"app.presentationUploder.extraHint": "IMPORTANT: each file may not exceed {0} MB and {1} pages.",
"app.presentationUploder.uploadLabel": "Upload",
@ -266,6 +266,7 @@
"app.presentationUploder.rejectedError": "The selected file(s) have been rejected. Please check the file type(s).",
"app.presentationUploder.connectionClosedError": "Interrupted by poor connectivity. Please try again.",
"app.presentationUploder.upload.progress": "Uploading ({0}%)",
"app.presentationUploder.conversion.204": "No content to capture",
"app.presentationUploder.upload.413": "File is too large, exceeded the maximum of {0} MB",
"app.presentationUploder.genericError": "Oops, Something went wrong ...",
"app.presentationUploder.upload.408": "Request upload token timeout.",
@ -915,6 +916,7 @@
"app.connection-status.no": "No",
"app.connection-status.notification": "Loss in your connection was detected",
"app.connection-status.offline": "offline",
"app.connection-status.clientNotRespondingWarning": "Client not responding",
"app.connection-status.audioUploadRate": "Audio Upload Rate",
"app.connection-status.audioDownloadRate": "Audio Download Rate",
"app.connection-status.videoUploadRate": "Video Upload Rate",

View File

@ -246,6 +246,8 @@
"app.presentationUploader.sent": "Enviado",
"app.presentationUploader.exportingTimeout": "La exportación está tardando demasiado...",
"app.presentationUploader.export": "Enviar al chat",
"app.presentationUploader.export.linkAvailable": "Enlace para descargar {0} disponible en el chat público.",
"app.presentationUploader.export.notAccessibleWarning": "puede no cumplir las normas de accesibilidad",
"app.presentationUploader.currentPresentationLabel": "Presentación actual",
"app.presentationUploder.extraHint": "IMPORTANTE: cada fichero no puede exceder {0} MB y {1} páginas",
"app.presentationUploder.uploadLabel": "Cargar",
@ -264,6 +266,7 @@
"app.presentationUploder.rejectedError": "El(los) archivo(s) seleccionado(s) ha(n) sido rechazado(s). Por favor, revise el(los) tipo(s) de archivo.",
"app.presentationUploder.connectionClosedError": "Interrumpido debido a deficiente conectividad. Por favor, inténtalo de nuevo.",
"app.presentationUploder.upload.progress": "Cargando ({0}%)",
"app.presentationUploder.conversion.204": "No hay contenido para capturar",
"app.presentationUploder.upload.413": "El archivo es demasiado grande, excede el máximo de {0} MB",
"app.presentationUploder.genericError": "¡Vaya!, algo ha salido mal...",
"app.presentationUploder.upload.408": "La solicitud de carga del token está fuera de tiempo.",
@ -454,6 +457,68 @@
"app.submenu.application.paginationEnabledLabel": "Paginación de vídeo",
"app.submenu.application.layoutOptionLabel": "Tipo de diseño",
"app.submenu.application.pushLayoutLabel": "Forzar diseño",
"app.submenu.application.localeDropdown.af": "Afrikaans",
"app.submenu.application.localeDropdown.ar": "Árabe",
"app.submenu.application.localeDropdown.az": "Azerbaiyano",
"app.submenu.application.localeDropdown.bg-BG": "Búlgaro",
"app.submenu.application.localeDropdown.bn": "Bengalí",
"app.submenu.application.localeDropdown.ca": "Catalán",
"app.submenu.application.localeDropdown.cs-CZ": "Checo",
"app.submenu.application.localeDropdown.da": "Danés",
"app.submenu.application.localeDropdown.de": "Alemán",
"app.submenu.application.localeDropdown.dv": "Dhivehi",
"app.submenu.application.localeDropdown.el-GR": "Griego (Grecia)",
"app.submenu.application.localeDropdown.en": "Inglés",
"app.submenu.application.localeDropdown.eo": "Esperanto",
"app.submenu.application.localeDropdown.es": "Español",
"app.submenu.application.localeDropdown.es-419": "Español (América Latina)",
"app.submenu.application.localeDropdown.es-ES": "Español (España)",
"app.submenu.application.localeDropdown.es-MX": "Español (México)",
"app.submenu.application.localeDropdown.et": "Estonio",
"app.submenu.application.localeDropdown.eu": "Vasco",
"app.submenu.application.localeDropdown.fa-IR": "Persa",
"app.submenu.application.localeDropdown.fi": "Finlandés",
"app.submenu.application.localeDropdown.fr": "Francés",
"app.submenu.application.localeDropdown.gl": "Gallego",
"app.submenu.application.localeDropdown.he": "Hebreo",
"app.submenu.application.localeDropdown.hi-IN": "Hindi",
"app.submenu.application.localeDropdown.hr": "Croata",
"app.submenu.application.localeDropdown.hu-HU": "Húngaro",
"app.submenu.application.localeDropdown.hy": "Armenio",
"app.submenu.application.localeDropdown.id": "Indonesio",
"app.submenu.application.localeDropdown.it-IT": "Italiano",
"app.submenu.application.localeDropdown.ja": "Japonés",
"app.submenu.application.localeDropdown.ka": "Georgiano",
"app.submenu.application.localeDropdown.km": "Jémer",
"app.submenu.application.localeDropdown.kn": "Canarés",
"app.submenu.application.localeDropdown.ko-KR": "Coreano (Corea)",
"app.submenu.application.localeDropdown.lo-LA": "Laosiano",
"app.submenu.application.localeDropdown.lt-LT": "Lituano",
"app.submenu.application.localeDropdown.lv": "Letón",
"app.submenu.application.localeDropdown.ml": "Malabar",
"app.submenu.application.localeDropdown.mn-MN": "Mongol",
"app.submenu.application.localeDropdown.nb-NO": "Noruego (bokmal)",
"app.submenu.application.localeDropdown.nl": "Holandés",
"app.submenu.application.localeDropdown.oc": "Occitano",
"app.submenu.application.localeDropdown.pl-PL": "Polaco",
"app.submenu.application.localeDropdown.pt": "Portugués",
"app.submenu.application.localeDropdown.pt-BR": "Portugués (Brasil)",
"app.submenu.application.localeDropdown.ro-RO": "Rumano",
"app.submenu.application.localeDropdown.ru": "Ruso",
"app.submenu.application.localeDropdown.sk-SK": "Eslovaco (Eslovaquia)",
"app.submenu.application.localeDropdown.sl": "Esloveno",
"app.submenu.application.localeDropdown.sr": "Serbio",
"app.submenu.application.localeDropdown.sv-SE": "Sueco",
"app.submenu.application.localeDropdown.ta": "Tamil",
"app.submenu.application.localeDropdown.te": "Télugu",
"app.submenu.application.localeDropdown.th": "Tailandés",
"app.submenu.application.localeDropdown.tr": "Turco",
"app.submenu.application.localeDropdown.tr-TR": "Turco (Turquía)",
"app.submenu.application.localeDropdown.uk-UA": "Ucraniano",
"app.submenu.application.localeDropdown.vi": "Vietnamita",
"app.submenu.application.localeDropdown.vi-VN": "Vietnamita (Vietnam)",
"app.submenu.application.localeDropdown.zh-CN": "Chino simplificado (China)",
"app.submenu.application.localeDropdown.zh-TW": "Chino tradicional (Taiwán)",
"app.submenu.notification.SectionTitle": "Notificaciones",
"app.submenu.notification.Desc": "Defina cómo y de qué será notificado.",
"app.submenu.notification.audioAlertLabel": "Alertas audibles",
@ -851,6 +916,7 @@
"app.connection-status.no": "No",
"app.connection-status.notification": "Se ha detectado la pérdida de su conexión",
"app.connection-status.offline": "Desconectado",
"app.connection-status.clientNotRespondingWarning": "El cliente no responde",
"app.connection-status.audioUploadRate": "Tasa de subida del audio",
"app.connection-status.audioDownloadRate": "Tasa de bajada del audio",
"app.connection-status.videoUploadRate": "Tasa de subida del vídeo",

View File

@ -246,6 +246,8 @@
"app.presentationUploader.sent": "Enviado",
"app.presentationUploader.exportingTimeout": "La exportación está tardando demasiado...",
"app.presentationUploader.export": "Enviar al chat",
"app.presentationUploader.export.linkAvailable": "Enlace para descargar {0} disponible en el chat público.",
"app.presentationUploader.export.notAccessibleWarning": "puede no cumplir las normas de accesibilidad",
"app.presentationUploader.currentPresentationLabel": "Presentación actual",
"app.presentationUploder.extraHint": "IMPORTANTE: cada fichero no puede exceder {0} MB y {1} páginas",
"app.presentationUploder.uploadLabel": "Cargar",
@ -264,6 +266,7 @@
"app.presentationUploder.rejectedError": "El(los) archivo(s) seleccionado(s) ha(n) sido rechazado(s). Por favor, revise el(los) tipo(s) de archivo(s).",
"app.presentationUploder.connectionClosedError": "Interrumpido debido a deficiente conectividad. Por favor, inténtalo de nuevo.",
"app.presentationUploder.upload.progress": "Cargando ({0}%)",
"app.presentationUploder.conversion.204": "No hay contenido para capturar",
"app.presentationUploder.upload.413": "El archivo es demasiado grande, excede el máximo de {0} MB",
"app.presentationUploder.genericError": "Oops, algo salió mal...",
"app.presentationUploder.upload.408": "El tiempo para solicitar el token de subida ha expirado.",
@ -454,6 +457,68 @@
"app.submenu.application.paginationEnabledLabel": "Paginación de vídeo",
"app.submenu.application.layoutOptionLabel": "Tipo de diseño",
"app.submenu.application.pushLayoutLabel": "Forzar diseño",
"app.submenu.application.localeDropdown.af": "Afrikaans",
"app.submenu.application.localeDropdown.ar": "Árabe",
"app.submenu.application.localeDropdown.az": "Azerbaiyano",
"app.submenu.application.localeDropdown.bg-BG": "Búlgaro",
"app.submenu.application.localeDropdown.bn": "Bengalí",
"app.submenu.application.localeDropdown.ca": "Catalán",
"app.submenu.application.localeDropdown.cs-CZ": "Checo",
"app.submenu.application.localeDropdown.da": "Danés",
"app.submenu.application.localeDropdown.de": "Alemán",
"app.submenu.application.localeDropdown.dv": "Dhivehi",
"app.submenu.application.localeDropdown.el-GR": "Griego (Grecia)",
"app.submenu.application.localeDropdown.en": "Inglés",
"app.submenu.application.localeDropdown.eo": "Esperanto",
"app.submenu.application.localeDropdown.es": "Español",
"app.submenu.application.localeDropdown.es-419": "Español (América Latina)",
"app.submenu.application.localeDropdown.es-ES": "Español (España)",
"app.submenu.application.localeDropdown.es-MX": "Español (México)",
"app.submenu.application.localeDropdown.et": "Estonio",
"app.submenu.application.localeDropdown.eu": "Vasco",
"app.submenu.application.localeDropdown.fa-IR": "Persa",
"app.submenu.application.localeDropdown.fi": "Finlandés",
"app.submenu.application.localeDropdown.fr": "Francés",
"app.submenu.application.localeDropdown.gl": "Gallego",
"app.submenu.application.localeDropdown.he": "Hebreo",
"app.submenu.application.localeDropdown.hi-IN": "Hindi",
"app.submenu.application.localeDropdown.hr": "Croata",
"app.submenu.application.localeDropdown.hu-HU": "Húngaro",
"app.submenu.application.localeDropdown.hy": "Armenio",
"app.submenu.application.localeDropdown.id": "Indonesio",
"app.submenu.application.localeDropdown.it-IT": "Italiano",
"app.submenu.application.localeDropdown.ja": "Japonés",
"app.submenu.application.localeDropdown.ka": "Georgiano",
"app.submenu.application.localeDropdown.km": "Jémer",
"app.submenu.application.localeDropdown.kn": "Canarés",
"app.submenu.application.localeDropdown.ko-KR": "Coreano (Corea)",
"app.submenu.application.localeDropdown.lo-LA": "Laosiano",
"app.submenu.application.localeDropdown.lt-LT": "Lituano",
"app.submenu.application.localeDropdown.lv": "Letón",
"app.submenu.application.localeDropdown.ml": "Malabar",
"app.submenu.application.localeDropdown.mn-MN": "Mongol",
"app.submenu.application.localeDropdown.nb-NO": "Noruego (bokmal)",
"app.submenu.application.localeDropdown.nl": "Holandés",
"app.submenu.application.localeDropdown.oc": "Occitano",
"app.submenu.application.localeDropdown.pl-PL": "Polaco",
"app.submenu.application.localeDropdown.pt": "Portugués",
"app.submenu.application.localeDropdown.pt-BR": "Portugués (Brasil)",
"app.submenu.application.localeDropdown.ro-RO": "Rumano",
"app.submenu.application.localeDropdown.ru": "Ruso",
"app.submenu.application.localeDropdown.sk-SK": "Eslovaco (Eslovaquia)",
"app.submenu.application.localeDropdown.sl": "Esloveno",
"app.submenu.application.localeDropdown.sr": "Serbio",
"app.submenu.application.localeDropdown.sv-SE": "Sueco",
"app.submenu.application.localeDropdown.ta": "Tamil",
"app.submenu.application.localeDropdown.te": "Télugu",
"app.submenu.application.localeDropdown.th": "Tailandés",
"app.submenu.application.localeDropdown.tr": "Turco",
"app.submenu.application.localeDropdown.tr-TR": "Turco (Turquía)",
"app.submenu.application.localeDropdown.uk-UA": "Ucraniano",
"app.submenu.application.localeDropdown.vi": "Vietnamita",
"app.submenu.application.localeDropdown.vi-VN": "Vietnamita (Vietnam)",
"app.submenu.application.localeDropdown.zh-CN": "Chino simplificado (China)",
"app.submenu.application.localeDropdown.zh-TW": "Chino tradicional (Taiwán)",
"app.submenu.notification.SectionTitle": "Notificaciones",
"app.submenu.notification.Desc": "Define cómo y qué quieres que te sea notificado.",
"app.submenu.notification.audioAlertLabel": "Alertas sonoras",
@ -851,6 +916,7 @@
"app.connection-status.no": "No",
"app.connection-status.notification": "Se ha detectado la pérdida de su conexión",
"app.connection-status.offline": "Desconectado",
"app.connection-status.clientNotRespondingWarning": "El cliente no responde",
"app.connection-status.audioUploadRate": "Tasa de subida del audio",
"app.connection-status.audioDownloadRate": "Tasa de bajada del audio",
"app.connection-status.videoUploadRate": "Tasa de subida del vídeo",

View File

@ -2,10 +2,11 @@
"app.home.greeting": "ارائه شما به زودی آغاز خواهد شد...",
"app.chat.submitLabel": "ارسال پیام",
"app.chat.loading": "پیام‌های گفتگوی بارگیری‌شده: {0}%",
"app.chat.errorMaxMessageLength": "پیام به میزان {0} کاراکتر بلند است",
"app.chat.disconnected": "ارتباط شما قطع شده است، امکان ارسال پیامها وجود ندارد",
"app.chat.errorMaxMessageLength": "پیام خیلی طولانی است، از حداکثر {0} کاراکتر بیشتر است",
"app.chat.disconnected": "ارتباط شما قطع شده است، امکان ارسال پیام وجود ندارد",
"app.chat.locked": "گفنگو قفل شده است، امکان ارسال هیچ پیامی وجود ندارد",
"app.chat.inputLabel": "ورودی پیام برای گفتگو {0}",
"app.chat.emojiButtonLabel": "انتخاب کننده شکلک",
"app.chat.inputPlaceholder": "پیام {0}",
"app.chat.titlePublic": "گفتگوی عمومی",
"app.chat.titlePrivate": "گفتگوی خصوصی با {0}",
@ -21,6 +22,7 @@
"app.chat.offline": "آفلاین",
"app.chat.pollResult": "نتایج نظرسنجی",
"app.chat.breakoutDurationUpdated": "زمان جلسه زیرمجموعه اکنون {0} دقیقه است",
"app.chat.breakoutDurationUpdatedModerator": "زمان اتاق‌های جانبی اکنون {0} دقیقه است و آگاه‌سازی ارسال شده است.",
"app.chat.emptyLogLabel": "سابقه گفتگو خالی است",
"app.chat.clearPublicChatMessage": "سابقه گفتگوی عمومی توسط مدیر حذف گردید",
"app.chat.multi.typing": "چند کاربر در حال نوشتن هستند",
@ -28,6 +30,27 @@
"app.chat.two.typing": "{0} و {1} در حال نوشتن هستند",
"app.chat.copySuccess": "رونوشت گفتگو کپی شد",
"app.chat.copyErr": "کپی رونوشت گفتگو با خطا مواجه شد!",
"app.emojiPicker.search": "جستجو",
"app.emojiPicker.notFound": "هیچ شکلکی پیدا نشد",
"app.emojiPicker.skintext": "رنگ پوست پیش‌فرض خود را انتخاب کنید",
"app.emojiPicker.clear": "پاکسازی",
"app.emojiPicker.categories.label": "دسته‌بندی شکلک‌ها",
"app.emojiPicker.categories.people": "مردم و بدن",
"app.emojiPicker.categories.nature": "حیوانات و طبیعت",
"app.emojiPicker.categories.foods": "غذا و نوشیدنی",
"app.emojiPicker.categories.places": "سفر و مکان‌ها",
"app.emojiPicker.categories.activity": "فعالیت",
"app.emojiPicker.categories.objects": "اشیاء",
"app.emojiPicker.categories.symbols": "نمادها",
"app.emojiPicker.categories.flags": "پرچم‌ها",
"app.emojiPicker.categories.recent": "اغلب استفاده می‌شود",
"app.emojiPicker.categories.search": "نتایج جستجو",
"app.emojiPicker.skintones.1": "رنگ پوست پیش‌فرض",
"app.emojiPicker.skintones.2": "رنگ پوست روشن",
"app.emojiPicker.skintones.3": "رنگ پوست نیمه‌روشن",
"app.emojiPicker.skintones.4": "رنگ پوست متوسط",
"app.emojiPicker.skintones.5": "رنگ پوست نیمه‌تیره",
"app.emojiPicker.skintones.6": "رنگ پوست تیره",
"app.captions.label": "زیرنویس‌ها",
"app.captions.menu.close": "بستن",
"app.captions.menu.start": "شروع",
@ -53,12 +76,21 @@
"app.captions.speech.start": "امکان تشخیص گفتار آغاز شد",
"app.captions.speech.stop": "امکان تشخیص گفتار متوقف شد",
"app.captions.speech.error": "به دلیل ناسازگاری مرورگر یا مدتی سکوت، تشخیص گفتار متوقف شد",
"app.confirmation.skipConfirm": "دوباره نپرس",
"app.confirmation.virtualBackground.title": "شروع پس‌زمینه مجازی جدید",
"app.confirmation.virtualBackground.description": "{0} به عنوان پس‌زمینه مجازی اضافه خواهد شد. ادامه؟",
"app.confirmationModal.yesLabel": "بله",
"app.textInput.sendLabel": "ارسال",
"app.title.defaultViewLabel": "نمای پیش‌فرض ارائه",
"app.notes.title": "یادداشت‌های اشتراکی",
"app.notes.label": "یادداشت‌ها",
"app.notes.hide": "پنهان‌کردن یادداشت‌ها",
"app.notes.locked": "قفل شده",
"app.notes.disabled": "در ناحیه رسانه سنجاق شده است",
"app.notes.notesDropdown.covertAndUpload": "تبدیل یادداشت‌ها به ارائه",
"app.notes.notesDropdown.pinNotes": "یادداشت‌ها را روی تخته سفید سنجاق کنید",
"app.notes.notesDropdown.unpinNotes": "سنجاق یادداشت‌ها را بردارید",
"app.notes.notesDropdown.notesOptions": "گزینه‌های یادداشت‌ها",
"app.pads.hint": "برای تمرکز روی نوارابزار برگه یادداشت، Esc را فشار دهید",
"app.user.activityCheck": "بررسی فعالیت کاربر",
"app.user.activityCheck.label": "بررسی کنید آیا کاربر هنوز در جلسه ({0}) حضور دارد",
@ -170,7 +202,7 @@
"app.presentation.endSlideContent": "انتهای محتوای اسلاید",
"app.presentation.changedSlideContent": "ارائه به اسلاید {0} تغییر کرد",
"app.presentation.emptySlideContent": "محتوایی برای اسلاید کنونی وجود ندارد",
"app.presentation.options.fullscreen": "تمام‌صفحه",
"app.presentation.options.fullscreen": "ارائه تمام صفحه",
"app.presentation.options.exitFullscreen": "خروج از تمام‌صفحه",
"app.presentation.options.minimize": "کوچک‌کردن",
"app.presentation.options.snapshot": "تصویر لحظه‌ای اسلاید کنونی",
@ -203,7 +235,20 @@
"app.presentation.presentationToolbar.goToSlide": "اسلاید {0}",
"app.presentation.placeholder": "در حال حاضر هیچ ارائه فعالی وجود ندارد",
"app.presentationUploder.title": "ارائه",
"app.presentationUploder.message": "به عنوان یک ارائه دهنده شما توانایی بارگذاری انواع فایل‌های مجموعه آفیس یا فایل PDF را دارید. ما فایل PDF را برای بهترین نتیجه توصیه می‌کنیم. لطفا مطمئن شوید که یک ارائه با استفاده از کادر دایره‌ای انتخاب شده است.",
"app.presentationUploder.message": "به عنوان یک ارائه دهنده شما توانایی بارگذاری انواع پرونده‌های مجموعه آفیس یا پرونده PDF را دارید. ما پرونده PDF را برای بهترین نتیجه توصیه می‌کنیم. لطفا مطمئن شوید که یک ارائه با استفاده از کادر دایره‌ای در سمت چپ انتخاب شده است.",
"app.presentationUploader.exportHint": "با انتخاب «ارسال به گفتگو» یک پیوند قابل بارگیری به همراه حاشیه‌نویسی‌ها در گفتگوی عمومی در اختیار کاربران قرار می‌گیرد.",
"app.presentationUploader.exportToastHeader": "در حال ارسال به گفتگو «{0} مورد»",
"app.presentationUploader.exportToastHeaderPlural": "در حال ارسال به گفتگو «{0} مورد»",
"app.presentationUploader.exporting": "در حال ارسال به گفتگو",
"app.presentationUploader.sending": "در حال ارسال",
"app.presentationUploader.collecting": "در حال استخراج اسلاید {0} از {1}...",
"app.presentationUploader.processing": "حاشیه نویسی اسلاید {0} از {1}...",
"app.presentationUploader.sent": "ارسال شد",
"app.presentationUploader.exportingTimeout": "خروجی‌گرفتن خیلی طول می‌کشد...",
"app.presentationUploader.export": "ارسال به گفتگو",
"app.presentationUploader.export.linkAvailable": "پیوند برای بارگیری‌کردن {0} در گفتگوی عمومی موجود است.",
"app.presentationUploader.export.notAccessibleWarning": "ممکن است با دسترسی‌پذیری سازگار نباشد",
"app.presentationUploader.currentPresentationLabel": "ارائه کنونی",
"app.presentationUploder.extraHint": "*مهم*: هر فایل نباید از {0} مگابایت و {1} صفحه تجاوز کند. ",
"app.presentationUploder.uploadLabel": "بارگذاری",
"app.presentationUploder.confirmLabel": "تایید",
@ -214,11 +259,14 @@
"app.presentationUploder.dropzoneImagesLabel": "تصاویر خود را برای بارگذاری کشیده و در اینجا رها کنید",
"app.presentationUploder.browseFilesLabel": "یا برای جستجو در بین فایل‌ها کلیک کنید",
"app.presentationUploder.browseImagesLabel": "یا تصاویر را مرور کنید/بگیرید",
"app.presentationUploder.externalUploadTitle": "افزودن محتوا از برنامه شخص ثالث",
"app.presentationUploder.externalUploadLabel": "مرور پرونده‌ها",
"app.presentationUploder.fileToUpload": "آماده بارگذاری ...",
"app.presentationUploder.currentBadge": "کنونی",
"app.presentationUploder.rejectedError": "پرونده(های) انتخاب‌شده رد شدند. لطفا نوع پرونده(ها) را بررسی کنید.",
"app.presentationUploder.connectionClosedError": "به دلیل اتصال ضعیف قطع شد. لطفا دوباره تلاش کنید. ",
"app.presentationUploder.upload.progress": "در حال بارگذاری ({0}%)",
"app.presentationUploder.conversion.204": "محتوایی برای ضبط وجود ندارد",
"app.presentationUploder.upload.413": "حجم پرونده زیاد است، از حداکثر {0} مگابایت بیشتر است",
"app.presentationUploder.genericError": "اوه، مشکلی پیش آمد...",
"app.presentationUploder.upload.408": "زمان درخواست توکن بارگذاری به پایان رسید.",
@ -230,14 +278,14 @@
"app.presentationUploder.conversion.generatedSlides": "اسلایدها تولید شدند ...",
"app.presentationUploder.conversion.generatingSvg": "در حال تولید تصاویر SVG ...",
"app.presentationUploder.conversion.pageCountExceeded": "تعداد صفحات از حداکثر {0} بیشتر است",
"app.presentationUploder.conversion.invalidMimeType": "قالب نامعتبر شناسایی شد (پسوند={0}، نوع محتوا={1})",
"app.presentationUploder.conversion.conversionTimeout": "اسلاید {0} طی {1} تلاش قابل پردازش نیست.",
"app.presentationUploder.conversion.officeDocConversionInvalid": "خطا در پردازش اسناد آفیس، لطفا به جای آن، یک پرونده PDF بارگذاری کنید.",
"app.presentationUploder.conversion.officeDocConversionFailed": "خطا در پردازش اسناد آفیس، لطفا به جای آن، یک پرونده PDF بارگذاری کنید.",
"app.presentationUploder.conversion.pdfHasBigPage": "ما نتوانستیم فایل PDF را تبدیل کنیم، لطفا آن را بهینه کنید. حداکثر اندازه صفحه {0}",
"app.presentationUploder.conversion.timeout": "اوه، عملیات تبدیل خیلی طول کشید",
"app.presentationUploder.conversion.pageCountFailed": "تعیین تعداد صفحات با خطا مواجه شد.",
"app.presentationUploder.conversion.unsupportedDocument": "پسوند پرونده پشتیبانی نمی‌شود",
"app.presentationUploder.isDownloadableLabel": "بارگیری ارائه مجاز نیست - برای اجازه دادن به بارگیری ارائه کلیک کنید",
"app.presentationUploder.isNotDownloadableLabel": "بارگیری ارائه مجاز است - برای جلوگیری از بارگیری ارائه، کلیک کنید",
"app.presentationUploder.removePresentationLabel": "حذف ارائه",
"app.presentationUploder.setAsCurrentPresentation": "انتخاب ارائه به عنوان ارائه کنونی",
"app.presentationUploder.tableHeading.filename": "نام پرونده",
@ -251,6 +299,10 @@
"app.presentationUploder.clearErrors": "پاک‌کردن خطاها",
"app.presentationUploder.clearErrorsDesc": "پاک‌کردن بارگذاری‌های ناموفق ارائه",
"app.presentationUploder.uploadViewTitle": "بارگذاری ارائه ",
"app.poll.questionAndoptions.label" : "متن سوال نمایش داده شود.\nالف. گزینه نظرسنجی *\nب. گزینه نظرسنجی (اختیاری)\nپ. گزینه نظرسنجی (اختیاری)\nت. گزینه نظرسنجی (اختیاری)\nث. گزینه نظرسنجی (اختیاری)",
"app.poll.customInput.label": "ورودی سفارشی",
"app.poll.customInputInstructions.label": "ورودی سفارشی فعال است - سوال نظرسنجی و گزینه(های) را در قالب مشخص بنویسید یا یک پرونده متنی را با همان قالب بکشید و رها کنید.",
"app.poll.maxOptionsWarning.label": "فقط ۵ گزینه اول می‌تواند مورد استفاده قرار گیرد!",
"app.poll.pollPaneTitle": "نظرسنجی",
"app.poll.enableMultipleResponseLabel": "اجازه برای پاسخ‌های متعدد به ازای هر پاسخ‌دهنده؟ ",
"app.poll.quickPollTitle": "نظرسنجی سریع",
@ -270,7 +322,7 @@
"app.poll.clickHereToSelect": "برای انتخاب اینجا را کلیک کنید",
"app.poll.question.label" : "سوالتان را بنویسید ...",
"app.poll.optionalQuestion.label" : "سوالتان را بنویسید (اختیاری) ...",
"app.poll.userResponse.label" : "پاسخ کاربر",
"app.poll.userResponse.label" : "پاسخ تایپ شده",
"app.poll.responseTypes.label" : "انواع پاسخ",
"app.poll.optionDelete.label" : "حذف",
"app.poll.responseChoices.label" : "انتخاب‌های پاسخ",
@ -329,11 +381,11 @@
"app.muteWarning.disableMessage": "هشدارهای بی‌صدابودن تا زمان فعال‌سازی صدا غیرفعال شدند",
"app.muteWarning.tooltip": "برای بستن و غیرفعال‌کردن هشدار تا فعال‌سازی مجدد صدا، کلیک کنید",
"app.navBar.settingsDropdown.optionsLabel": "گزینه‌ها",
"app.navBar.settingsDropdown.fullscreenLabel": "تغییر به تمام‌صفحه",
"app.navBar.settingsDropdown.fullscreenLabel": "برنامه تمام صفحه",
"app.navBar.settingsDropdown.settingsLabel": "تنظیمات",
"app.navBar.settingsDropdown.aboutLabel": "درباره",
"app.navBar.settingsDropdown.leaveSessionLabel": "ترک جلسه",
"app.navBar.settingsDropdown.exitFullscreenLabel": "خروج از تمامصفحه",
"app.navBar.settingsDropdown.exitFullscreenLabel": "خروج از تمام صفحه",
"app.navBar.settingsDropdown.fullscreenDesc": "تمام‌صفحه‌کردن منوی تنظیمات",
"app.navBar.settingsDropdown.settingsDesc": "تغییر تنظیمات عمومی",
"app.navBar.settingsDropdown.aboutDesc": "نمایش اطلاعات درباره کارخواه",
@ -342,12 +394,13 @@
"app.navBar.settingsDropdown.hotkeysLabel": "میانبرهای صفحه کلید",
"app.navBar.settingsDropdown.hotkeysDesc": "فهرست‌کردن میانبرهای موجود صفحه کلید",
"app.navBar.settingsDropdown.helpLabel": "راهنما",
"app.navBar.settingsDropdown.openAppLabel": "در برنامه تبلت BigBlueButton باز کنید",
"app.navBar.settingsDropdown.helpDesc": "کاربر را به آموزش‌های ویدیویی هدایت می‌کند (یک زبانه جدید باز می‌کند)",
"app.navBar.settingsDropdown.endMeetingDesc": "جلسه کنونی را خاتمه می‌دهد",
"app.navBar.settingsDropdown.endMeetingLabel": "اتمام جلسه",
"app.navBar.userListToggleBtnLabel": "تغییر وضعیت نمایش فهرست کاربران",
"app.navBar.toggleUserList.ariaLabel": "تغییر وضعیت نمایش کاربران و پیام ها",
"app.navBar.toggleUserList.newMessages": "اعلان با پیام جدید",
"app.navBar.toggleUserList.newMessages": "با آگاه‌سازی پیام جدید",
"app.navBar.toggleUserList.newMsgAria": "پیام جدید از {0}",
"app.navBar.recording": "جلسه در حال ضبط شدن است",
"app.navBar.recording.on": "در حال ضبط",
@ -359,7 +412,7 @@
"app.endMeeting.description": "این عمل جلسه را برای {0} کاربر فعال به پایان می‌رساند. آیا مطمئن هستید که می‌خواهید این جلسه را به پایان برسانید؟",
"app.endMeeting.noUserDescription": "آیا مطمئن هستید که می‌خواهید این جلسه را به پایان برسانید؟",
"app.endMeeting.contentWarning": "پیام‌های گفتگو، یادداشت‌های اشتراکی، محتوای تخته‌سفید و اسناد به اشتراک گذاشته‌شده برای این جلسه دیگر مستقیماً قابل دسترس نخواهند بود",
"app.endMeeting.yesLabel": "بله",
"app.endMeeting.yesLabel": "پایان جلسه برای همه کاربران",
"app.endMeeting.noLabel": "خیر",
"app.about.title": "درباره",
"app.about.version": "نسخه کارخواه:",
@ -369,6 +422,15 @@
"app.about.confirmDesc": "تأیید",
"app.about.dismissLabel": "لغو",
"app.about.dismissDesc": "بستن اطلاعات درباره کارخواه",
"app.mobileAppModal.title": "باز کردن برنامه تبلت BigBlueButton",
"app.mobileAppModal.description": "آیا برنامه تبلت BigBlueButton را روی دستگاه خود نصب کرده‌اید؟",
"app.mobileAppModal.openApp": "بله، اکنون برنامه را باز کنید",
"app.mobileAppModal.obtainUrlMsg": "دریافت نشانی اینترنتی جلسه",
"app.mobileAppModal.obtainUrlErrorMsg": "خطا در تلاش برای دریافت نشانی اینترنتی جلسه",
"app.mobileAppModal.openStore": "خیر، فروشگاه برنامه را برای دانلود باز کن",
"app.mobileAppModal.dismissLabel": "لغو",
"app.mobileAppModal.dismissDesc": "بستن",
"app.mobileAppModal.userConnectedWithSameId": "کاربر {0} با استفاده از همان شناسه شما متصل شد.",
"app.actionsBar.changeStatusLabel": "تغییر وضعیت",
"app.actionsBar.muteLabel": "حالت بی‌صدا",
"app.actionsBar.unmuteLabel": "فعال‌سازی صدا",
@ -379,10 +441,12 @@
"app.actionsBar.actionsDropdown.restorePresentationDesc": "دکمه بازیابی ارائه بعد از کوچک‌کردن آن",
"app.actionsBar.actionsDropdown.minimizePresentationLabel": "کوچک‌کردن ارائه",
"app.actionsBar.actionsDropdown.minimizePresentationDesc": "دکمه‌ای که برای کوچک‌کردن ارائه استفاده می‌شود",
"app.actionsBar.actionsDropdown.layoutModal": "تنظیمات چیدمان پنجره",
"app.screenshare.screenShareLabel" : "اشتراک صفحه",
"app.submenu.application.applicationSectionTitle": "برنامه",
"app.submenu.application.animationsLabel": "انیمیشن ها",
"app.submenu.application.audioFilterLabel": "فیلترهای صوتی برای میکروفون",
"app.submenu.application.darkThemeLabel": "حالت تیره",
"app.submenu.application.fontSizeControlLabel": "اندازه قلم",
"app.submenu.application.increaseFontBtnLabel": "افزایش اندازه متن برنامه",
"app.submenu.application.decreaseFontBtnLabel": "کاهش اندازه متن برنامه",
@ -392,8 +456,71 @@
"app.submenu.application.noLocaleOptionLabel": "هیچ زبان فعالی وجود ندارد",
"app.submenu.application.paginationEnabledLabel": "صفحه‌بندی ویدیو",
"app.submenu.application.layoutOptionLabel": "نوع چیدمان",
"app.submenu.notification.SectionTitle": "اعلان‌ها",
"app.submenu.notification.Desc": "تعریف کنید که چگونه و چه چیزی به شما اطلاع داده شود.",
"app.submenu.application.pushLayoutLabel": "تحمیل چیدمان",
"app.submenu.application.localeDropdown.af": "آفریقایی",
"app.submenu.application.localeDropdown.ar": "عربی",
"app.submenu.application.localeDropdown.az": "آذربایجانی",
"app.submenu.application.localeDropdown.bg-BG": "بلغاری",
"app.submenu.application.localeDropdown.bn": "بنگالی",
"app.submenu.application.localeDropdown.ca": "کاتالان",
"app.submenu.application.localeDropdown.cs-CZ": "چکی",
"app.submenu.application.localeDropdown.da": "دانمارکی",
"app.submenu.application.localeDropdown.de": "آلمانی",
"app.submenu.application.localeDropdown.dv": "دیوهی",
"app.submenu.application.localeDropdown.el-GR": "یونانی (یونان)",
"app.submenu.application.localeDropdown.en": "انگلیسی",
"app.submenu.application.localeDropdown.eo": "اسپرانتو",
"app.submenu.application.localeDropdown.es": "اسپانیایی",
"app.submenu.application.localeDropdown.es-419": "اسپانیایی (آمریکای لاتین)",
"app.submenu.application.localeDropdown.es-ES": "اسپانیایی (اسپانیا)",
"app.submenu.application.localeDropdown.es-MX": "اسپانیایی (مکزیک)",
"app.submenu.application.localeDropdown.et": "استونیایی",
"app.submenu.application.localeDropdown.eu": "باسکی",
"app.submenu.application.localeDropdown.fa-IR": "فارسی",
"app.submenu.application.localeDropdown.fi": "فنلاندی",
"app.submenu.application.localeDropdown.fr": "فرانسوی",
"app.submenu.application.localeDropdown.gl": "گالیسی",
"app.submenu.application.localeDropdown.he": "عبری",
"app.submenu.application.localeDropdown.hi-IN": "هندی",
"app.submenu.application.localeDropdown.hr": "کرواتی",
"app.submenu.application.localeDropdown.hu-HU": "مجارستانی",
"app.submenu.application.localeDropdown.hy": "ارمنی",
"app.submenu.application.localeDropdown.id": "اندونزیایی",
"app.submenu.application.localeDropdown.it-IT": "ایتالیایی",
"app.submenu.application.localeDropdown.ja": "ژاپنی",
"app.submenu.application.localeDropdown.ka": "گرجی",
"app.submenu.application.localeDropdown.km": "خمر",
"app.submenu.application.localeDropdown.kn": "کنادا",
"app.submenu.application.localeDropdown.ko-KR": "کره‌ای (کره)",
"app.submenu.application.localeDropdown.lo-LA": "لائو",
"app.submenu.application.localeDropdown.lt-LT": "لیتوانیایی",
"app.submenu.application.localeDropdown.lv": "لتونیایی",
"app.submenu.application.localeDropdown.ml": "مالایالم",
"app.submenu.application.localeDropdown.mn-MN": "مغولی",
"app.submenu.application.localeDropdown.nb-NO": "نروژی (بوکمال)",
"app.submenu.application.localeDropdown.nl": "هلندی",
"app.submenu.application.localeDropdown.oc": "اکسیتان",
"app.submenu.application.localeDropdown.pl-PL": "لهستانی",
"app.submenu.application.localeDropdown.pt": "پرتغالی",
"app.submenu.application.localeDropdown.pt-BR": "پرتغالی (برزیل)",
"app.submenu.application.localeDropdown.ro-RO": "رومانیایی",
"app.submenu.application.localeDropdown.ru": "روسی",
"app.submenu.application.localeDropdown.sk-SK": "اسلواکی (اسلواکی)",
"app.submenu.application.localeDropdown.sl": "اسلوونیایی",
"app.submenu.application.localeDropdown.sr": "صربی",
"app.submenu.application.localeDropdown.sv-SE": "سوئدی",
"app.submenu.application.localeDropdown.ta": "تامیلی",
"app.submenu.application.localeDropdown.te": "تلوگو",
"app.submenu.application.localeDropdown.th": "تایلندی",
"app.submenu.application.localeDropdown.tr": "ترکی",
"app.submenu.application.localeDropdown.tr-TR": "ترکی (ترکیه)",
"app.submenu.application.localeDropdown.uk-UA": "اوکراینی",
"app.submenu.application.localeDropdown.vi": "ویتنامی",
"app.submenu.application.localeDropdown.vi-VN": "ویتنامی (ویتنام)",
"app.submenu.application.localeDropdown.zh-CN": "چینی ساده شده (چین)",
"app.submenu.application.localeDropdown.zh-TW": "چینی سنتی (تایوان)",
"app.submenu.notification.SectionTitle": "آگاه‌سازی‌ها",
"app.submenu.notification.Desc": "تعریف کنید که چگونه و چه چیزی به شما آگاه‌سازی داده شود.",
"app.submenu.notification.audioAlertLabel": "هشدارهای صوتی",
"app.submenu.notification.pushAlertLabel": "هشدارهای پدیدارشونده (popup)",
"app.submenu.notification.messagesLabel": "پیام گفتگو",
@ -420,7 +547,7 @@
"app.settings.main.save.label.description": "تغییرات را ذخیره کرده و منوی تنظیمات را می‌بندد",
"app.settings.dataSavingTab.label": "صرفه‌جویی در مصرف داده",
"app.settings.dataSavingTab.webcam": "فعال‌سازی دوربین‌های دیگر شرکت‌کنندگان",
"app.settings.dataSavingTab.screenShare": "فعال‌سازی اشتراک‌گذاری صفحه",
"app.settings.dataSavingTab.screenShare": "فعال‌سازی اشتراک‌گذاری دسکتاپ دیگر شرکت‌کنندگان",
"app.settings.dataSavingTab.description": "برای صرفه جویی در مصرف پهنای باند اینترنت، آنچه را که باید نمایش داده شود انتخاب کنید.",
"app.settings.save-notification.label": "تنظیمات ذخیره شدند",
"app.statusNotifier.lowerHands": "پایین آوردن دست‌ها",
@ -440,7 +567,6 @@
"app.actionsBar.actionsDropdown.presentationLabel": "مدیریت ارائه‌ها",
"app.actionsBar.actionsDropdown.initPollLabel": "آغاز یک نظرسنجی",
"app.actionsBar.actionsDropdown.desktopShareLabel": "اشتراک‌گذاری صفحه خود",
"app.actionsBar.actionsDropdown.lockedDesktopShareLabel": "اشتراک گذاری قفل شد",
"app.actionsBar.actionsDropdown.stopDesktopShareLabel": "متوقف‌کردن اشتراک‌گذاری صفحه خود",
"app.actionsBar.actionsDropdown.presentationDesc": "بارگذاری پرونده ارائه شما",
"app.actionsBar.actionsDropdown.initPollDesc": "آغاز یک نظرسنجی",
@ -457,6 +583,7 @@
"app.actionsBar.actionsDropdown.takePresenterDesc": "تغییر نقش خود به عنوان ارائه‌دهنده جدید",
"app.actionsBar.actionsDropdown.selectRandUserLabel": "انتخاب کاربر تصادفی",
"app.actionsBar.actionsDropdown.selectRandUserDesc": "انتخاب تصادفی کاربر از بینندگان موجود",
"app.actionsBar.actionsDropdown.propagateLayoutLabel": "اعمال چیدمان برای همه",
"app.actionsBar.emojiMenu.statusTriggerLabel": "تنظیم وضعیت",
"app.actionsBar.emojiMenu.awayLabel": "عدم حضور",
"app.actionsBar.emojiMenu.awayDesc": "تغییر وضعیت خود به عدم حضور",
@ -496,6 +623,7 @@
"app.audioNotification.audioFailedError1012": "اتصال بسته شد (خطای ICE ۱۰۱۲)",
"app.audioNotification.audioFailedMessage": "اتصال صدای شما با خطا موجه شد",
"app.audioNotification.mediaFailedMessage": "متد getUserMicMedia به دلیل اینکه اتصال تنها از طریق لینک امن امکان پذیر است، با خطا مواجه شد",
"app.audioNotification.deviceChangeFailed": "تغییر دستگاه صوتی انجام نشد. بررسی کنید که آیا دستگاه انتخاب‌شده به درستی تنظیم شده و در دسترس باشد",
"app.audioNotification.closeLabel": "بستن",
"app.audioNotificaion.reconnectingAsListenOnly": "میکروفن برای کاربران قفل شده است، شما به عنوان شنونده به جلسه متصل خواهید شد ",
"app.breakoutJoinConfirmation.title": "پیوستن به اتاق زیرمجموعه",
@ -509,13 +637,14 @@
"app.breakout.dropdown.manageDuration": "تغییر مدت زمان",
"app.breakout.dropdown.destroyAll": "اتمام اتاق‌های زیرمجموعه",
"app.breakout.dropdown.options": "گزینه‌های اتاق‌های زیرمجموعه",
"app.breakout.dropdown.manageUsers": "مدیریت کاربران",
"app.calculatingBreakoutTimeRemaining": "در حال محاسبه زمان باقی مانده ...",
"app.audioModal.ariaTitle": "پنجره پیوستن به صدا",
"app.audioModal.microphoneLabel": "میکروفون",
"app.audioModal.listenOnlyLabel": "تنها شنونده",
"app.audioModal.microphoneDesc": "پیوستن به جلسه صوتی با میکروفن",
"app.audioModal.listenOnlyDesc": "پیوستن به جلسه صوتی به صورت شنونده",
"app.audioModal.audioChoiceLabel": "چگونه می‌خواهید صدای خود را متصل کنید؟",
"app.audioModal.audioChoiceLabel": "چگونه می‌خواهید به این جلسه بپیوندید؟",
"app.audioModal.iOSBrowser": "صدا/تصویر پشتیبانی نمی‌شود",
"app.audioModal.iOSErrorDescription": "در حال حاضر صدا و تصویر در مرورگر کروم iOS پشتیبانی نمی‌شود.",
"app.audioModal.iOSErrorRecommendation": "پیشنهاد ما استفاده از مرورگر سافاری در iOS است.",
@ -555,6 +684,7 @@
"app.audio.changeAudioDevice": "تغییر دستگاه صوتی",
"app.audio.enterSessionLabel": "ورود به جلسه",
"app.audio.playSoundLabel": "پخش صدا",
"app.audio.stopAudioFeedback": "توقف بازخورد صدا",
"app.audio.backLabel": "بازگشت",
"app.audio.loading": "در حال بارگیری",
"app.audio.microphones": "میکروفون‌ها",
@ -567,10 +697,32 @@
"app.audio.audioSettings.testSpeakerLabel": "بلندگوی خود را امتحان کنید",
"app.audio.audioSettings.microphoneStreamLabel": "بلندی جریان صدای شما",
"app.audio.audioSettings.retryLabel": "تلاش مجدد",
"app.audio.audioSettings.fallbackInputLabel": "ورودی صدا {0}",
"app.audio.audioSettings.fallbackOutputLabel": "خروجی صدا {0}",
"app.audio.audioSettings.defaultOutputDeviceLabel": "پیش‌فرض",
"app.audio.audioSettings.findingDevicesLabel": "در حال یافتن دستگاه‌ها...",
"app.audio.listenOnly.backLabel": "بازگشت",
"app.audio.listenOnly.closeLabel": "بستن",
"app.audio.permissionsOverlay.title": "اجازه دسترسی به میکروفون خود را بدهید",
"app.audio.permissionsOverlay.hint": "ما برای متصل کردن شما به جلسه صوتی نیازمند داشتن مجوز دسترسی به دستگاه‌های صوتی/تصویری شما هستیم :)",
"app.audio.captions.button.start": "شروع زیرنویس‌های بسته شده",
"app.audio.captions.button.stop": "توقف زیرنویس‌های بسته شده",
"app.audio.captions.button.language": "زبان",
"app.audio.captions.button.transcription": "آوانویسی",
"app.audio.captions.button.transcriptionSettings": "تنظیمات آوانویسی",
"app.audio.captions.speech.title": "آوانویسی خودکار",
"app.audio.captions.speech.disabled": "غیرفعال شده",
"app.audio.captions.speech.unsupported": "مرورگر شما از تشخیص گفتار پشتیبانی نمی‌کند. صدای شما آوانویسی نخواهد شد",
"app.audio.captions.select.de-DE": "آلمانی",
"app.audio.captions.select.en-US": "انگلیسی",
"app.audio.captions.select.es-ES": "اسپانیایی",
"app.audio.captions.select.fr-FR": "فرانسوی",
"app.audio.captions.select.hi-ID": "هندی",
"app.audio.captions.select.it-IT": "ایتالیایی",
"app.audio.captions.select.ja-JP": "ژاپنی",
"app.audio.captions.select.pt-BR": "پرتغالی",
"app.audio.captions.select.ru-RU": "روسی",
"app.audio.captions.select.zh-CN": "چینی",
"app.error.removed": "شما از جلسه حذف شده‌اید",
"app.error.meeting.ended": "شما از جلسه خارج شده‌اید",
"app.meeting.logout.duplicateUserEjectReason": "کاربر تکراری در حال تلاش برای پیوستن به جلسه است",
@ -578,6 +730,7 @@
"app.meeting.logout.ejectedFromMeeting": "شما از جلسه حذف شده‌اید",
"app.meeting.logout.validateTokenFailedEjectReason": "خطا در اعتبارسنجی توکن احراز هویت",
"app.meeting.logout.userInactivityEjectReason": "کاربر برای مدت طولانی غیرفعال است",
"app.meeting.logout.maxParticipantsReached": "حداکثر تعداد شرکت‌کنندگان مجاز برای این جلسه رسیده است",
"app.meeting-ended.rating.legendLabel": "رتبه‌بندی بازخورد",
"app.meeting-ended.rating.starLabel": "ستاره",
"app.modal.close": "بستن",
@ -599,8 +752,11 @@
"app.error.403": "شما از جلسه حذف شده‌اید",
"app.error.404": "پیدا نشد",
"app.error.408": "احراز هویت شکست خورد",
"app.error.409": "ناسازگاری",
"app.error.410": "جلسه پایان یافت",
"app.error.500": "اوه، مشکلی پیش آمد",
"app.error.503": "ارتباط شما قطع شد",
"app.error.disconnected.rejoin": "شما می‌توانید صفحه را برای پیوستن مجدد تازه‌سازی کنید.",
"app.error.userLoggedOut": "کاربر به خاطر خروج از سیستم، sessionToken نامعتبر دارد",
"app.error.ejectedUser": "کاربر به خاطر اخراج، sessionToken نامعتبر دارد",
"app.error.joinedAnotherWindow": "به نظر می‌رسد که این جلسه در پنجره مرورگر دیگری باز شده است. ",
@ -644,14 +800,18 @@
"app.userList.guest.privateMessageLabel": "پیام",
"app.userList.guest.acceptLabel": "پذیرفتن",
"app.userList.guest.denyLabel": "ردکردن",
"app.userList.guest.feedbackMessage": "اقدام اعمال‌شده:",
"app.user-info.title": "جستجوی دایرکتوری",
"app.toast.breakoutRoomEnded": "اتاق زیرمجموعه بسته شد، لطفا دوباره به صدا بپیوندید.",
"app.toast.chat.public": "پیام جدید در گفتگوی عمومی",
"app.toast.chat.private": "پیام جدید در گفتگوی خصوصی",
"app.toast.chat.system": "سیستم",
"app.toast.chat.poll": "نتایج نظرسنجی",
"app.toast.chat.pollClick": "نتایج نظرسنجی منتشر شد. برای دیدن اینجا کلیک کنید.",
"app.toast.clearedEmoji.label": "وضعیت شکلک‌ها پاک شد",
"app.toast.setEmoji.label": "وضعیت شکلک‌ها به {0} تغییر داده شد",
"app.toast.meetingMuteOn.label": "همه کاربران بی‌صدا شده‌اند",
"app.toast.meetingMuteOnViewers.label": "همه بینندگان بی‌صدا شده‌اند",
"app.toast.meetingMuteOff.label": "امکان بی‌صدا کردن جلسه غیرفعال شد",
"app.toast.setEmoji.raiseHand": "شما دستتان را بالا برده‌اید",
"app.toast.setEmoji.lowerHand": "دست شما پایین آورده شد",
@ -688,6 +848,38 @@
"app.shortcut-help.toggleFullscreenKey": "کلید Enter",
"app.shortcut-help.nextSlideKey": "کلید سمت راست",
"app.shortcut-help.previousSlideKey": "کلید سمت چپ",
"app.shortcut-help.select": "انتخاب ابزار",
"app.shortcut-help.pencil": "مداد",
"app.shortcut-help.eraser": "پاک‌کن",
"app.shortcut-help.rectangle": "مستطیل",
"app.shortcut-help.elipse": "بیضی",
"app.shortcut-help.triangle": "مثلث",
"app.shortcut-help.line": "خط",
"app.shortcut-help.arrow": "فلش",
"app.shortcut-help.text": "ابزار متن",
"app.shortcut-help.note": "یادداشت چسبنده",
"app.shortcut-help.general": "عمومی",
"app.shortcut-help.presentation": "ارائه",
"app.shortcut-help.whiteboard": "تخته‌سفید",
"app.shortcut-help.zoomIn": "افزایش بزرگ‌نمایی",
"app.shortcut-help.zoomOut": "کاهش بزرگ‌نمایی",
"app.shortcut-help.zoomFit": "بازنشانی بزرگ‌نمایی",
"app.shortcut-help.zoomSelect": "بزرگ‌نمایی به انتخاب",
"app.shortcut-help.flipH": "وارونه‌سازی افقی",
"app.shortcut-help.flipV": "وارونه‌سازی عمودی",
"app.shortcut-help.lock": "قفل / باز کردن",
"app.shortcut-help.moveToFront": "حرکت به سمت جلو",
"app.shortcut-help.moveToBack": "حرکت به سمت عقب",
"app.shortcut-help.moveForward": "به جلو حرکت کن",
"app.shortcut-help.moveBackward": "به عقب حرکت کن",
"app.shortcut-help.undo": "واگردانی",
"app.shortcut-help.redo": "انجام دوباره",
"app.shortcut-help.cut": "برش",
"app.shortcut-help.copy": "کپی",
"app.shortcut-help.paste": "چسباندن",
"app.shortcut-help.selectAll": "انتخاب همه",
"app.shortcut-help.delete": "حذف",
"app.shortcut-help.duplicate": "تکثیرکردن",
"app.lock-viewers.title": "قفل‌کردن کاربران",
"app.lock-viewers.description": "این گزینه‌ها شما را قادر می‌سازد تا دسترسی به امکانات ویژه را از کاربران بگیرید.",
"app.lock-viewers.featuresLable": "ویژگی",
@ -724,6 +916,7 @@
"app.connection-status.no": "خیر",
"app.connection-status.notification": "قطعی در اتصال شما پیدا شد",
"app.connection-status.offline": "آفلاین",
"app.connection-status.clientNotRespondingWarning": "کارخواه پاسخ نمی‌دهد",
"app.connection-status.audioUploadRate": "نرخ بارگذاری صدا",
"app.connection-status.audioDownloadRate": "نرخ بارگیری صدا",
"app.connection-status.videoUploadRate": "نرخ بارگذاری ویدیو",
@ -737,13 +930,19 @@
"app.connection-status.next": "صفحه بعدی",
"app.connection-status.prev": "صفحه قبلی",
"app.learning-dashboard.label": "پیشخوان تحلیل یادگیری",
"app.learning-dashboard.description": "بازکردن پیشخوان به همراه فعالیت‌های کاربران",
"app.learning-dashboard.description": "پیشخوان به همراه فعالیت‌های کاربران",
"app.learning-dashboard.clickHereToOpen": "بازکردن پیشخوان تحلیل یادگیری",
"app.recording.startTitle": "شروع ضبط",
"app.recording.stopTitle": "وقفه در ضبط",
"app.recording.resumeTitle": "از سر گرفتن ضبط",
"app.recording.startDescription": "می‌توانید بعدا دوباره دکمه ضبط را انتخاب کنید تا در ضبط وقفه ایجاد کنید.",
"app.recording.stopDescription": "آیا از وقفه در ضبط اطمینان دارید؟ با انتخاب مجدد دکمه ضبط، می‌توانید آن را ادامه دهید.",
"app.recording.notify.title": "ضبط شروع شده است",
"app.recording.notify.description": "ضبط براساس باقی‌مانده این جلسه در دسترس خواهد بود",
"app.recording.notify.continue": "ادامه",
"app.recording.notify.leave": "ترک جلسه",
"app.recording.notify.continueLabel" : "پذیرفتن ضبط و ادامه",
"app.recording.notify.leaveLabel" : "ضبط را نپذیر و جلسه را ترک کن",
"app.videoPreview.cameraLabel": "دوربین",
"app.videoPreview.profileLabel": "کیفیت",
"app.videoPreview.quality.low": "کم",
@ -760,13 +959,20 @@
"app.videoPreview.webcamOptionLabel": "انتخاب دوربین",
"app.videoPreview.webcamPreviewLabel": "پیش‌نمایش دوربین",
"app.videoPreview.webcamSettingsTitle": "تنظیمات دوربین",
"app.videoPreview.webcamEffectsTitle": "جلوه‌های بصری وب‌کم",
"app.videoPreview.webcamVirtualBackgroundLabel": "تنظیمات پس‌زمینه مجازی",
"app.videoPreview.webcamVirtualBackgroundDisabledLabel": "این دستگاه از پس‌زمینه‌های مجازی پشتیبانی نمی‌کند",
"app.videoPreview.webcamNotFoundLabel": "دوربین یافت نشد",
"app.videoPreview.profileNotFoundLabel": "نمایه دوربین پشتیبانی نمی‌شود",
"app.videoPreview.brightness": "روشنایی",
"app.videoPreview.wholeImageBrightnessLabel": "کل تصویر",
"app.videoPreview.wholeImageBrightnessDesc": "روشنایی را برای جریان تصویر و پس‌زمینه اعمال می‌کند",
"app.videoPreview.sliderDesc": "افزایش یا کاهش سطح روشنایی",
"app.video.joinVideo": "اشتراک‌گذاری دوربین",
"app.video.connecting": "اشتراک دوربین در حال شروع است ...",
"app.video.leaveVideo": "متوقف‌کردن اشتراک‌گذاری دوربین",
"app.video.videoSettings": "تنظیمات ویدیو",
"app.video.visualEffects": "جلوه‌های بصری",
"app.video.advancedVideo": "باز‌کردن تنظیمات پیشرفته",
"app.video.iceCandidateError": "خطا در افزودن کاندید ICE",
"app.video.iceConnectionStateError": "اتصال موفقیت‌آمیز نبود (خطای ICE ۱۱۰۷)",
@ -782,6 +988,7 @@
"app.video.notReadableError": "عدم امکان دسترسی به دوربین، مطمئن شوید دوربین شما توسط برنامه دیگری در حال استفاده نیست",
"app.video.timeoutError": "مرورگر به موقع پاسخ نداد.",
"app.video.genericError": "خطای ناشناخته‌ای در دستگاه رخ داده است (خطا {0})",
"app.video.inactiveError": "وب‌کم شما به طور غیرمنتظره‌ای متوقف شد. لطفا مجوزهای مرورگر خود را بررسی کنید",
"app.video.mediaTimedOutError": "جریان دوربین شما قطع شد. تلاش کنید دوباره آن را به اشتراک بگذارید",
"app.video.mediaFlowTimeout1020": "رسانه نتوانست به سرور برسد (خطای 1020)",
"app.video.suggestWebcamLock": "آیا تنظیمات قفل برای دوربین‌های کاربران اعمال شود؟",
@ -804,8 +1011,17 @@
"app.video.virtualBackground.board": "تابلو",
"app.video.virtualBackground.coffeeshop": "کافی‌شاپ",
"app.video.virtualBackground.background": "پس‌زمینه",
"app.video.virtualBackground.backgroundWithIndex": "پس‌زمینه {0}",
"app.video.virtualBackground.custom": "از رایانه خود بارگذاری کنید",
"app.video.virtualBackground.remove": "حذف تصویر اضافه شده",
"app.video.virtualBackground.genericError": "جلوه دوربین اعمال نشد. دوباره تلاش کنید.",
"app.video.virtualBackground.camBgAriaDesc": "تنظیم پس‌زمینه مجازی دوربین به {0}",
"app.video.virtualBackground.maximumFileSizeExceeded": "از حداکثر اندازه پرونده بیشتر شده است. ({0} مگابایت)",
"app.video.virtualBackground.typeNotAllowed": "نوع پرونده مجاز نیست.",
"app.video.virtualBackground.errorOnRead": "هنگام خواندن پرونده مشکلی پیش آمد.",
"app.video.virtualBackground.uploaded": "بارگذاری شد",
"app.video.virtualBackground.uploading": "در حال بارگذاری...",
"app.video.virtualBackground.button.customDesc": "افزودن یک تصویر پس‌زمینه مجازی",
"app.video.camCapReached": "نمی‌توانید دوربین‌های بیشتری را به اشتراک بگذارید",
"app.video.meetingCamCapReached": "جلسه به حد مجاز دوربین‌های همزمان خود رسیده است",
"app.video.dropZoneLabel": "اینجا رها کنید",
@ -826,6 +1042,7 @@
"app.whiteboard.annotations.poll": "نتایج نظرسنجی منتشر شد",
"app.whiteboard.annotations.pollResult": "نتیجه نظرسنجی",
"app.whiteboard.annotations.noResponses": "بدون پاسخ",
"app.whiteboard.annotations.notAllowed": "شما مجاز به انجام این تغییر نیستید",
"app.whiteboard.toolbar.tools": "ابزارها",
"app.whiteboard.toolbar.tools.hand": "حرکت‌دادن",
"app.whiteboard.toolbar.tools.pencil": "مداد",
@ -852,6 +1069,7 @@
"app.whiteboard.toolbar.color.silver": "نقره‌ای",
"app.whiteboard.toolbar.undo": "پاک‌کردن آخرین نوشته",
"app.whiteboard.toolbar.clear": "پاک‌کردن همه نوشته‌ها",
"app.whiteboard.toolbar.clearConfirmation": "آیا مطمئن هستید که می‌خواهید همه حاشیه‌نویسی‌ها را پاک کنید؟",
"app.whiteboard.toolbar.multiUserOn": "فعال‌سازی حالت چندکاربره تخته‌سفید",
"app.whiteboard.toolbar.multiUserOff": "غیرفعال‌سازی حالت چندکاربره تخته‌سفید",
"app.whiteboard.toolbar.palmRejectionOn": "فعال‌سازی نادیده‌گرفتن لمس اضافی",
@ -871,13 +1089,13 @@
"app.videoDock.webcamUnfocusDesc": "برداشتن تمرکز روی دوربین انتخاب شده",
"app.videoDock.webcamPinLabel": "سنجاق‌کردن",
"app.videoDock.webcamPinDesc": "دوربین انتخاب‌شده را سنجاق کنید",
"app.videoDock.webcamFullscreenLabel": "وب‌کم تمام‌صفحه",
"app.videoDock.webcamSqueezedButtonLabel": "گزینه‌های وب‌کم",
"app.videoDock.webcamUnpinLabel": "برداشتن سنجاق",
"app.videoDock.webcamUnpinLabelDisabled": "فقط مدیران می‌توانند سنجاق کاربران را بردارند",
"app.videoDock.webcamUnpinDesc": "سنجاق دوربین انتخاب‌شده را بردارید",
"app.videoDock.autoplayBlockedDesc": "ما برای نمایش دوربین کاربران دیگر به شما، به اجازهٔ شما نیازمندیم.",
"app.videoDock.autoplayAllowLabel": "مشاهده دوربین‌ها",
"app.invitation.title": "دعوت به اتاق زیرمجموعه",
"app.invitation.confirm": "دعوت",
"app.createBreakoutRoom.title": "اتاق‌های زیرمجموعه",
"app.createBreakoutRoom.ariaTitle": "پنهان‌کردن اتاق‌های زیرمجموعه",
"app.createBreakoutRoom.breakoutRoomLabel": "اتاق‌های زیرمجموعه {0}",
@ -909,6 +1127,8 @@
"app.createBreakoutRoom.addRoomTime": "افزایش زمان اتاق زیرمجموعه به ",
"app.createBreakoutRoom.addParticipantLabel": "+ افزودن شرکت کننده",
"app.createBreakoutRoom.freeJoin": "اجازه انتخاب اتاق زیرمجموعه به کاربران برای پیوستن",
"app.createBreakoutRoom.captureNotes": "ثبت یادداشت‌های اشتراکی پس از پایان اتاق‌های زیرمجموعه",
"app.createBreakoutRoom.captureSlides": "ثبت تخته‌سفید پس از پایان اتاق‌های زیرمجموعه",
"app.createBreakoutRoom.leastOneWarnBreakout": "شما باید حداقل یک کاربر را در هر اتاق زیرمجموعه قرار دهید.",
"app.createBreakoutRoom.minimumDurationWarnBreakout": "حداقل مدت زمان اتاق زیرمجموعه {0} دقیقه است.",
"app.createBreakoutRoom.modalDesc": "نکته: می‌توانید نام یک کاربر را بکشید و رها کنید تا به آن‌ها اتاقی را اختصاص دهید. ",
@ -921,6 +1141,14 @@
"app.createBreakoutRoom.setTimeCancel": "لغو",
"app.createBreakoutRoom.setTimeHigherThanMeetingTimeError": "مدت زمان اتاق‌های زیرمجموعه نمی‌تواند از زمان جلسه بیشتر باشد.",
"app.createBreakoutRoom.roomNameInputDesc": "به‌روزرسانی نام اتاق‌های زیر مجموعه",
"app.createBreakoutRoom.movedUserLabel": "{0} به اتاق {1} منتقل شد",
"app.updateBreakoutRoom.modalDesc": "برای به‌روزرسانی یا دعوت از یک کاربر، به سادگی آنها را به اتاق مورد نظر بکشید.",
"app.updateBreakoutRoom.cancelLabel": "لغو",
"app.updateBreakoutRoom.title": "بروزرسانی اتاق‌های زیرمجموعه",
"app.updateBreakoutRoom.confirm": "اعمال",
"app.updateBreakoutRoom.userChangeRoomNotification": "شما به اتاق {0} منتقل شدید.",
"app.smartMediaShare.externalVideo": "ویدیو(های) خارجی",
"app.update.resetRoom": "بازنشانی اتاق کاربر",
"app.externalVideo.start": "اشتراک یک ویدیوی جدید",
"app.externalVideo.title": "اشتراک یک ویدیوی خارجی",
"app.externalVideo.input": "آدرس ویدیوی خارجی",
@ -946,14 +1174,23 @@
"app.debugWindow.form.enableAutoarrangeLayoutDescription": "(اگر ناحیه دوربین‌ها را بکشید یا تغییر اندازه دهید غیرفعال می‌شود)",
"app.debugWindow.form.chatLoggerLabel": "آزمایش سطوح گفتگو گزارشات",
"app.debugWindow.form.button.apply": "اعمال",
"app.layout.modal.title": "چیدمان‌ها",
"app.layout.modal.confirm": "تایید",
"app.layout.modal.cancel": "لغو",
"app.layout.modal.layoutLabel": "چیدمان خود را انتخاب کنید",
"app.layout.modal.keepPushingLayoutLabel": "تحمیل چیدمان به همه",
"app.layout.modal.pushLayoutLabel": "تحمیل به همه",
"app.layout.modal.layoutToastLabel": "تنظیمات چیدمان تغییر کرد",
"app.layout.modal.layoutSingular": "چیدمان",
"app.layout.modal.layoutBtnDesc": "چیدمان را به عنوان گزینه انتخاب‌شده تنظیم می‌کند",
"app.layout.style.custom": "سفارشی",
"app.layout.style.smart": "چینش هوشمند",
"app.layout.style.smart": "چیدمان هوشمند",
"app.layout.style.presentationFocus": "تمرکز روی ارائه",
"app.layout.style.videoFocus": "تمرکز روی ویدیو",
"app.layout.style.customPush": "سفارشی (قالب برای همه اعمال شود)",
"app.layout.style.smartPush": "چینش هوشمند (قالب برای همه اعمال شود)",
"app.layout.style.presentationFocusPush": "تمرکز روی ارائه (قالب برای همه اعمال شود)",
"app.layout.style.videoFocusPush": "تمرکز روی ویدیو (قالب برای همه اعمال شود)",
"app.layout.style.customPush": "سفارشی (چیدمان برای همه اعمال شود)",
"app.layout.style.smartPush": "چیدمان هوشمند (چیدمان برای همه اعمال شود)",
"app.layout.style.presentationFocusPush": "تمرکز روی ارائه (چیدمان برای همه اعمال شود)",
"app.layout.style.videoFocusPush": "تمرکز روی ویدیو (چیدمان برای همه اعمال شود)",
"playback.button.about.aria": "درباره",
"playback.button.clear.aria": "پاک‌سازی جستجو",
"playback.button.close.aria": "بستن پنجره",
@ -995,6 +1232,7 @@
"playback.player.thumbnails.wrapper.aria": "ناحیه تصاویر بندانگشتی",
"playback.player.webcams.wrapper.aria": "ناحیه دوربین‌ها",
"app.learningDashboard.dashboardTitle": "پیشخوان تحلیل یادگیری",
"app.learningDashboard.bigbluebuttonTitle": "BigBlueButton",
"app.learningDashboard.downloadSessionDataLabel": "بارگیری داده‌های جلسه",
"app.learningDashboard.lastUpdatedLabel": "آخرین به‌روز‌رسانی در",
"app.learningDashboard.sessionDataDownloadedLabel": "بارگیری شد!",
@ -1005,7 +1243,7 @@
"app.learningDashboard.indicators.meetingStatusActive": "فعال",
"app.learningDashboard.indicators.usersOnline": "کاربران فعال",
"app.learningDashboard.indicators.usersTotal": "تعداد کل کاربران",
"app.learningDashboard.indicators.polls": "نظرسنجی‌ها",
"app.learningDashboard.indicators.polls": "نظرسنجی‌",
"app.learningDashboard.indicators.timeline": "خط زمانی",
"app.learningDashboard.indicators.activityScore": "امتیاز فعالیت",
"app.learningDashboard.indicators.duration": "مدت زمان",
@ -1019,6 +1257,12 @@
"app.learningDashboard.userDetails.response": "پاسخ",
"app.learningDashboard.userDetails.mostCommonAnswer": "رایج‌ترین پاسخ",
"app.learningDashboard.userDetails.anonymousAnswer": "نظرسنجی ناشناس",
"app.learningDashboard.userDetails.talkTime": "زمان صحبت‌ کردن",
"app.learningDashboard.userDetails.messages": "پیام‌ها",
"app.learningDashboard.userDetails.emojis": "شکلک‌ها",
"app.learningDashboard.userDetails.raiseHands": "بالابردن دست‌ها",
"app.learningDashboard.userDetails.pollVotes": "رای‌های نظرسنجی",
"app.learningDashboard.userDetails.onlineIndicator": "{0} زمان آنلاین",
"app.learningDashboard.usersTable.title": "مرور کلی",
"app.learningDashboard.usersTable.colOnline": "زمان آنلاین‌بودن",
"app.learningDashboard.usersTable.colTalk": "زمان صحبت‌کردن",
@ -1042,8 +1286,13 @@
"app.learningDashboard.pollsTable.anonymousRowName": "ناشناس",
"app.learningDashboard.pollsTable.noPollsCreatedHeading": "هیچ نظرسنجی ایجاد نشده است",
"app.learningDashboard.pollsTable.noPollsCreatedMessage": "زمانی که یک نظرسنجی برای کاربران ارسال شد، نتایج آن‌ها در این فهرست ظاهر می‌شود.",
"app.learningDashboard.pollsTable.answerTotal": "جمع کل",
"app.learningDashboard.pollsTable.userLabel": "کاربر",
"app.learningDashboard.statusTimelineTable.title": "خط زمانی",
"app.learningDashboard.statusTimelineTable.thumbnail": "تصویر کوچک ارائه",
"app.learningDashboard.statusTimelineTable.presentation": "ارائه",
"app.learningDashboard.statusTimelineTable.pageNumber": "صفحه",
"app.learningDashboard.statusTimelineTable.setAt": "تنظیم در",
"app.learningDashboard.errors.invalidToken": "توکن نشست نامعتبر است",
"app.learningDashboard.errors.dataUnavailable": "داده‌ها دیگر در دسترس نیستند",
"mobileApp.portals.list.empty.addFirstPortal.label": "با استفاده از دکمه بالا اولین درگاه خود را اضافه کنید،",
@ -1057,6 +1306,4 @@
"mobileApp.portals.addPortalPopup.validation.emptyFields": "زمینه‌های مورد نیاز",
"mobileApp.portals.addPortalPopup.validation.portalNameAlreadyExists": "نام در حال حاضر استفاده شده است",
"mobileApp.portals.addPortalPopup.validation.urlInvalid": "خطا در تلاش برای بارگیری صفحه - آدرس و اتصال شبکه را بررسی کنید"
}

View File

@ -247,7 +247,7 @@
"app.presentationUploader.exportingTimeout": "A exportación está tardando demasiado...",
"app.presentationUploader.export": "Enviar para o chat",
"app.presentationUploader.export.linkAvailable": "Ligazón para descargar {0} dispoñible no chat público.",
"app.presentationUploader.export.notAccessibleWarning": "pode non ser accesible",
"app.presentationUploader.export.notAccessibleWarning": "pode non cumprir as normas de accesibilidade",
"app.presentationUploader.currentPresentationLabel": "Presentación actual",
"app.presentationUploder.extraHint": "IMPORTANTE: cada ficheiro non pode exceder {0} MB e {1} páxinas.",
"app.presentationUploder.uploadLabel": "Enviar",
@ -266,6 +266,7 @@
"app.presentationUploder.rejectedError": "O(s) ficheiro(s) seleccionado(s) foi(foron) rexeitado(s). Revise o(os) tipo(s) de ficheiro.",
"app.presentationUploder.connectionClosedError": "Interrompeuse por mor dunha mala conectividade. Tenteo de novo.",
"app.presentationUploder.upload.progress": "Enviando ({0}%)",
"app.presentationUploder.conversion.204": "Non hai contido que capturar",
"app.presentationUploder.upload.413": "O ficheiro é demasiado grande, superou o máximo de {0} MB",
"app.presentationUploder.genericError": "Ouh! algo foi mal…",
"app.presentationUploder.upload.408": "Solicitar o tempo de espera do testemuño de envío.",
@ -915,6 +916,7 @@
"app.connection-status.no": "Non",
"app.connection-status.notification": "Detectouse a perda da súa conexión",
"app.connection-status.offline": "sen conexión",
"app.connection-status.clientNotRespondingWarning": "O cliente non responde",
"app.connection-status.audioUploadRate": "Taxa de envío do son",
"app.connection-status.audioDownloadRate": "Taxa de descarga do son",
"app.connection-status.videoUploadRate": "Taxa de envío do vídeo",

View File

@ -88,7 +88,7 @@
"app.notes.locked": "ロック",
"app.notes.disabled": "メディアエリアに固定",
"app.notes.notesDropdown.covertAndUpload": "ノートをプレゼンファイルに変換",
"app.notes.notesDropdown.pinNotes": "ホワイトボードにノートの内容を固定",
"app.notes.notesDropdown.pinNotes": "ノートをホワイトボードに表示",
"app.notes.notesDropdown.unpinNotes": "ノートを外す",
"app.notes.notesDropdown.notesOptions": "ノートの操作",
"app.pads.hint": "パッドのツールバーにフォーカスするにはEscを押してください",
@ -247,7 +247,7 @@
"app.presentationUploader.exportingTimeout": "エクスポートに時間がかかりすぎています...",
"app.presentationUploader.export": "チャットへ送信",
"app.presentationUploader.export.linkAvailable": "{0}をダウンロードするためのリンクが、公開チャットから利用できます。",
"app.presentationUploader.export.notAccessibleWarning": "にはアクセスできないようです",
"app.presentationUploader.export.notAccessibleWarning": "一部の視聴者には、ファイルが読み取りづらい可能性があります",
"app.presentationUploader.currentPresentationLabel": "プレゼンテーションファイル",
"app.presentationUploder.extraHint": "重要:それぞれのファイルが{0}MB、{1}ページを超えないようにしてください。",
"app.presentationUploder.uploadLabel": "アップロード",
@ -266,6 +266,7 @@
"app.presentationUploder.rejectedError": "選択されたファイルはアップロードできません。ファイル形式を確認してください。",
"app.presentationUploder.connectionClosedError": "接続に問題があったため中断されました。もう一度試してみてください。",
"app.presentationUploder.upload.progress": "アップロード中({0}%)",
"app.presentationUploder.conversion.204": "内容が読み取れませんでした",
"app.presentationUploder.upload.413": "ファイルが大きすぎます。最大サイズは {0} MBです。",
"app.presentationUploder.genericError": "申し訳ありませんが、何か問題があるようです。",
"app.presentationUploder.upload.408": "アップロード許可の要求が時間切れになりました。",
@ -915,6 +916,7 @@
"app.connection-status.no": "なし",
"app.connection-status.notification": "接続の切断がありました",
"app.connection-status.offline": "オフライン",
"app.connection-status.clientNotRespondingWarning": "クライアントからの応答なし",
"app.connection-status.audioUploadRate": "音声の上り速度",
"app.connection-status.audioDownloadRate": "音声の下り速度",
"app.connection-status.videoUploadRate": "映像の上り速度",

View File

@ -266,6 +266,7 @@
"app.presentationUploder.rejectedError": "Загрузка выбранных файлов была отменена. Пожалуйста проверьте тип файла (файлов)",
"app.presentationUploder.connectionClosedError": "Прервано из-за нестабильного соединения. Пожалуйста, попробуйте снова.",
"app.presentationUploder.upload.progress": "Загрузка ({0}%)",
"app.presentationUploder.conversion.204": "Нет контента для захвата",
"app.presentationUploder.upload.413": "Размер файла слишком большой. Максимум: {0} МБ",
"app.presentationUploder.genericError": "Упс, Что-то пошло не так...",
"app.presentationUploder.upload.408": "Токен запроса загрузки истёк.",

View File

@ -246,6 +246,8 @@
"app.presentationUploader.sent": "Gönderildi",
"app.presentationUploader.exportingTimeout": "Dışa aktarma işlemi çok uzun sürüyor...",
"app.presentationUploader.export": "Sohbete gönder",
"app.presentationUploader.export.linkAvailable": "{0} indirmek için bağlantıyı herkese açık sohbette bulabilirsiniz.",
"app.presentationUploader.export.notAccessibleWarning": "erişilebilirlik uyumluluğu olmayabilir",
"app.presentationUploader.currentPresentationLabel": "Geçerli sunum",
"app.presentationUploder.extraHint": "ÖNEMLİ: Her bir dosya {0} MB boyutundan ve {1} sayfadan küçük olmalıdır.",
"app.presentationUploder.uploadLabel": "Yükle",
@ -264,6 +266,7 @@
"app.presentationUploder.rejectedError": "Seçilmiş dosya(lar) reddedildi. Lütfen dosya türlerini denetleyin.",
"app.presentationUploder.connectionClosedError": "Kötü bağlantı nedeniyle kesintiye uğradı. Lütfen yeniden deneyin.",
"app.presentationUploder.upload.progress": "Yükleniyor ({0}%)",
"app.presentationUploder.conversion.204": "Yakalanacak bir içerik bulunamadı",
"app.presentationUploder.upload.413": "Dosya boyutu çok büyük. En fazla {0} MB olabilir",
"app.presentationUploder.genericError": "Bir sorun çıktı...",
"app.presentationUploder.upload.408": "Yükleme isteği kodunun süresi geçmiş.",
@ -454,6 +457,68 @@
"app.submenu.application.paginationEnabledLabel": "Görüntü sayfalandırma",
"app.submenu.application.layoutOptionLabel": "Ekran düzeni türü",
"app.submenu.application.pushLayoutLabel": "Ekran düzenini gönder",
"app.submenu.application.localeDropdown.af": "Afrikaanca",
"app.submenu.application.localeDropdown.ar": "Arapça",
"app.submenu.application.localeDropdown.az": "Azerice",
"app.submenu.application.localeDropdown.bg-BG": "Bulgarca",
"app.submenu.application.localeDropdown.bn": "Bengalce",
"app.submenu.application.localeDropdown.ca": "Katalanca",
"app.submenu.application.localeDropdown.cs-CZ": "Çekce",
"app.submenu.application.localeDropdown.da": "Danca",
"app.submenu.application.localeDropdown.de": "Almanca",
"app.submenu.application.localeDropdown.dv": "Divehi",
"app.submenu.application.localeDropdown.el-GR": "Yunanca",
"app.submenu.application.localeDropdown.en": "İngilizce",
"app.submenu.application.localeDropdown.eo": "Esperanto",
"app.submenu.application.localeDropdown.es": "İspanyolca",
"app.submenu.application.localeDropdown.es-419": "İspanyolca (Latin Amerika)",
"app.submenu.application.localeDropdown.es-ES": "İspanyolca (İspanya)",
"app.submenu.application.localeDropdown.es-MX": "İspanyolca (Meksika)",
"app.submenu.application.localeDropdown.et": "Estonca",
"app.submenu.application.localeDropdown.eu": "Bask",
"app.submenu.application.localeDropdown.fa-IR": "Farsça",
"app.submenu.application.localeDropdown.fi": "Fince",
"app.submenu.application.localeDropdown.fr": "Fransızca",
"app.submenu.application.localeDropdown.gl": "Galiçce",
"app.submenu.application.localeDropdown.he": "İbranice",
"app.submenu.application.localeDropdown.hi-IN": "Hintçe",
"app.submenu.application.localeDropdown.hr": "Hırvatça",
"app.submenu.application.localeDropdown.hu-HU": "Macarca",
"app.submenu.application.localeDropdown.hy": "Ermenice",
"app.submenu.application.localeDropdown.id": "Endonezce",
"app.submenu.application.localeDropdown.it-IT": "İtalyanca",
"app.submenu.application.localeDropdown.ja": "Japonca",
"app.submenu.application.localeDropdown.ka": "Gürcüce",
"app.submenu.application.localeDropdown.km": "Kmer",
"app.submenu.application.localeDropdown.kn": "Kannada",
"app.submenu.application.localeDropdown.ko-KR": "Korece",
"app.submenu.application.localeDropdown.lo-LA": "Lao",
"app.submenu.application.localeDropdown.lt-LT": "Litvanca",
"app.submenu.application.localeDropdown.lv": "Letonca",
"app.submenu.application.localeDropdown.ml": "Malayalam",
"app.submenu.application.localeDropdown.mn-MN": "Moğolca",
"app.submenu.application.localeDropdown.nb-NO": "Norveçce (bokmal)",
"app.submenu.application.localeDropdown.nl": "Felemenkçe",
"app.submenu.application.localeDropdown.oc": "Oksitan",
"app.submenu.application.localeDropdown.pl-PL": "Lehçe",
"app.submenu.application.localeDropdown.pt": "Portekizce",
"app.submenu.application.localeDropdown.pt-BR": "Portekizce (Brezilya)",
"app.submenu.application.localeDropdown.ro-RO": "Romence",
"app.submenu.application.localeDropdown.ru": "Rusça",
"app.submenu.application.localeDropdown.sk-SK": "Slovakça",
"app.submenu.application.localeDropdown.sl": "Slovence",
"app.submenu.application.localeDropdown.sr": "Sırça",
"app.submenu.application.localeDropdown.sv-SE": "İsveççe",
"app.submenu.application.localeDropdown.ta": "Tamil",
"app.submenu.application.localeDropdown.te": "Telugu",
"app.submenu.application.localeDropdown.th": "Tay",
"app.submenu.application.localeDropdown.tr": "Türkçe",
"app.submenu.application.localeDropdown.tr-TR": "Türkçe (Türkiye)",
"app.submenu.application.localeDropdown.uk-UA": "Ukraynaca",
"app.submenu.application.localeDropdown.vi": "Vietnamca",
"app.submenu.application.localeDropdown.vi-VN": "Vietnamca (Vietnam)",
"app.submenu.application.localeDropdown.zh-CN": "Çince basit (Çin)",
"app.submenu.application.localeDropdown.zh-TW": "Çince geleneksel (Tayvan)",
"app.submenu.notification.SectionTitle": "Bildirimler",
"app.submenu.notification.Desc": "Neyin nasıl bildirileceğini belirleyin.",
"app.submenu.notification.audioAlertLabel": "Sesli uyarılar",
@ -851,6 +916,7 @@
"app.connection-status.no": "Hayır",
"app.connection-status.notification": "Bağlantınızda kayıp algılandı",
"app.connection-status.offline": "çevrimdışı",
"app.connection-status.clientNotRespondingWarning": "İstemci yanıt vermiyor",
"app.connection-status.audioUploadRate": "Ses yükleme hızı",
"app.connection-status.audioDownloadRate": "Ses indirme hızı",
"app.connection-status.videoUploadRate": "Görüntü yükleme hızı",

View File

@ -266,6 +266,7 @@
"app.presentationUploder.rejectedError": "Неможливо завантажити вибрані файл(и). Перевірте тип файлу(iв).",
"app.presentationUploder.connectionClosedError": "Перервано через поганий зв'язок. Спробуйте пізніше",
"app.presentationUploder.upload.progress": "Завантаження ({0}%)",
"app.presentationUploder.conversion.204": "Відсутній вміст для перетворення",
"app.presentationUploder.upload.413": "Файл надто великий, розмір перевищує допустимі {0} МБ",
"app.presentationUploder.genericError": "Йой! Щось пішло не так ...",
"app.presentationUploder.upload.408": "Вичерпано час запиту дії токену для завантаження.",
@ -512,10 +513,10 @@
"app.submenu.application.localeDropdown.te": "Телугу",
"app.submenu.application.localeDropdown.th": "Тайська",
"app.submenu.application.localeDropdown.tr": "Турецька",
"app.submenu.application.localeDropdown.tr-TR": " Турецька",
"app.submenu.application.localeDropdown.tr-TR": "Турецька (Туреччина)",
"app.submenu.application.localeDropdown.uk-UA": "Українська",
"app.submenu.application.localeDropdown.vi": "В'єтнамська",
"app.submenu.application.localeDropdown.vi-VN": " Турецька",
"app.submenu.application.localeDropdown.vi-VN": "В’єтнамська (В’єтнам)",
"app.submenu.application.localeDropdown.zh-CN": "Китайська спрощена (Китай)",
"app.submenu.application.localeDropdown.zh-TW": "Китайська спрощена (Тайвань)",
"app.submenu.notification.SectionTitle": "Сповіщення",

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