diff --git a/akka-bbb-apps/build.sbt b/akka-bbb-apps/build.sbt index d16fdc33b810..57df043ed039 100755 --- a/akka-bbb-apps/build.sbt +++ b/akka-bbb-apps/build.sbt @@ -42,7 +42,7 @@ testOptions in Test += Tests.Argument(TestFrameworks.Specs2, "html", "console", testOptions in Test += Tests.Argument(TestFrameworks.ScalaTest, "-h", "target/scalatest-reports") Seq(Revolver.settings: _*) -lazy val bbbAppsAkka = (project in file(".")).settings(name := "bbb-apps-akka", libraryDependencies ++= Dependencies.runtime).settings(compileSettings) +lazy val bbbAppsAkka = (project in file(".")).settings(name := "bbb-apps-akka", libraryDependencies ++= Dependencies.runtime, dependencyOverrides ++= Dependencies.overrides).settings(compileSettings) // See https://github.com/scala-ide/scalariform // Config file is in ./.scalariform.conf diff --git a/akka-bbb-apps/deploy.sh b/akka-bbb-apps/deploy.sh index 219884fc5f38..a4484b6c09fd 100755 --- a/akka-bbb-apps/deploy.sh +++ b/akka-bbb-apps/deploy.sh @@ -9,5 +9,7 @@ echo '' echo '----------------' echo 'bbb-apps-akka updated' +sudo systemctl enable bbb-apps-akka.service + sudo service bbb-apps-akka start echo 'starting service bbb-apps-akka' diff --git a/akka-bbb-apps/project/Dependencies.scala b/akka-bbb-apps/project/Dependencies.scala index 7001c7db5b83..0d34e4ef59c1 100755 --- a/akka-bbb-apps/project/Dependencies.scala +++ b/akka-bbb-apps/project/Dependencies.scala @@ -15,15 +15,17 @@ object Dependencies { val pekkoVersion = "1.0.1" val pekkoHttpVersion = "1.0.0" val gson = "2.8.9" - val jackson = "2.13.5" - val logback = "1.2.13" + val jackson = "2.18.9" + val netty = "4.1.135.Final" + val logback = "1.5.38" + val slf4j = "2.0.17" val quicklens = "1.7.5" val spray = "1.3.6" val semver = "0.10.2" val commonmark = "0.27.0" // Apache Commons - val lang = "3.12.0" + val lang = "3.18.0" val codec = "1.15" val httpcomponents = "4.5.14" @@ -32,14 +34,14 @@ object Dependencies { // Database val slick = "3.4.1" - val postgresql = "42.5.0" + val postgresql = "42.7.13" val slickPg = "0.21.1" // Test val scalaTest = "3.2.11" val mockito = "2.23.0" val akkaTestKit = "2.6.0" - val jacksonDataFormat = "2.13.5" + val jacksonDataFormat = "2.18.9" } object Compile { @@ -117,4 +119,19 @@ object Dependencies { Compile.slickPgSprayJson, Compile.postgresql, Compile.jacksonDataFormat) ++ testing + + // Pin transitively-pulled artifacts to fixed releases; keep the jackson suite aligned. + val overrides = Seq( + "com.fasterxml.jackson.core" % "jackson-databind" % Versions.jackson, + "com.fasterxml.jackson.core" % "jackson-core" % Versions.jackson, + "com.fasterxml.jackson.core" % "jackson-annotations" % Versions.jackson, + "io.netty" % "netty-handler" % Versions.netty, + "io.netty" % "netty-codec" % Versions.netty, + "io.netty" % "netty-common" % Versions.netty, + "io.netty" % "netty-buffer" % Versions.netty, + "io.netty" % "netty-transport" % Versions.netty, + "io.netty" % "netty-resolver" % Versions.netty, + // logback 1.5.x is an slf4j-2.x provider; pin slf4j-api 2.x so the + // ServiceLoader binding resolves (pekko-slf4j is runtime-compatible). + "org.slf4j" % "slf4j-api" % Versions.slf4j) } diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/pads/PadslHdlrHelpers.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/pads/PadslHdlrHelpers.scala index 3b776db2f637..a69298ba8708 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/pads/PadslHdlrHelpers.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/pads/PadslHdlrHelpers.scala @@ -30,16 +30,17 @@ object PadslHdlrHelpers { } def broadcastBNSharedNotesCreateCmdMsg( - outGW: OutMsgRouter, - meetingId: String, - externalId: String, - model: String, - sharedNotesInitialContentJson: Vector[AnyRef] + outGW: OutMsgRouter, + meetingId: String, + externalId: String, + model: String, + sharedNotesInitialContentJson: Vector[AnyRef], + sharedNotesInitialContentMarkdown: String ): Unit = { val routing = collection.immutable.HashMap("sender" -> "bbb-apps-akka") val envelope = BbbCoreEnvelope(BNSharedNotesCreateCmdMsg.NAME, routing) val header = BbbCoreHeaderWithMeetingId(BNSharedNotesCreateCmdMsg.NAME, meetingId) - val body = BNSharedNotesCreateCmdMsgBody(externalId, model, sharedNotesInitialContentJson) + val body = BNSharedNotesCreateCmdMsgBody(externalId, model, sharedNotesInitialContentJson, sharedNotesInitialContentMarkdown) val event = BNSharedNotesCreateCmdMsg(header, body) val msgEvent = BbbCommonEnvCoreMsg(envelope, event) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/polls/PollHdlrHelpers.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/polls/PollHdlrHelpers.scala index 5afce5dd2d77..c4dd0f955d70 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/polls/PollHdlrHelpers.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/polls/PollHdlrHelpers.scala @@ -6,6 +6,27 @@ import org.bigbluebutton.core.running.OutMsgRouter object PollHdlrHelpers { + // Selects the answer ids that will actually be recorded for a poll vote. + // + // - A single-response question keeps at most the first requested id; a + // multi-response question keeps every distinct requested id. + // - Out-of-range ids (negative, or >= the number of options) are dropped so a + // malformed vote can neither crash the meeting actor via the indexed answer + // access that follows nor violate the poll_response foreign key on + // persistence. Invalid votes are silently ignored. + // + // validAnswerIds is the range of option indices for the question (empty when + // the question has no answers), so an empty range drops every requested id. + def selectValidAnswerIds(requestedAnswerIds: Seq[Int], multiResponse: Boolean, validAnswerIds: Range): Seq[Int] = { + val deduped = + if (!multiResponse && requestedAnswerIds.length > 1) { + Seq(requestedAnswerIds.head) + } else { + requestedAnswerIds.distinct + } + deduped.filter(validAnswerIds.contains) + } + def broadcastPollUpdatedEvent(outGW: OutMsgRouter, meetingId: String, userId: String, pollId: String, poll: SimplePollResultOutVO): Unit = { val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, meetingId, userId) val envelope = BbbCoreEnvelope(PollUpdatedEvtMsg.NAME, routing) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/polls/RespondToPollReqMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/polls/RespondToPollReqMsgHdlr.scala index 7f326a185a82..597d6401cba6 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/polls/RespondToPollReqMsgHdlr.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/polls/RespondToPollReqMsgHdlr.scala @@ -19,13 +19,15 @@ trait RespondToPollReqMsgHdlr { if (poll.stopped) { log.info("Ignoring vote from user {} because poll {} is already finished in meeting {}", msg.header.userId, msg.body.pollId, msg.header.meetingId) } else { - val answers = { - if (!poll.questions(0).multiResponse && msg.body.answerIds.length > 1) { - Seq(msg.body.answerIds.head) - } else { - msg.body.answerIds.distinct - } - } + val question = poll.questions.headOption + val answerList = question.flatMap(_.answers) + // Valid answer ids are the option indices of the poll's question. Any id + // outside this range is a malformed/out-of-range vote. + val validAnswerIds = answerList.map(_.indices).getOrElse(0 until 0) + + val answers = PollHdlrHelpers.selectValidAnswerIds( + msg.body.answerIds, question.exists(_.multiResponse), validAnswerIds + ) for { (pollId: String, updatedPoll: SimplePollResultOutVO) <- Polls.handleRespondToPollReqMsg(msg.header.userId, poll.id, @@ -34,8 +36,9 @@ trait RespondToPollReqMsgHdlr { PollHdlrHelpers.broadcastPollUpdatedEvent(bus.outGW, liveMeeting.props.meetingProp.intId, msg.header.userId, pollId, updatedPoll) for { answerId <- answers + options <- answerList } yield { - val answerText = poll.questions(0).answers.get(answerId).key + val answerText = options(answerId).key PollHdlrHelpers.broadcastUserRespondedToPollRecordMsg(bus.outGW, liveMeeting.props.meetingProp.intId, msg.header.userId, pollId, answerId, answerText, poll.isSecret) } diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/presentationpod/PresentationConversionCompletedSysPubMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/presentationpod/PresentationConversionCompletedSysPubMsgHdlr.scala index 3466f4186590..154b4ab20a46 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/presentationpod/PresentationConversionCompletedSysPubMsgHdlr.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/presentationpod/PresentationConversionCompletedSysPubMsgHdlr.scala @@ -8,6 +8,8 @@ import org.bigbluebutton.core.models.PresentationInPod import org.bigbluebutton.core.running.LiveMeeting import org.bigbluebutton.core2.message.senders.MsgBuilder +import java.io.File +import java.net.URI import java.time.{Instant, Duration} trait PresentationConversionCompletedSysPubMsgHdlr { @@ -58,6 +60,12 @@ trait PresentationConversionCompletedSysPubMsgHdlr { pods = pods.addPresentationToPod(pod.id, presWithConvertedName) PresPresentationDAO.updatePages(presWithConvertedName) + if (pres.downloadable) { + val originalFilename = new URI(null, null, pres.name, null).getRawPath + val originalFileURI = List("presentation", "download", meetingId, + s"${pres.id}?presFilename=${pres.id}.${originalDownloadableExtension}&filename=$originalFilename").mkString("", File.separator, "") + PresPresentationDAO.updateDownloadUri(pres.id, originalFileURI) + } if(pres.current) { val notifyEvent = MsgBuilder.buildNotifyAllInMeetingEvtMsg( meetingId, @@ -93,4 +101,3 @@ trait PresentationConversionCompletedSysPubMsgHdlr { } } - diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/SharedNotesRevDAO.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/SharedNotesRevDAO.scala index c383f9b76b5d..2d4770fdc60c 100644 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/SharedNotesRevDAO.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/db/SharedNotesRevDAO.scala @@ -49,7 +49,7 @@ object SharedNotesRevDAO { def insertNextRev(meetingId: String, sharedNotesExtId: String, userId: String) = { DatabaseConnection.enqueue( sqlu""" - insert into "sharedNotes_rev"("meetingId", "sharedNotesExtId", "userId", "rev") + insert into "sharedNotes_rev"("meetingId", "sharedNotesExtId", "userId", "rev", "createdAt") select ${meetingId} as "meetingId", ${sharedNotesExtId} as "sharedNotesExtId", @@ -58,7 +58,8 @@ object SharedNotesRevDAO { from "sharedNotes_rev" where "meetingId" = ${meetingId} and "sharedNotesExtId" = ${sharedNotesExtId} - ),0) + 1 as "rev" + ),0) + 1 as "rev", + current_timestamp as "createdAt" """ ) } diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/running/MeetingActor.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/running/MeetingActor.scala index 3097f23863eb..48d247089034 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/running/MeetingActor.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/running/MeetingActor.scala @@ -378,7 +378,8 @@ class MeetingActor( } else { PadslHdlrHelpers.broadcastBNSharedNotesCreateCmdMsg( outGW, liveMeeting.props.meetingProp.intId, - sharedNotesPadId, sharedNotesPadId, liveMeeting.props.meetingProp.sharedNotesInitialContentJson + sharedNotesPadId, sharedNotesPadId, liveMeeting.props.meetingProp.sharedNotesInitialContentJson, + liveMeeting.props.meetingProp.sharedNotesInitialContentMarkdown ) } } diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core2/AnalyticsActor.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core2/AnalyticsActor.scala index 5aa8207c507e..a9b09fc71abc 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core2/AnalyticsActor.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core2/AnalyticsActor.scala @@ -234,6 +234,7 @@ class AnalyticsActor(val includeChat: Boolean) extends Actor with ActorLogging { // Breakouts case m: CreateBreakoutRoomsCmdMsg => logMessage(msg) + case m: ChangeUserBreakoutReqMsg => logMessage(msg) case _ => // ignore message } diff --git a/akka-bbb-apps/src/test/scala/org/bigbluebutton/core/apps/polls/PollHdlrHelpersSpec.scala b/akka-bbb-apps/src/test/scala/org/bigbluebutton/core/apps/polls/PollHdlrHelpersSpec.scala new file mode 100644 index 000000000000..b27aaf99c3ed --- /dev/null +++ b/akka-bbb-apps/src/test/scala/org/bigbluebutton/core/apps/polls/PollHdlrHelpersSpec.scala @@ -0,0 +1,66 @@ +package org.bigbluebutton.core.apps.polls + +import org.scalatest.flatspec.AnyFlatSpec + +// Exercises the answer-id bounds filter that keeps a malformed poll vote from +// reaching the indexed answer access in the handler (which would crash the +// meeting actor) or the poll_response foreign key on persistence. The logic +// lives in the PollHdlrHelpers object so it can be tested in isolation, without +// standing up a LiveMeeting / MessageBus / poll fixtures. +// +// NOTE: extends AnyFlatSpec directly rather than the shared UnitSpec, which +// currently does not compile against the resolved ScalaTest 3.2.x (UnitSpec +// still imports the pre-3.2 org.scalatest.FlatSpec / Matchers packages). Same +// choice WhiteboardModelSpec made. +class PollHdlrHelpersSpec extends AnyFlatSpec { + + import PollHdlrHelpers.selectValidAnswerIds + + // A two-option question: valid indices are 0 and 1. + private val validAnswerIds = 0 until 2 + + it should "drop an id past the last option (999)" in { + assert(selectValidAnswerIds(Seq(999), multiResponse = false, validAnswerIds) == Seq.empty) + } + + it should "drop a negative id (-1)" in { + assert(selectValidAnswerIds(Seq(-1), multiResponse = false, validAnswerIds) == Seq.empty) + } + + it should "drop Int.MaxValue without an index-out-of-bounds crash" in { + assert(selectValidAnswerIds(Seq(2147483647), multiResponse = false, validAnswerIds) == Seq.empty) + } + + it should "keep a valid id (0)" in { + assert(selectValidAnswerIds(Seq(0), multiResponse = false, validAnswerIds) == Seq(0)) + } + + it should "return empty for an empty vote (no-op)" in { + assert(selectValidAnswerIds(Seq.empty, multiResponse = false, validAnswerIds) == Seq.empty) + } + + it should "keep every valid id when the question is multi-response" in { + assert(selectValidAnswerIds(Seq(0, 1), multiResponse = true, validAnswerIds) == Seq(0, 1)) + } + + it should "keep only the first id when the question is single-response" in { + assert(selectValidAnswerIds(Seq(0, 1), multiResponse = false, validAnswerIds) == Seq(0)) + } + + it should "drop a single-response vote whose first id is out of range" in { + // Single-response keeps only the head, then bounds-filters it: an + // out-of-range head leaves nothing, it does not fall through to a later id. + assert(selectValidAnswerIds(Seq(999, 0), multiResponse = false, validAnswerIds) == Seq.empty) + } + + it should "drop every id when the question has no answers (empty valid range)" in { + // Mirrors the handler's answers=None path: with no answer options the valid + // range is empty, so nothing survives the filter. + val noOptions = 0 until 0 + assert(selectValidAnswerIds(Seq(0, 1, 999), multiResponse = true, noOptions) == Seq.empty) + } + + it should "collapse duplicate valid ids on a multi-response vote" in { + assert(selectValidAnswerIds(Seq(1, 1, 0), multiResponse = true, validAnswerIds) == Seq(1, 0)) + } +} diff --git a/akka-bbb-fsesl/build.sbt b/akka-bbb-fsesl/build.sbt index d2cc454ec5a1..19075c5395ff 100755 --- a/akka-bbb-fsesl/build.sbt +++ b/akka-bbb-fsesl/build.sbt @@ -43,7 +43,7 @@ testOptions in Test += Tests.Argument(TestFrameworks.Specs2, "html", "console", testOptions in Test += Tests.Argument(TestFrameworks.ScalaTest, "-h", "target/scalatest-reports") Seq(Revolver.settings: _*) -lazy val bbbFseslAkka = (project in file(".")).settings(name := "bbb-fsesl-akka", libraryDependencies ++= Dependencies.runtime).settings(compileSettings) +lazy val bbbFseslAkka = (project in file(".")).settings(name := "bbb-fsesl-akka", libraryDependencies ++= Dependencies.runtime, dependencyOverrides ++= Dependencies.overrides).settings(compileSettings) // See https://github.com/scala-ide/scalariform // Config file is in ./.scalariform.conf diff --git a/akka-bbb-fsesl/project/Dependencies.scala b/akka-bbb-fsesl/project/Dependencies.scala index 53b57c6ba6b7..a09ce1b3b4bf 100755 --- a/akka-bbb-fsesl/project/Dependencies.scala +++ b/akka-bbb-fsesl/project/Dependencies.scala @@ -14,10 +14,13 @@ object Dependencies { // Libraries val pekkoVersion = "1.0.1" val pekkoHttpVersion = "1.0.0" - val logback = "1.2.13" + val logback = "1.5.38" + val jackson = "2.18.9" + val netty = "4.1.135.Final" + val slf4j = "2.0.17" // Apache Commons - val lang = "3.12.0" + val lang = "3.18.0" val codec = "1.15" // BigBlueButton @@ -83,4 +86,21 @@ object Dependencies { Compile.bbbFseslClient, Compile.pekkoHttp, Compile.pekkoHttpSprayJson) ++ testing + + // Pin transitively-pulled artifacts to fixed releases; keep the jackson suite aligned. + val overrides = Seq( + "com.fasterxml.jackson.core" % "jackson-databind" % Versions.jackson, + "com.fasterxml.jackson.core" % "jackson-core" % Versions.jackson, + "com.fasterxml.jackson.core" % "jackson-annotations" % Versions.jackson, + "com.fasterxml.jackson.dataformat" % "jackson-dataformat-yaml" % Versions.jackson, + "com.fasterxml.jackson.module" %% "jackson-module-scala" % Versions.jackson, + "io.netty" % "netty-handler" % Versions.netty, + "io.netty" % "netty-codec" % Versions.netty, + "io.netty" % "netty-common" % Versions.netty, + "io.netty" % "netty-buffer" % Versions.netty, + "io.netty" % "netty-transport" % Versions.netty, + "io.netty" % "netty-resolver" % Versions.netty, + // logback 1.5.x is an slf4j-2.x provider; pin slf4j-api 2.x so the + // ServiceLoader binding resolves (pekko-slf4j is runtime-compatible). + "org.slf4j" % "slf4j-api" % Versions.slf4j) } diff --git a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/domain/Meeting2x.scala b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/domain/Meeting2x.scala index 06927e5c9dcf..612b9c642106 100755 --- a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/domain/Meeting2x.scala +++ b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/domain/Meeting2x.scala @@ -14,6 +14,7 @@ case class MeetingProp( intId: String, meetingCameraCap: Int, sharedNotesInitialContentJson: Vector[AnyRef], + sharedNotesInitialContentMarkdown: String = "", sharedNotesEditor: String, maxPinnedCameras: Int, cameraBridge: String, diff --git a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/PadsMsgs.scala b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/PadsMsgs.scala index f432eeba661c..1fe23260d9ae 100644 --- a/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/PadsMsgs.scala +++ b/bbb-common-message/src/main/scala/org/bigbluebutton/common2/msgs/PadsMsgs.scala @@ -29,7 +29,7 @@ case class PadCreateReqMsgBody(externalId: String, name: String) // apps -> shared-notes-server object BNSharedNotesCreateCmdMsg { val NAME = "BNSharedNotesCreateCmdMsg" } case class BNSharedNotesCreateCmdMsg(header: BbbCoreHeaderWithMeetingId, body: BNSharedNotesCreateCmdMsgBody) extends BbbCoreMsg -case class BNSharedNotesCreateCmdMsgBody(externalId: String, model: String, initialContentJson: Vector[AnyRef]) +case class BNSharedNotesCreateCmdMsgBody(externalId: String, model: String, initialContentJson: Vector[AnyRef], initialContentMarkdown: String = "") // shared-notes-server -> apps object BNSharedNotesCreatedEvtMsg { val NAME = "BNSharedNotesCreatedEvtMsg" } diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/ApiParams.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/ApiParams.java index 2bcb3cb621c0..10a25378e4f2 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/ApiParams.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/ApiParams.java @@ -24,6 +24,8 @@ public class ApiParams { public static final String ALLOW_START_STOP_RECORDING = "allowStartStopRecording"; public static final String SHARED_NOTES_EDITOR = "sharedNotesEditor"; public static final String SHARED_NOTES_INITIAL_CONTENT_JSON_URL = "sharedNotesInitialContentJsonUrl"; + public static final String SHARED_NOTES_INITIAL_CONTENT_MARKDOWN = "sharedNotesInitialContentMarkdown"; + public static final String SHARED_NOTES_INITIAL_CONTENT_MARKDOWN_URL = "sharedNotesInitialContentMarkdownUrl"; public static final String ATTENDEE_PW = "attendeePW"; public static final String AUTO_START_RECORDING = "autoStartRecording"; public static final String BANNER_COLOR = "bannerColor"; diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java index e0a3500a1141..f7eb936c5217 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java @@ -22,7 +22,6 @@ import java.io.IOException; import java.net.MalformedURLException; import java.net.URI; -import java.net.URL; import java.util.*; import java.util.Map.Entry; import java.util.concurrent.*; @@ -51,6 +50,8 @@ import org.bigbluebutton.api.util.ParsedPluginManifest; import org.bigbluebutton.api.util.PluginUtils; import org.bigbluebutton.api.service.RedirectFollowerService; +import org.bigbluebutton.api.service.SecureUrlDownloader; +import org.bigbluebutton.api.service.ValidatedUrl; import org.bigbluebutton.api2.IBbbWebApiGWApp; import org.bigbluebutton.api2.domain.UploadedTrack; import org.bigbluebutton.common2.redis.RedisStorageService; @@ -68,7 +69,6 @@ import java.io.BufferedReader; import java.io.InputStreamReader; -import java.util.stream.Collectors; import org.springframework.data.domain.*; @@ -110,6 +110,8 @@ public class MeetingService implements MessageListener { private PresentationUrlDownloadService presDownloadService; private RedirectFollowerService redirectFollower; private SharedNotesRedirectValidatorService sharedNotesRedirectValidator; + private SecureUrlDownloader secureUrlDownloader; + private int maxSharedNotesInitialContentUrlPayloadSize; private IBbbWebApiGWApp gw; @@ -397,30 +399,35 @@ public ArrayList getSharedNotesInitialContent(Meeting m) { return initialContent; } - public ArrayList requestSharedNotesInitialContentFromUrl(String meetingId, String initialContentJsonUrl) { - ArrayList initialContent = new ArrayList<>(); - if (!initialContentJsonUrl.isEmpty()) { - try { - String finalInitialContentJsonUrl = redirectFollower.followRedirect( - meetingId, initialContentJsonUrl, 0, initialContentJsonUrl, sharedNotesRedirectValidator, 6000 - ); - - URL url = new URL(finalInitialContentJsonUrl); - String content; - try (BufferedReader in = new BufferedReader(new InputStreamReader(url.openStream()))) { - content = in.lines().collect(Collectors.joining("\n")); - } - initialContent = parseSharedNotesInitialContent(content); - } catch (MalformedURLException e) { - log.error( - "Malformed URL for sharedNotesInitialContentJsonUrl: [{}]", initialContentJsonUrl); - } catch (IOException e) { - log.error( - "Something went wrong while processing [{}]. Error: {}", initialContentJsonUrl, e.getMessage()); - } - } - return initialContent; - } + public ArrayList requestSharedNotesInitialContentFromUrl(String meetingId, String initialContentJsonUrl) { + if (initialContentJsonUrl.isEmpty()) { + return new ArrayList<>(); + } + String content = fetchUrlContent(meetingId, initialContentJsonUrl); + return parseSharedNotesInitialContent(content); + } + + /** + * Fetches the content at the given URL using the DNS-pinned, security-validated fetch path + * (protocol allowlist, blocked/local host rules, and rebinding protection). Returns an empty + * string when validation fails, the request errors, or the payload exceeds the configured cap. + */ + private String fetchUrlContent(String meetingId, String url) { + ValidatedUrl validatedUrl = redirectFollower.followRedirectSecure( + meetingId, url, 0, url, sharedNotesRedirectValidator, 6000 + ); + + if (validatedUrl == null) { + log.error("Failed to validate and resolve URL [{}] for meeting [{}]", url, meetingId); + return ""; + } + + String content = secureUrlDownloader.downloadToString( + meetingId, validatedUrl, 6000, maxSharedNotesInitialContentUrlPayloadSize + ); + + return content != null ? content : ""; + } public ArrayList parseSharedNotesInitialContent(String content) { ArrayList initialContent = null; @@ -437,6 +444,32 @@ public ArrayList parseSharedNotesInitialContent(String content) { return initialContent; } + public String getSharedNotesInitialContentMarkdown(Meeting m) { + String sharedNotesInitialContentMarkdownUrl = m.getSharedNotesInitialContentMarkdownUrl(); + + if (!sharedNotesInitialContentMarkdownUrl.isEmpty()) { + return requestSharedNotesInitialContentMarkdownFromUrl(m.getInternalId(), sharedNotesInitialContentMarkdownUrl); + } + + // Raw markdown can arrive either as a create param or, for larger content, in the POST + // body via the `sharedNotesInitialContentMarkdown` xml module. The create param wins when + // both are present; the payload is the fallback (mirrors sharedNotesInitialContentJson). + String markdownFromParam = m.getSharedNotesInitialContentMarkdown(); + if (markdownFromParam != null && !markdownFromParam.isEmpty()) { + return markdownFromParam; + } + + String markdownFromPayload = m.getSharedNotesInitialContentMarkdownFromPayload(); + return markdownFromPayload != null ? markdownFromPayload : ""; + } + + public String requestSharedNotesInitialContentMarkdownFromUrl(String meetingId, String initialContentMarkdownUrl) { + if (initialContentMarkdownUrl.isEmpty()) { + return ""; + } + return fetchUrlContent(meetingId, initialContentMarkdownUrl); + } + public Map requestPluginManifests(Meeting m) { Map pluginsResult = new ConcurrentHashMap<>(); Map metadata = m.getMetadata(); @@ -568,6 +601,7 @@ public synchronized boolean createMeeting(Meeting m, Map plugins m.setPlugins(pluginsMap); m.setSharedNotesInitialContentJson(sharedNotesInitialContentMap); + m.setSharedNotesInitialContentMarkdown(getSharedNotesInitialContentMarkdown(m)); handle(new CreateMeeting(m)); return true; } @@ -645,7 +679,7 @@ private void handleCreateMeeting(Meeting m) { gw.createMeeting(m.getInternalId(), m.getExternalId(), m.getParentMeetingId(), m.getName(), m.isRecord(), m.getTelVoice(), m.getDuration(), m.getAutoStartRecording(), m.getAllowStartStopRecording(), - m.getSharedNotesInitialContentJson(), m.getSharedNotesEditor(), m.getRecordFullDurationMedia(), + m.getSharedNotesInitialContentJson(), m.getSharedNotesInitialContentMarkdown(), m.getSharedNotesEditor(), m.getRecordFullDurationMedia(), m.getWebcamsOnlyForModerator(), m.getMultiUserWhiteboardEnabled(), m.getMeetingCameraCap(), m.getUserCameraCap(), m.getMaxPinnedCameras(), m.getCameraBridge(), m.getScreenShareBridge(), @@ -1672,5 +1706,13 @@ public void setRedirectFollower(RedirectFollowerService redirectFollower) { public void setSharedNotesRedirectValidator(SharedNotesRedirectValidatorService sharedNotesRedirectValidator) { this.sharedNotesRedirectValidator = sharedNotesRedirectValidator; } + + public void setSecureUrlDownloader(SecureUrlDownloader secureUrlDownloader) { + this.secureUrlDownloader = secureUrlDownloader; + } + + public void setMaxSharedNotesInitialContentUrlPayloadSize(int maxSharedNotesInitialContentUrlPayloadSize) { + this.maxSharedNotesInitialContentUrlPayloadSize = maxSharedNotesInitialContentUrlPayloadSize; + } } diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/ParamsProcessorUtil.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/ParamsProcessorUtil.java index 6510bf236954..f37907babe5d 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/ParamsProcessorUtil.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/ParamsProcessorUtil.java @@ -668,6 +668,18 @@ boolean record = processRecordMeeting(params.get(ApiParams.RECORD)); } } + String sharedNotesInitialContentMarkdown = ""; + if (!StringUtils.isEmpty(params.get(ApiParams.SHARED_NOTES_INITIAL_CONTENT_MARKDOWN))) { + sharedNotesInitialContentMarkdown = params + .get(ApiParams.SHARED_NOTES_INITIAL_CONTENT_MARKDOWN); + } + + String sharedNotesInitialContentMarkdownUrl = ""; + if (!StringUtils.isEmpty(params.get(ApiParams.SHARED_NOTES_INITIAL_CONTENT_MARKDOWN_URL))) { + sharedNotesInitialContentMarkdownUrl = params + .get(ApiParams.SHARED_NOTES_INITIAL_CONTENT_MARKDOWN_URL); + } + String sharedNotesEditor = defaultSharedNotesEditor; if (!StringUtils.isEmpty(params.get(ApiParams.SHARED_NOTES_EDITOR))) { try { @@ -1001,6 +1013,8 @@ boolean record = processRecordMeeting(params.get(ApiParams.RECORD)); .withAllowStartStopRecording(allowStartStoptRec) .withSharedNotesEditor(sharedNotesEditor) .withSharedNotesInitialContentJsonUrl(sharedNotesInitialContentJsonUrl) + .withSharedNotesInitialContentMarkdown(sharedNotesInitialContentMarkdown) + .withSharedNotesInitialContentMarkdownUrl(sharedNotesInitialContentMarkdownUrl) .withPresentationConversionCacheEnabled(presentationCacheEnabled) .withRecordFullDurationMedia(_recordFullDurationMedia) .withWebcamsOnlyForModerator(webcamsOnlyForMod) diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Meeting.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Meeting.java index 2a3c4393f410..96cbaea836d4 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Meeting.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/domain/Meeting.java @@ -72,6 +72,8 @@ public class Meeting { private String sharedNotesEditor = "etherpad"; private String sharedNotesInitialContentJsonUrl = ""; private ArrayList sharedNotesInitialContentJson; + private String sharedNotesInitialContentMarkdownUrl = ""; + private String sharedNotesInitialContentMarkdown = ""; private boolean presentationConversionCacheEnabled = false; private boolean recordFullDurationMedia = false; private boolean haveRecordingMarks = false; @@ -136,6 +138,7 @@ public class Meeting { private String meetingEndedCallbackURL = ""; private String sharedNotesInitialContentJsonFromPayload; + private String sharedNotesInitialContentMarkdownFromPayload = ""; private String overrideClientSettings = ""; @@ -177,6 +180,8 @@ record = builder.record; allowStartStopRecording = builder.allowStartStopRecording; sharedNotesEditor = builder.sharedNotesEditor; sharedNotesInitialContentJsonUrl = builder.sharedNotesInitialContentJsonUrl; + sharedNotesInitialContentMarkdownUrl = builder.sharedNotesInitialContentMarkdownUrl; + sharedNotesInitialContentMarkdown = builder.sharedNotesInitialContentMarkdown; presentationConversionCacheEnabled = builder.presentationConversionCacheEnabled; recordFullDurationMedia = builder.recordFullDurationMedia; webcamsOnlyForModerator = builder.webcamsOnlyForModerator; @@ -697,6 +702,18 @@ public void setSharedNotesInitialContentJson(ArrayList initialContentJso sharedNotesInitialContentJson = initialContentJson; } + public String getSharedNotesInitialContentMarkdownUrl() { + return sharedNotesInitialContentMarkdownUrl; + } + + public String getSharedNotesInitialContentMarkdown() { + return sharedNotesInitialContentMarkdown; + } + + public void setSharedNotesInitialContentMarkdown(String initialContentMarkdown) { + sharedNotesInitialContentMarkdown = initialContentMarkdown; + } + public boolean isPresentationConversionCacheEnabled() { return presentationConversionCacheEnabled; } @@ -1020,6 +1037,14 @@ public String getSharedNotesInitialContentJsonFromPayload() { public void setSharedNotesInitialContentJsonFromPayload(String sharedNotesInitialContentJsonFromPayload) { this.sharedNotesInitialContentJsonFromPayload = sharedNotesInitialContentJsonFromPayload; } + + public String getSharedNotesInitialContentMarkdownFromPayload() { + return sharedNotesInitialContentMarkdownFromPayload; + } + + public void setSharedNotesInitialContentMarkdownFromPayload(String sharedNotesInitialContentMarkdownFromPayload) { + this.sharedNotesInitialContentMarkdownFromPayload = sharedNotesInitialContentMarkdownFromPayload; + } /*** * Meeting Builder @@ -1036,6 +1061,8 @@ public static class Builder { private boolean allowStartStopRecording; private String sharedNotesEditor; private String sharedNotesInitialContentJsonUrl; + private String sharedNotesInitialContentMarkdownUrl; + private String sharedNotesInitialContentMarkdown; private boolean presentationConversionCacheEnabled; private boolean webcamsOnlyForModerator; private boolean multiUserWhiteboardEnabled; @@ -1133,6 +1160,16 @@ public Builder withSharedNotesInitialContentJsonUrl(String initialContent) { return this; } + public Builder withSharedNotesInitialContentMarkdownUrl(String initialContent) { + this.sharedNotesInitialContentMarkdownUrl = initialContent; + return this; + } + + public Builder withSharedNotesInitialContentMarkdown(String initialContent) { + this.sharedNotesInitialContentMarkdown = initialContent; + return this; + } + public Builder withPresentationConversionCacheEnabled(boolean cacheEnabled) { this.presentationConversionCacheEnabled = cacheEnabled; return this; diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/model/request/MeetingRunning.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/model/request/MeetingRunning.java index 6b3bd6d42191..db5b8f8f1fc3 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/model/request/MeetingRunning.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/model/request/MeetingRunning.java @@ -2,6 +2,7 @@ import org.bigbluebutton.api.model.constraint.ContentTypeConstraint; import org.bigbluebutton.api.model.constraint.MeetingIDConstraint; +import org.bigbluebutton.api.model.constraint.NotNull; import org.bigbluebutton.api.model.shared.Checksum; import jakarta.servlet.http.HttpServletRequest; @@ -20,6 +21,7 @@ public enum Params implements RequestParameters { public String getValue() { return value; } } + @NotNull(key = "missingParamMeetingID", message = "You must provide a meeting ID") @MeetingIDConstraint private String meetingID; diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/service/RedirectFollowerService.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/service/RedirectFollowerService.java index a656f3cb1d1d..269e8ce242b4 100644 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/service/RedirectFollowerService.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/service/RedirectFollowerService.java @@ -12,7 +12,6 @@ import org.apache.http.impl.client.HttpClients; import java.io.IOException; -import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URL; import java.net.UnknownHostException; @@ -21,62 +20,6 @@ public class RedirectFollowerService { private static final Logger log = LoggerFactory.getLogger(RedirectFollowerService.class); private static final int MAX_REDIRECTS = 5; - public String followRedirect( - String meetingId, String redirectUrl, - int redirectCount, String origUrl, - RedirectValidator redirectValidator, - int downloadReadTimeoutInMs - ) { - if (redirectCount > MAX_REDIRECTS) { - log.error("Max redirect reached for meeting=[{}] with url=[{}]", - meetingId, origUrl); - return null; - } - - if(!redirectValidator.isRedirectValid(redirectUrl)) return null; - - URL attemptUrl; - try { - attemptUrl = new URL(redirectUrl); - } catch (MalformedURLException e) { - log.error("Malformed url=[{}] for meeting=[{}]", redirectUrl, meetingId, e); - return null; - } - - HttpURLConnection conn; - try { - conn = (HttpURLConnection) attemptUrl.openConnection(); - conn.setReadTimeout(downloadReadTimeoutInMs); - conn.addRequestProperty("Accept-Language", "en-US,en;q=0.8"); - conn.addRequestProperty("User-Agent", "Mozilla"); - conn.setInstanceFollowRedirects(false); - - // normally, 3xx is redirect - int status = conn.getResponseCode(); - if (status != HttpURLConnection.HTTP_OK) { - if (status == HttpURLConnection.HTTP_MOVED_TEMP - || status == HttpURLConnection.HTTP_MOVED_PERM - || status == HttpURLConnection.HTTP_SEE_OTHER) { - String newUrl = conn.getHeaderField("Location"); - return followRedirect( - meetingId, newUrl, redirectCount + 1, - origUrl, redirectValidator, downloadReadTimeoutInMs - ); - } else { - log.error( - "Invalid HTTP response=[{}] for url=[{}] with meeting[{}]", - status, redirectUrl, meetingId); - return null; - } - } else { - return redirectUrl; - } - } catch (IOException e) { - log.error("IOException for url=[{}] with meeting[{}]", redirectUrl, meetingId, e); - return null; - } - } - public ValidatedUrl followRedirectSecure( String meetingId, String redirectUrl, int redirectCount, String origUrl, diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/service/ServiceUtils.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/service/ServiceUtils.java index 0725e260e7d0..5bd7832a3efd 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/service/ServiceUtils.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/service/ServiceUtils.java @@ -20,6 +20,10 @@ public class ServiceUtils { public static ValidationService getValidationService() { return validationService; } public static Meeting findMeetingFromMeetingID(String meetingID) { + if (meetingID == null || meetingID.isEmpty()) { + return null; + } + log.info("Attempting to find meeting with ID {}", meetingID); Meeting meeting = meetingService.getMeeting(meetingID); diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/service/impl/SharedNotesRedirectValidatorService.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/service/impl/SharedNotesRedirectValidatorService.java index 6797a83fb9d6..fd88d87e35d8 100644 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/service/impl/SharedNotesRedirectValidatorService.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/service/impl/SharedNotesRedirectValidatorService.java @@ -1,43 +1,13 @@ package org.bigbluebutton.api.service.impl; -import org.apache.commons.validator.routines.InetAddressValidator; -import org.bigbluebutton.api.service.RedirectValidator; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.net.InetAddress; -import java.net.MalformedURLException; -import java.net.URL; -import java.net.UnknownHostException; - -public class SharedNotesRedirectValidatorService implements RedirectValidator { - private static final Logger log = LoggerFactory.getLogger(SharedNotesRedirectValidatorService.class); - public boolean isRedirectValid(String redirectUrl) { - log.info("Validating redirect URL [{}]", redirectUrl); - URL url; - - try { - url = new URL(redirectUrl); - } catch(MalformedURLException e) { - log.error("Malformed URL [{}]", redirectUrl); - return false; - } - - try { - InetAddress[] addresses = InetAddress.getAllByName(url.getHost()); - InetAddressValidator validator = InetAddressValidator.getInstance(); - - for(InetAddress address: addresses) { - if(!validator.isValid(address.getHostAddress())) { - log.error("Invalid address [{}]", address.getHostAddress()); - return false; - } - } - } catch(UnknownHostException e) { - log.error("Unknown host [{}]", url.getHost()); - return false; - } - - return true; - } +/** + * Redirect validator for shared notes initial-content URLs + * (sharedNotesInitialContentJsonUrl, sharedNotesInitialContentMarkdownUrl). + * + * Extends {@link BaseUrlRedirectValidator} with the full set of security + * checks (protocol allowlist, blocked hosts, allowed local hosts, DNS + * rebinding protection via IP address resolution and validation). + */ +public class SharedNotesRedirectValidatorService extends BaseUrlRedirectValidator { + // No additional behaviour needed beyond what BaseUrlRedirectValidator provides. } diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api2/IBbbWebApiGWApp.java b/bbb-common-web/src/main/java/org/bigbluebutton/api2/IBbbWebApiGWApp.java index 05d9ca81af84..3e1dc7e73453 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api2/IBbbWebApiGWApp.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api2/IBbbWebApiGWApp.java @@ -21,6 +21,7 @@ void createMeeting(String meetingID, String externalMeetingID, String voiceBridge, Integer duration, Boolean autoStartRecording, Boolean allowStartStopRecording, ArrayList sharedNotesInitialContentJson, + String sharedNotesInitialContentMarkdown, String sharedNotesEditor, Boolean recordFullDurationMedia, Boolean webcamsOnlyForModerator, diff --git a/bbb-common-web/src/main/scala/org/bigbluebutton/api2/BbbWebApiGWApp.scala b/bbb-common-web/src/main/scala/org/bigbluebutton/api2/BbbWebApiGWApp.scala index 89b6346da37b..0651cb532cbe 100755 --- a/bbb-common-web/src/main/scala/org/bigbluebutton/api2/BbbWebApiGWApp.scala +++ b/bbb-common-web/src/main/scala/org/bigbluebutton/api2/BbbWebApiGWApp.scala @@ -127,6 +127,7 @@ class BbbWebApiGWApp( autoStartRecording: java.lang.Boolean, allowStartStopRecording: java.lang.Boolean, sharedNotesInitialContentJson: java.util.ArrayList[AnyRef], + sharedNotesInitialContentMarkdown: java.lang.String, sharedNotesEditor: java.lang.String, recordFullDurationMedia: java.lang.Boolean, webcamsOnlyForModerator: java.lang.Boolean, @@ -189,6 +190,7 @@ class BbbWebApiGWApp( intId = meetingId, meetingCameraCap = meetingCameraCap.intValue(), sharedNotesInitialContentJson = sharedNotesInitialContentJsonVector, + sharedNotesInitialContentMarkdown = sharedNotesInitialContentMarkdown, sharedNotesEditor = sharedNotesEditor, maxPinnedCameras = maxPinnedCameras.intValue(), cameraBridge, diff --git a/bbb-export-annotations/lib/utils/slide-background.js b/bbb-export-annotations/lib/utils/slide-background.js new file mode 100644 index 000000000000..24bf9056c236 --- /dev/null +++ b/bbb-export-annotations/lib/utils/slide-background.js @@ -0,0 +1,122 @@ +import cp from 'child_process'; +import fs from 'fs'; + +/** + * Ensure a slide background SVG carries a viewBox. + * + * A slide whose root `` declares a width/height but no viewBox has no + * intrinsic mapping from its user coordinates to that box, so when it is + * rasterized (or referenced) it renders into the top-left corner and leaves the + * rest blank. Deriving a viewBox from the declared width/height restores that + * mapping so the slide content fills the whole slide. See issue #25303 and the + * regression guarded by PR #25315. + * + * The file is patched in place. A missing width/height, an already present + * viewBox, or an unreadable file is a no-op (nothing to derive from). + * + * @param {string} file Path of the slide SVG. + */ +export function ensureSlideViewBox(file) { + const svg = fs.readFileSync(file, {encoding: 'utf-8'}); + const svgTag = svg.match(/]*>/)?.[0]; + + if (!svgTag || /viewBox=/.test(svgTag)) return; + + const width = svgTag.match(/(?` embedded in + * a slide SVG. Returns 0 when the slide has no embedded raster (pure vector) or + * cannot be read. + * @param {string} file Path of the slide SVG. + * @return {number} Largest embedded raster width, or 0. + */ +export function largestEmbeddedRasterWidth(file) { + try { + const svg = fs.readFileSync(file, {encoding: 'utf-8'}); + let maxWidth = 0; + const imageTag = /]*>/gi; + const widthAttr = /(? maxWidth) maxWidth = width; + } + return maxWidth; + } catch (error) { + return 0; + } +} + +/** + * Rasterize a background slide SVG to a PNG so it composites without cropping. + * + * The background slide is later embedded in the annotated SVG as an `` + * sized to the slide canvas. CairoSVG only rescales a *referenced* SVG to that + * box when it is resolution-independent; slides carrying absolute units (e.g. + * `width="720pt"`) keep their intrinsic size and render cropped into the + * top-left corner even when they have a viewBox (issue #25303, the case PR + * #25315 could not reach). A raster image always scales to fill the `` + * box, so rasterizing the slide first sidesteps that. The slide's viewBox is + * ensured first so that slides missing one still fill the raster. + * + * The rasterization itself must avoid a second CairoSVG (< 2.7) quirk: when it + * *downscales* an embedded raster below its native pixel size it crops the + * slide the same way. Office-document slides embed a single high-resolution + * image (e.g. a 2048px picture in a 720pt viewBox), so the final resolution + * `toPx(slideWidth)` can fall well below it. We therefore render at least as + * large as the biggest embedded raster; the composite scales the PNG down + * cleanly afterwards, keeping the background sharp. + * + * @param {string} svgPath Path of the slide background SVG. + * @param {string} pngPath Destination path for the rasterized PNG. + * @param {Object} options + * @param {number} options.width Target output width in pixels (final render). + * @param {number} options.height Target output height in pixels. + * @param {string} options.cairosvg Path to the CairoSVG executable. + * @param {boolean} [options.unsafe=false] Pass CairoSVG's `-u` flag, needed + * from CairoSVG 2.7.0 onwards to allow loading external resources. + * @return {string} Path of the rasterized PNG. + * @throws {Error} If CairoSVG cannot be spawned or exits non-zero. + */ +export function rasterizeSlideBackground(svgPath, pngPath, { + width, height, cairosvg, unsafe = false, +}) { + ensureSlideViewBox(svgPath); + + // Never render below the largest embedded raster's native width, otherwise + // CairoSVG crops the slide (issue #25303). Keep the slide's aspect ratio. + let renderWidth = width; + let renderHeight = height; + const maxRasterWidth = largestEmbeddedRasterWidth(svgPath); + if (maxRasterWidth > renderWidth) { + renderWidth = maxRasterWidth; + renderHeight = Math.round(maxRasterWidth * height / width); + } + + const args = [ + svgPath, + '--output-width', renderWidth, + '--output-height', renderHeight, + ...(unsafe ? ['-u'] : []), + '-o', pngPath, + ]; + + const result = cp.spawnSync(cairosvg, args, {shell: false}); + + if (result.error) throw result.error; + + if (result.status !== 0) { + const stderr = result.stderr?.toString().trim(); + throw new Error(`CairoSVG exited with status ${result.status}: ${stderr}`); + } + + return pngPath; +} diff --git a/bbb-export-annotations/workers/process.js b/bbb-export-annotations/workers/process.js index 0854afec53d9..949dcd9c6664 100644 --- a/bbb-export-annotations/workers/process.js +++ b/bbb-export-annotations/workers/process.js @@ -4,6 +4,7 @@ import {createSVGWindow} from 'svgdom'; import {SVG as svgCanvas, registerWindow} from '@svgdotjs/svg.js'; import cp from 'child_process'; import WorkerStarter from '../lib/utils/worker-starter.js'; +import {rasterizeSlideBackground} from '../lib/utils/slide-background.js'; import {workerData} from 'worker_threads'; import path from 'path'; import sanitize from 'sanitize-filename'; @@ -416,14 +417,40 @@ async function processPresentationAnnotations() { 'xmlns:xlink': 'http://www.w3.org/1999/xlink', }); - const backgroundFile = path.join(dropbox, - `slide${currentSlide.page}.${backgroundFormat}`); - const backgroundDataURI = getBackgroundDataURI(backgroundFile, - backgroundFormat); + // The background slide is composited into the annotated SVG as an + // sized to the canvas. When the background is itself an SVG, CairoSVG only + // rescales it to that box if it is resolution-independent; slides carrying + // absolute units (e.g. width="720pt") keep their intrinsic size and render + // cropped into the top-left corner (issue #25303). Rasterizing the slide to + // a PNG first sidesteps this: a raster image always scales to the + // box. The helper ensures the slide has a viewBox (so slides missing one + // still fill the raster) and renders at the same resolution as the final + // SVG->PDF pass (toPx of the slide dims) so the background stays sharp. + let backgroundSlide = `${bgImagePath}.${backgroundFormat}`; + + if (backgroundFormat === 'svg') { + try { + // Rasterize the same SVG we validated above (svgBackgroundSlide), not + // the dropbox copy, so it is clear which file feeds the raster. + backgroundSlide = rasterizeSlideBackground( + svgBackgroundSlide, + `${bgImagePath}-bg.png`, + { + width: toPx(slideWidth), + height: toPx(slideHeight), + cairosvg: config.shared.cairosvg, + unsafe: config.process.cairoSVGUnsafeFlag, + }); + } catch (error) { + logger.error(`Rasterizing slide ${currentSlide.page} ` + + `failed for job ${jobId}: ${error.message}`); + statusUpdate.setError(); + } + } // Add the image element canvas - .image(backgroundDataURI) + .image(`file://${backgroundSlide}`) .size(scaledWidth, scaledHeight); // Add a group element with class 'whiteboard' diff --git a/bbb-graphql-actions/src/actions/pollSubmitUserVote.ts b/bbb-graphql-actions/src/actions/pollSubmitUserVote.ts index 077f70abe212..ac234092037a 100644 --- a/bbb-graphql-actions/src/actions/pollSubmitUserVote.ts +++ b/bbb-graphql-actions/src/actions/pollSubmitUserVote.ts @@ -16,6 +16,10 @@ export default function buildRedisMessage(sessionVariables: Record !Number.isInteger(id) || id < 0)) { + throw new ValidationError('Parameter answerIds must contain only non-negative integers', 400); + } + const routing = { meetingId: sessionVariables['x-hasura-meetingid'] as String, userId: sessionVariables['x-hasura-userid'] as String diff --git a/bbb-graphql-server/bbb_schema.sql b/bbb-graphql-server/bbb_schema.sql index 66e29fd41571..7dde66c685ac 100644 --- a/bbb-graphql-server/bbb_schema.sql +++ b/bbb-graphql-server/bbb_schema.sql @@ -1477,7 +1477,10 @@ SELECT "user"."meetingId", CASE WHEN "chat"."access" = 'PUBLIC_ACCESS' THEN true ELSE false end "public", "chat"."pinnedMessageId", "chat"."pinnedByUserId", - "chat"."pinnedAt" + "chat"."pinnedAt", + last_msg."lastMessage", + last_msg."lastMessageAt", + deleted_by."name" AS "lastMessageDeletedByName" FROM "user" JOIN "chat_user" cu ON cu."meetingId" = "user"."meetingId" AND cu."userId" = "user"."userId" --now it will always add chat_user for public chat onUserJoin @@ -1487,6 +1490,24 @@ LEFT JOIN "chat_user" chat_with ON chat_with."meetingId" = chat."meetingId" AND chat_with."chatId" = chat."chatId" AND chat_with."userId" != cu."userId" AND chat_with."chatId" != 'MAIN-PUBLIC-GROUP-CHAT' +--last message preview for the chats list (issue 25416): resolved here so the private +--chat list renders the preview from the chats subscription itself, instead of gating it +--behind a per-item subscription that mounts the row a moment later and shifts the list. +--Index Scan Backward on idx_v_chat_message_unread ("meetingId","chatId","createdAt") -> rows=1. +LEFT JOIN LATERAL ( + SELECT cm."message" AS "lastMessage", + cm."createdAt" AS "lastMessageAt", + cm."deletedByUserId" AS "lastMessageDeletedByUserId" + FROM "chat_message" cm + WHERE cm."meetingId" = "user"."meetingId" + AND cm."chatId" = cu."chatId" + ORDER BY cm."createdAt" DESC + LIMIT 1 +) last_msg ON true +--resolve the deleter's display name so a soft-deleted last message (message=NULL, +--deletedByUserId set) can reuse the existing "deleted by {userName}" preview label. +LEFT JOIN "user" deleted_by ON deleted_by."meetingId" = "user"."meetingId" + AND deleted_by."userId" = last_msg."lastMessageDeletedByUserId" WHERE cu."visible" is true; CREATE INDEX "idx_v_chat_with" on chat_user("meetingId","chatId","userId") WHERE "chatId" != 'MAIN-PUBLIC-GROUP-CHAT'; diff --git a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_chat.yaml b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_chat.yaml index 4727c3912b85..d100a9a5d980 100644 --- a/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_chat.yaml +++ b/bbb-graphql-server/metadata/databases/BigBlueButton/tables/public_v_chat.yaml @@ -41,6 +41,9 @@ select_permissions: - pinnedMessageId - pinnedByUserId - pinnedAt + - lastMessage + - lastMessageAt + - lastMessageDeletedByName filter: _and: - meetingId: diff --git a/bbb-graphql-server/metadata/databases/databases.yaml b/bbb-graphql-server/metadata/databases/databases.yaml index 68c9a1563eec..ead496340332 100644 --- a/bbb-graphql-server/metadata/databases/databases.yaml +++ b/bbb-graphql-server/metadata/databases/databases.yaml @@ -19,7 +19,7 @@ database_url: postgres://bbb_hasura:bbb_hasura@127.0.0.1:5432/bbb_graphql isolation_level: read-committed pool_settings: - connection_lifetime: 3600 + connection_lifetime: 300 max_connections: 100 use_prepared_statements: true tables: "!include BigBlueButton/tables/tables.yaml" diff --git a/bbb-graphql-server/metadata/query_collections.yaml b/bbb-graphql-server/metadata/query_collections.yaml index 4f5be5a6be6f..0ef200eec8ba 100644 --- a/bbb-graphql-server/metadata/query_collections.yaml +++ b/bbb-graphql-server/metadata/query_collections.yaml @@ -45,6 +45,7 @@ allowModsToEjectCameras allowModsToUnmuteUsers authenticatedGuest + allowPromoteGuestToModerator maxUserConcurrentAccesses maxUsers meetingLayout diff --git a/bbb-shared-notes-server/src/redis/handler.ts b/bbb-shared-notes-server/src/redis/handler.ts index 7e7e35033780..97e7a18ecdb1 100644 --- a/bbb-shared-notes-server/src/redis/handler.ts +++ b/bbb-shared-notes-server/src/redis/handler.ts @@ -104,17 +104,19 @@ const handleSharedNotesCreate = async (header: MessageHeader, body: MessageBody) externalId, model, initialContentJson, + initialContentMarkdown, } = body; const padId = `${documentNamePrefix}${meetingId}`; - const validateInitialContentNotEmpty = (): boolean => { - return initialContentJson !== undefined - && initialContentJson !== null - && typeof initialContentJson === "object" - && Object.keys(initialContentJson).length > 0 - } - if (validateInitialContentNotEmpty()) { + const hasInitialContentJson = initialContentJson !== undefined + && initialContentJson !== null + && typeof initialContentJson === "object" + && Object.keys(initialContentJson).length > 0; + const hasInitialContentMarkdown = typeof initialContentMarkdown === "string" + && initialContentMarkdown.length > 0; + + if (hasInitialContentMarkdown || hasInitialContentJson) { logger.debug( 'Received initial content', { padId, @@ -122,7 +124,10 @@ const handleSharedNotesCreate = async (header: MessageHeader, body: MessageBody) } ); try { - const statusReturn = await pushInitialContent(padId, initialContentJson); + const statusReturn = await pushInitialContent(padId, { + initialContentJson: hasInitialContentJson ? initialContentJson : undefined, + initialContentMarkdown: hasInitialContentMarkdown ? initialContentMarkdown : undefined, + }); if (statusReturn.error) { logger.error('Error found, see details', { logCode: statusReturn.statusCode, diff --git a/bbb-shared-notes-server/src/redis/service/pushInitialContent.ts b/bbb-shared-notes-server/src/redis/service/pushInitialContent.ts index f05d2886992d..905ab30ad491 100644 --- a/bbb-shared-notes-server/src/redis/service/pushInitialContent.ts +++ b/bbb-shared-notes-server/src/redis/service/pushInitialContent.ts @@ -1,13 +1,89 @@ import { ServerBlockNoteEditor } from "@blocknote/server-util"; +import * as Y from "yjs"; import hocuspocus from "../../hocuspocus"; import { Logger } from "../../common/logger"; const logger = new Logger('redis.service.pushInitialContent'); -export async function pushInitialContent(padId: string, initialContentJson: any): Promise<{ statusCode: string; error?: string; }> { +// Schema generics are left open (as the raw JSON blocks already are): this tier only +// forwards blocks into the Yjs fragment, it does not depend on the concrete schema. +type Editor = ServerBlockNoteEditor; + +interface InitialContent { + // BlockNote blocks already parsed on the web tier (sharedNotesInitialContentJson). + initialContentJson?: any; + // Raw markdown carried untouched from the create API (sharedNotesInitialContentMarkdown); + // BlockNote only exists here, so the markdown -> blocks conversion happens on this tier. + initialContentMarkdown?: string; +} + +// Seed the fragment from JSON blocks already parsed on the web tier. Returns a +// result when the blocks are present and convert cleanly; returns null when there +// are no usable blocks or the conversion fails, so the caller falls back to +// markdown. Any partial write is rolled back before falling back. +function seedFromJson( + editor: Editor, + jsonBlocks: any, + fragment: Y.XmlFragment, + documentName: string, +): { statusCode: string } | null { + const hasJsonBlocks = Array.isArray(jsonBlocks) && jsonBlocks.length > 0; + if (!hasJsonBlocks) { + return null; + } + + try { + editor.blocksToYXmlFragment(jsonBlocks, fragment); + logger.info('Document seeded from initial JSON content', { documentName }); + return { statusCode: "document_loaded" }; + } catch (jsonError) { + // Do not crash on malformed JSON blocks; drop anything partially written and + // let the caller fall back to the markdown content. + logger.warn('Failed to seed document from JSON, falling back to markdown', { + documentName, + error: jsonError instanceof Error ? jsonError.message : String(jsonError), + }); + if (fragment.length > 0) fragment.delete(0, fragment.length); + return null; + } +} + +// Seed the fragment from raw markdown. Markdown must be parsed to blocks here +// because BlockNote is unavailable on the web tier that builds the message. +// Returns a result when the markdown converts to usable blocks; returns null when +// it produces no blocks or the conversion fails, so the caller falls back to +// no_initial_content. Any partial write is rolled back before falling back. +async function seedFromMarkdown( + editor: Editor, + markdown: string, + fragment: Y.XmlFragment, + documentName: string, +): Promise<{ statusCode: string } | null> { + try { + const markdownBlocks = await editor.tryParseMarkdownToBlocks(markdown); + if (!Array.isArray(markdownBlocks) || markdownBlocks.length === 0) { + logger.warn('Markdown parsing produced no usable blocks', { documentName }); + return null; + } + + editor.blocksToYXmlFragment(markdownBlocks, fragment); + logger.info('Document seeded from initial markdown content', { documentName }); + return { statusCode: "document_loaded" }; + } catch (markdownError) { + // Do not crash on malformed markdown; drop anything partially written and + // let the caller fall back to no_initial_content. + logger.warn('Failed to seed document from markdown', { + documentName, + error: markdownError instanceof Error ? markdownError.message : String(markdownError), + }); + if (fragment.length > 0) fragment.delete(0, fragment.length); + return null; + } +} + +export async function pushInitialContent(padId: string, content: InitialContent): Promise<{ statusCode: string; error?: string; }> { const documentName = padId; - const initialBlocks = initialContentJson; let connection: Awaited> | null = null; try { connection = await hocuspocus.openDirectConnection(documentName); @@ -34,12 +110,26 @@ export async function pushInitialContent(padId: string, initialContentJson: any) // Create a ServerBlockNoteEditor instance const editor = ServerBlockNoteEditor.create(); - // Convert blocks to Yjs XML Fragment directly in our document's fragment - editor.blocksToYXmlFragment(initialBlocks, fragment); + // JSON (already parsed on the web tier) takes precedence. The raw markdown is only a + // fallback, used when JSON is absent, empty, or fails to convert to a valid document. + // Markdown must be parsed to blocks here because BlockNote is unavailable on the web + // tier that builds the message. + const jsonResult = seedFromJson(editor, content.initialContentJson, fragment, documentName); + if (jsonResult) { + return jsonResult; + } + + if (content.initialContentMarkdown) { + const markdownResult = await seedFromMarkdown(editor, content.initialContentMarkdown, fragment, documentName); + if (markdownResult) { + return markdownResult; + } + } - logger.info('Document created/loaded successfully', { documentName }); + logger.warn('No usable initial content to seed document', { documentName }); return { - statusCode: "document_loaded", + statusCode: "no_initial_content", + error: 'No usable initial content (JSON empty/invalid and no markdown fallback)', } } catch (error) { logger.error('Error creating document', { error, documentName }); @@ -50,4 +140,4 @@ export async function pushInitialContent(padId: string, initialContentJson: any) } finally { if (connection) await connection.disconnect(); } -} \ No newline at end of file +} diff --git a/bigbluebutton-html5/imports/startup/client/intlLoader.tsx b/bigbluebutton-html5/imports/startup/client/intlLoader.tsx index 1a897342e846..e7c49ede83db 100644 --- a/bigbluebutton-html5/imports/startup/client/intlLoader.tsx +++ b/bigbluebutton-html5/imports/startup/client/intlLoader.tsx @@ -1,13 +1,37 @@ -import React, { useCallback, useContext, useEffect } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { IntlProvider } from 'react-intl'; -import { LoadingContext } from '/imports/ui/components/common/loading-screen/loading-screen-HOC/component'; +import LoadingScreen from '/imports/ui/components/common/loading-screen/component'; +import { ErrorScreen } from '/imports/ui/components/error-screen/component'; import useCurrentLocale from '/imports/ui/core/local-states/useCurrentLocale'; import logger from './logger'; +// Backoff between locale fetch retries: 1s, 2s, then 5s repeated, each with equal +// jitter (the effective delay is uniformly random in [base/2, base]) to avoid a +// thundering herd of clients retrying in lockstep after a shared network blip. A +// transient failure (a network rejection such as ERR_NETWORK_CHANGED, or a 5xx/429 +// response) keeps retrying until it succeeds, the component unmounts (the fetch is +// aborted), or the shared deadline passes. On give-up the client shows an error +// screen instead of a blank one: the client must never be left on a blank screen +// because a transient network blip dropped the locale. +const RETRY_DELAYS = [1000, 2000, 5000]; +// Mirrors CustomUsersSettings' CONNECTION_TIMEOUT (the parent in client/main.tsx): +// stop retrying after this long and give up so the loading state resolves into an +// error screen instead of spinning forever. The deadline is shared across the +// index fetch and every language-set fetch of a single load (threaded from +// fetchLocalizedMessages), so the whole load is bounded by one budget rather than +// each fetch getting its own. +const MAX_RETRY_DURATION = 60000; + interface LocaleJson { [key: string]: string; } +// The locales index is an array of available locale entries; only `name` is used +// to build the usable-locale list, so that is all we type here. +interface LocaleIndexEntry { + name: string; +} + interface IntlLoaderContainerProps { children: React.ReactNode; } @@ -17,24 +41,123 @@ interface IntlLoaderProps extends IntlLoaderContainerProps { setCurrentLocale: (locale: string) => void; } -const buildFetchLocale = (locale: string) => { +// Waits `ms` and resolves to true, or resolves early to false if the signal +// aborts (already aborted at call time, or aborts while waiting). The abort +// listener is always detached: the setTimeout callback removes it on normal +// completion, and the once:true listener removes itself when it fires. This +// keeps listeners from piling up across retries on a long-lived signal. +const waitForDelay = (ms: number, signal: AbortSignal): Promise => new Promise((resolve) => { + if (signal.aborted) { + resolve(false); + return; + } + const onAbort = () => { + clearTimeout(timer); + resolve(false); + }; + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(true); + }, ms); + signal.addEventListener('abort', onAbort, { once: true }); +}); + +const buildFetchLocale = ( + locale: string, + signal: AbortSignal, + deadline: number, +): Promise => { const clientVersion = window.meetingClientSettings.public.app.html5ClientBuild; - const localesPath = 'locales'; + const url = `locales/${locale !== 'index' ? `${locale}.json?v=${clientVersion}` : ''}`; - return new Promise((resolve) => { - fetch(`${localesPath}/${locale !== 'index' ? `${locale}.json?v=${clientVersion}` : ''}`) - .then((response) => { - if (!response.ok) { - return resolve(false); + const attempt = async (retryCount: number): Promise => { + // Per-attempt controller: composes the outer unmount signal with a timeout so a + // hung fetch (server accepts the connection but never responds) still aborts at + // the shared deadline instead of stranding the client on the loading screen. + // Aborting attemptController (not the outer signal) keeps the outer/unmount + // abort distinguishable from the deadline timeout in the catch below. We do not + // use AbortSignal.any(): Safari 16 support is required (see index.html). + const attemptController = new AbortController(); + const onOuterAbort = () => attemptController.abort(); + signal.addEventListener('abort', onOuterAbort, { once: true }); + // A once-listener never fires for an already-dispatched abort, so if the outer + // signal aborted before this attempt began, mirror it onto attemptController now. + if (signal.aborted) attemptController.abort(); + const timeoutId = setTimeout(() => attemptController.abort(), Math.max(0, deadline - Date.now())); + try { + const response = await fetch(url, { signal: attemptController.signal }); + if (!response.ok) { + // 5xx and 429 are transient (server overloaded / rate limited): fall + // through to the retry path by throwing so the catch handles the backoff. + if (response.status >= 500 || response.status === 429) { + throw new Error(`HTTP ${response.status}`); } - return response.json() - .then((jsonResponse) => resolve(jsonResponse)) - .catch(() => { - logger.error({ logCode: 'intl_parse_locale_SyntaxError' }, `Could not parse locale file ${locale}.json, invalid json`); - resolve(false); - }); - }); - }); + // Any other HTTP error (e.g. 404 for a locale that does not exist) is a + // legitimate fallback, not a transient failure: resolve false so the merge + // step falls back to another locale. Retrying would never help. + return false; + } + return await response.json() as T; + } catch (error) { + // The component unmounted mid-flight: stop, do not retry. + if (signal.aborted) return false; + // Malformed JSON is not transient (the file is served but corrupt), so + // retrying is pointless. Fall back to another locale. + if (error instanceof SyntaxError) { + logger.error({ logCode: 'intl_parse_locale_SyntaxError' }, `Could not parse locale file ${locale}.json, invalid json`); + return false; + } + // A transient failure: the fetch rejected (network down/changed -> + // TypeError: Failed to fetch) or the response was a 5xx/429. Retry with + // backoff until the network recovers, unless the shared deadline has + // passed: then give up and fall back so the load can resolve. + if (Date.now() > deadline) { + logger.error( + { + logCode: 'intl_fetch_locale_giveup', + extraInfo: { + locale, + retryCount, + error: error instanceof Error ? error.message : String(error), + }, + }, + `Gave up fetching locale ${locale} after ${retryCount} retries: retry budget exhausted`, + ); + return false; + } + // Clamp the backoff to the time left before the deadline so the wait cannot + // overshoot the budget (which would push the give-up past MAX_RETRY_DURATION). + const remaining = deadline - Date.now(); + const baseDelay = Math.min(RETRY_DELAYS[Math.min(retryCount, RETRY_DELAYS.length - 1)], remaining); + // Equal jitter: uniformly random in [base/2, base]. + const delay = baseDelay / 2 + (Math.random() * baseDelay) / 2; + logger.warn( + { + logCode: 'intl_fetch_locale_retry', + extraInfo: { + locale, + retryCount, + delay, + error: error instanceof Error ? error.message : String(error), + }, + }, + `Locale fetch failed for ${locale}, retrying in ${Math.round(delay)}ms`, + ); + // waitForDelay resolves false when the signal aborted before or during the + // wait; skip the doomed next fetch and fall back instead. + const completed = await waitForDelay(delay, signal); + if (!completed) return false; + return attempt(retryCount + 1); + } finally { + // Detach this attempt's timeout and outer-abort listener so neither piles up + // across retries. Running before the recursive attempt's promise settles is + // fine: the next attempt owns its own controller, timeout and listener. + clearTimeout(timeoutId); + signal.removeEventListener('abort', onOuterAbort); + } + }; + + return attempt(0); }; const fetchLocaleOptions = (locale: string, init: boolean, localesList: string[] = []) => { @@ -92,22 +215,36 @@ const IntlLoader: React.FC = ({ currentLocale, setCurrentLocale, }) => { - const loadingContextInfo = useContext(LoadingContext); - const [fetching, setFetching] = React.useState(false); + const [hasError, setHasError] = React.useState(false); const [normalizedLocale, setNormalizedLocale] = React.useState(navigator.language.replace('_', '-')); const [messages, setMessages] = React.useState({}); const [fallbackOnEmptyLocaleString, setFallbackOnEmptyLocaleString] = React.useState(false); const skipInitialLocaleFetch = React.useRef(true); - const fetchLocalizedMessages = useCallback((locale: string, init: boolean) => { + const fetchLocalizedMessages = useCallback((locale: string, init: boolean, signal: AbortSignal) => { setFetching(true); - buildFetchLocale('index') + setHasError(false); + // Single budget shared across the index fetch and every language-set fetch of + // this load, so the whole load is bounded by MAX_RETRY_DURATION rather than + // each fetch getting its own (which could stack to ~2x in the worst case). + const deadline = Date.now() + MAX_RETRY_DURATION; + buildFetchLocale('index', signal, deadline) .then((resp) => { + if (signal.aborted) return; + // The index fetch fell back to false (e.g. 404, corrupt index.json, or the + // retry budget was exhausted): there is no locale list to work from, so + // surface the error screen instead of crashing on .map or spinning on a + // blank screen. + if (!Array.isArray(resp)) { + setHasError(true); + setFetching(false); + return; + } const data = fetchLocaleOptions( locale, init, - (resp as { name: string }[]).map((l) => l.name), + resp.map((l) => l.name), ); const { @@ -123,15 +260,15 @@ const IntlLoader: React.FC = ({ normalizedLocale, ])).filter((locale) => locale); - Promise.all(languageSets.map((locale) => buildFetchLocale(locale))) + Promise.all(languageSets.map((locale) => buildFetchLocale(locale, signal, deadline))) .then((resp) => { - const typedResp = resp as Array; - const foundLocales = typedResp.filter((locale) => locale instanceof Object) as LocaleJson[]; + if (signal.aborted) return; + const foundLocales = resp.filter((locale): locale is LocaleJson => locale !== false); if (foundLocales.length === 0) { - const error = `${{ logCode: 'intl_fetch_locale_error' }},Could not fetch any locale file for ${languageSets.join(', ')}`; - loadingContextInfo.setLoading(false); - logger.error(error); - throw new Error(error); + logger.error({ logCode: 'intl_fetch_locale_none_error', extraInfo: { languageSets } }, 'Could not fetch any locale file'); + setHasError(true); + setFetching(false); + return; } const mergedLocale = foundLocales .reduce((acc, locale: LocaleJson) => Object.assign(acc, locale), {}); @@ -139,28 +276,43 @@ const IntlLoader: React.FC = ({ setNormalizedLocale(replacedLocale); setCurrentLocale(replacedLocale); setMessages(mergedLocale); - if (!init) { - loadingContextInfo.setLoading(false); - } + setFetching(false); }).catch((error) => { - loadingContextInfo.setLoading(false); - throw new Error(error); + logger.error( + { + logCode: 'intl_fetch_locale_merge_error', + extraInfo: { error: error instanceof Error ? error.message : String(error) }, + }, + 'Error fetching localized messages', + ); + setHasError(true); + setFetching(false); }); }) - .catch(() => { - loadingContextInfo.setLoading(false); - throw new Error('unable to fetch localized messages'); + .catch((error) => { + logger.error( + { + logCode: 'intl_fetch_locale_index_error', + extraInfo: { error: error instanceof Error ? error.message : String(error) }, + }, + 'Unable to fetch localized messages', + ); + setHasError(true); + setFetching(false); }); }, []); useEffect(() => { + const controller = new AbortController(); const language = navigator.languages ? navigator.languages[0] : navigator.language; // if currentLocale was already overridden before this component mounted, use it instead if (currentLocale !== normalizedLocale) { - fetchLocalizedMessages(currentLocale, false); + fetchLocalizedMessages(currentLocale, false, controller.signal); } else { - fetchLocalizedMessages(language, true); + fetchLocalizedMessages(language, true, controller.signal); } + // Aborts any in-flight fetch and clears a pending retry timeout on unmount. + return () => controller.abort(); }, []); useEffect(() => { @@ -168,11 +320,14 @@ const IntlLoader: React.FC = ({ // Prevents redundant initial locale fetches when a locale override is detected at mount time if (skipInitialLocaleFetch.current) { skipInitialLocaleFetch.current = false; - return; + return undefined; } if (currentLocale !== normalizedLocale) { - fetchLocalizedMessages(currentLocale, false); + const controller = new AbortController(); + fetchLocalizedMessages(currentLocale, false, controller.signal); + return () => controller.abort(); } + return undefined; }, [currentLocale]); useEffect(() => { @@ -189,7 +344,19 @@ const IntlLoader: React.FC = ({ } }, [fetching]); - return !fetching || Object.keys(messages).length > 0 ? ( + const hasMessages = Object.keys(messages).length > 0; + + // Three render states: (1) locale loading failed completely and we have nothing + // to show -> ErrorScreen (its named export guards for a null intl and falls back + // to hardcoded English, so it is safe outside IntlProvider); (2) still fetching + // with no messages yet -> LoadingScreen; (3) done or we already have a usable + // locale -> render the app. A failed locale *switch* keeps the working app up + // (hasMessages is true), so ErrorScreen only ever replaces a blank screen. + if (hasError && !hasMessages) { + return ; + } + + return !fetching || hasMessages ? ( = ({ > {children} - ) : null; + ) : ; }; const IntlLoaderContainer: React.FC = ({ diff --git a/bigbluebutton-html5/imports/ui/Types/chat.ts b/bigbluebutton-html5/imports/ui/Types/chat.ts index ef8f592b3cfe..b389ebcdbc27 100644 --- a/bigbluebutton-html5/imports/ui/Types/chat.ts +++ b/bigbluebutton-html5/imports/ui/Types/chat.ts @@ -10,6 +10,9 @@ export interface Chat { userId: string; participant?: User; lastSeenAt: string; + lastMessage: string | null; + lastMessageAt: string | null; + lastMessageDeletedByName: string | null; pinnedMessageId: string | null; pinnedByUserId: string | null; pinnedAt: string | null; diff --git a/bigbluebutton-html5/imports/ui/Types/meetingClientSettings.ts b/bigbluebutton-html5/imports/ui/Types/meetingClientSettings.ts index 4f0ebb1d6364..9706ad2d7df4 100644 --- a/bigbluebutton-html5/imports/ui/Types/meetingClientSettings.ts +++ b/bigbluebutton-html5/imports/ui/Types/meetingClientSettings.ts @@ -66,6 +66,7 @@ export interface App { skipMeetingEnded: boolean dynamicGuestPolicy: boolean enableGuestLobbyMessage: boolean + showGuestLobbyWaitingQueuePosition: boolean guestPolicyExtraAllowOptions: boolean alwaysShowWaitingRoomUI: boolean enableLimitOfViewersInWebcam: boolean @@ -622,6 +623,8 @@ export interface SharedNotes { maxDocumentChars: number maxLengthForContentUpdate: number staticFormattingToolbar: boolean + importMarkdownEnabled: boolean + exportMarkdownEnabled: boolean } export interface Media { diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/component.jsx b/bigbluebutton-html5/imports/ui/components/actions-bar/component.jsx index 643f1928ae66..390836012a16 100755 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/component.jsx @@ -46,11 +46,13 @@ class ActionsBar extends PureComponent { buttonProps = { key: `${plugin.type}-${plugin.id}`, onClick: plugin.onClick, - hideLabel: true, + hideLabel: plugin.hideLabel !== false, color: plugin.color || 'primary', - size: 'lg', - circle: true, - label: plugin.tooltip, + size: plugin.size || 'lg', + circle: plugin.circle !== false, + style: plugin.style, + label: plugin.label || plugin.tooltip, + tooltipLabel: plugin.tooltip, dataTest: plugin.dataTest, }; if (typeof plugin?.icon === 'string') { diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/media-area/media-sharing/component.tsx b/bigbluebutton-html5/imports/ui/components/actions-bar/media-area/media-sharing/component.tsx index 2fdd147c592e..34d90f9155bd 100644 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/media-area/media-sharing/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/media-area/media-sharing/component.tsx @@ -20,6 +20,7 @@ import { MediaButtonPluginItem } from '../types'; import { layoutSelectOutput } from '/imports/ui/components/layout/context'; import { Output } from '/imports/ui/components/layout/layoutTypes'; import { getSettingsSingletonInstance } from '/imports/ui/services/settings'; +import InMemoryStorage from '/imports/ui/services/storage/in-memory'; interface MediaSharingModalProps { open: boolean; @@ -167,6 +168,15 @@ const MediaSharingModal: React.FC = ({ const [currentView, setCurrentView] = useState<'main' | 'presentation' | 'externalVideo' | 'cameraAsContent'>('main'); const [localRequestingPresenter, setLocalRequestingPresenter] = useState(false); + // Mirror the presentation-upload view into in-memory storage so the document-title manager can + // surface the "Upload Presentation" view name. The 3.0 setter (actions-dropdown) did not survive + // the merge into the 4.0 media-area, so the key was read but never written. + useEffect(() => { + const uploadViewOpen = open && currentView === 'presentation'; + InMemoryStorage.setItem('showUploadPresentationView', uploadViewOpen ? 'true' : ''); + return () => InMemoryStorage.setItem('showUploadPresentationView', ''); + }, [open, currentView]); + useEffect(() => { setLocalRequestingPresenter(isRequestingPresenter); }, [isRequestingPresenter]); diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/quick-poll-dropdown/component.jsx b/bigbluebutton-html5/imports/ui/components/actions-bar/quick-poll-dropdown/component.jsx index 2e2a4532c7ac..bd612635cc03 100644 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/quick-poll-dropdown/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/quick-poll-dropdown/component.jsx @@ -341,7 +341,7 @@ const QuickPollDropdown = (props) => { poll, })); - const pollQuestion = (question?.length > 0 && question[0]?.replace(/ *\([^)]*\) */g, '')) || ''; + const pollQuestion = (question?.length > 0 && question[0]) || ''; const slideId = currentSlide.id; diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/reactions-button/styles.js b/bigbluebutton-html5/imports/ui/components/actions-bar/reactions-button/styles.js index 828d95758712..527cf082c0c0 100644 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/reactions-button/styles.js +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/reactions-button/styles.js @@ -2,9 +2,9 @@ import styled from 'styled-components'; import Button from '/imports/ui/components/common/button/component'; import { + colorBorder, colorWhite, colorGrayDark, - colorGrayLightest, btnPrimaryColor, btnPrimaryActiveBg, } from '/imports/ui/stylesheets/styled-components/palette'; @@ -69,7 +69,7 @@ const ReactionsButtonWrapper = styled(ButtonWrapper)` ${({ isMobile }) => !isMobile && ` - border: 1px solid ${colorGrayLightest}; + border: 1px solid ${colorBorder}; padding: 1rem 0.5rem; width: auto; `} diff --git a/bigbluebutton-html5/imports/ui/components/app/component.tsx b/bigbluebutton-html5/imports/ui/components/app/component.tsx index 597c34c9c18c..8a735caafde0 100644 --- a/bigbluebutton-html5/imports/ui/components/app/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/app/component.tsx @@ -51,6 +51,7 @@ import usePollShortcut from './hooks/usePollShortcut'; import useUserStatusNotifications from './hooks/useUserStatusNotifications'; import { NotesRenderMode } from '/imports/ui/components/notes/constants'; import RequestPresenterContainer from '/imports/ui/components/request-presenter/container'; +import DocumentTitleManager from './document-title-manager/component'; interface AppProps { darkTheme: boolean; @@ -146,6 +147,7 @@ const App: React.FC = ({ }} > + diff --git a/bigbluebutton-html5/imports/ui/components/app/document-title-manager/component.tsx b/bigbluebutton-html5/imports/ui/components/app/document-title-manager/component.tsx new file mode 100644 index 000000000000..736bd144fba4 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/app/document-title-manager/component.tsx @@ -0,0 +1,219 @@ +import React, { + useContext, useEffect, useMemo, useState, +} from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import * as PluginSdk from 'bigbluebutton-html-plugin-sdk'; +import { GenericContentType } from 'bigbluebutton-html-plugin-sdk/dist/cjs/extensible-areas/generic-content-item/enums'; +import getFromUserSettings from '/imports/ui/services/users-settings'; +import { PluginsContext } from '/imports/ui/components/components-data/plugin-context/context'; +import { layoutSelect, layoutSelectInput } from '/imports/ui/components/layout/context'; +import { PANELS } from '/imports/ui/components/layout/enums'; +import { Layout, Input } from '/imports/ui/components/layout/layoutTypes'; +import useMeeting from '/imports/ui/core/hooks/useMeeting'; +import useChat from '/imports/ui/core/hooks/useChat'; +import { Chat } from '/imports/ui/Types/chat'; +import { GraphqlDataHookSubscriptionResponse } from '/imports/ui/Types/hook'; +import { useStorageKey } from '/imports/ui/services/storage/hooks'; +import { + DOCUMENT_TITLE_VIEW_CHANGED, + getActiveDocumentTitleView, +} from './service'; + +const intlMessages = defineMessages({ + defaultViewLabel: { + id: 'app.title.defaultViewLabel', + description: 'view name appended to document title', + }, + userListLabel: { + id: 'app.userList.label', + description: 'view name appended to document title', + }, + chatLabel: { + id: 'app.chat.label', + description: 'view name appended to document title', + }, + publicChatTitle: { + id: 'app.chat.titlePublic', + description: 'view name appended to document title', + }, + privateChatTitle: { + id: 'app.chat.titlePrivateToUser', + description: 'view name appended to document title', + }, + pollTitle: { + id: 'app.poll.pollPaneTitle', + description: 'view name appended to document title', + }, + captionsTitle: { + id: 'app.userList.captionsTitle', + description: 'view name appended to document title', + }, + notesTitle: { + id: 'app.notes.title', + description: 'view name appended to document title', + }, + breakoutRoomsTitle: { + id: 'app.createBreakoutRoom.title', + description: 'view name appended to document title', + }, + timerTitle: { + id: 'app.timer.title', + description: 'view name appended to document title', + }, + waitingUsersTitle: { + id: 'app.userList.guest.waitingUsers', + description: 'view name appended to document title', + }, + uploadPresentationTitle: { + id: 'app.presentationUploder.uploadViewTitle', + description: 'view name appended to document title', + }, + defaultBreakoutName: { + id: 'app.createBreakoutRoom.room', + description: 'default breakout room name', + }, +}); + +const getClientTitle = () => { + const publicConfig = window.meetingClientSettings?.public; + return getFromUserSettings('bbb_client_title', publicConfig?.app?.clientTitle || 'BigBlueButton'); +}; + +const getChatTitle = ( + chatId: string, + chats: Array> | null | undefined, + intl: ReturnType, +) => { + const chatConfig = window.meetingClientSettings.public.chat; + const isPublicChatId = chatId === chatConfig.public_id || chatId === chatConfig.public_group_id; + const selectedChat = chats?.find((chat) => ( + chat.chatId === chatId + || (isPublicChatId && (chat.chatId === chatConfig.public_id || chat.chatId === chatConfig.public_group_id)) + )); + + if (selectedChat?.public || isPublicChatId) { + return intl.formatMessage(intlMessages.publicChatTitle); + } + + if (selectedChat?.participant?.name) { + return intl.formatMessage(intlMessages.privateChatTitle, { + participantName: selectedChat.participant.name, + }); + } + + return intl.formatMessage(intlMessages.chatLabel); +}; + +const DocumentTitleManager: React.FC = () => { + const intl = useIntl(); + const idChatOpen = layoutSelect((i: Layout) => i.idChatOpen); + const sidebarNavigation = layoutSelectInput((i: Input) => i.sidebarNavigation); + const sidebarContent = layoutSelectInput((i: Input) => i.sidebarContent); + const uploadPresentationOpen = !!useStorageKey('showUploadPresentationView'); + const [registeredViewTitle, setRegisteredViewTitle] = useState(getActiveDocumentTitleView()); + const { pluginsExtensibleAreasAggregatedState } = useContext(PluginsContext); + const { data: meeting } = useMeeting((m) => ({ + name: m.name, + breakoutPolicies: { + sequence: m.breakoutPolicies?.sequence, + }, + })); + const { data: chats } = useChat((chat) => ({ + chatId: chat.chatId, + participant: chat.participant, + public: chat.public, + })) as GraphqlDataHookSubscriptionResponse[]>; + + const genericSidekickContentTitle = useMemo(() => { + const { sidebarContentPanel } = sidebarContent; + if (!sidebarContentPanel.startsWith(PANELS.GENERIC_CONTENT_SIDEKICK)) return null; + + const genericSidekickContentId = sidebarContentPanel.replace(PANELS.GENERIC_CONTENT_SIDEKICK, ''); + const genericContentItems = pluginsExtensibleAreasAggregatedState.genericContentItems || []; + const genericSidekickContentItems = genericContentItems + .filter((item) => item.type === GenericContentType.SIDEKICK_AREA) as PluginSdk.GenericContentSidekickArea[]; + const genericContentItem = genericSidekickContentItems.find((item) => item.id === genericSidekickContentId); + + return genericContentItem?.name || null; + }, [pluginsExtensibleAreasAggregatedState.genericContentItems, sidebarContent]); + + const activeViewTitle = useMemo(() => { + if (registeredViewTitle) return registeredViewTitle; + if (uploadPresentationOpen) return intl.formatMessage(intlMessages.uploadPresentationTitle); + + switch (sidebarContent.sidebarContentPanel) { + case PANELS.CHAT: + return getChatTitle(idChatOpen, chats, intl); + case PANELS.POLL: + return intl.formatMessage(intlMessages.pollTitle); + case PANELS.CAPTIONS: + return intl.formatMessage(intlMessages.captionsTitle); + case PANELS.BREAKOUT: + return intl.formatMessage(intlMessages.breakoutRoomsTitle); + case PANELS.SHARED_NOTES: + return intl.formatMessage(intlMessages.notesTitle); + case PANELS.TIMER: + return intl.formatMessage(intlMessages.timerTitle); + case PANELS.WAITING_USERS: + return intl.formatMessage(intlMessages.waitingUsersTitle); + default: + if (genericSidekickContentTitle) return genericSidekickContentTitle; + } + + if (sidebarNavigation.isOpen && sidebarNavigation.sidebarNavPanel === PANELS.USERLIST) { + return intl.formatMessage(intlMessages.userListLabel); + } + + return intl.formatMessage(intlMessages.defaultViewLabel); + }, [ + chats, + genericSidekickContentTitle, + idChatOpen, + intl, + sidebarContent.sidebarContentPanel, + sidebarNavigation.isOpen, + sidebarNavigation.sidebarNavPanel, + registeredViewTitle, + uploadPresentationOpen, + ]); + + const documentTitle = useMemo(() => { + const titleSegments = [getClientTitle()]; + const meetingName = meeting?.name?.trim(); + const breakoutNum = meeting?.breakoutPolicies?.sequence; + + if (meetingName) { + if (breakoutNum && breakoutNum > 0) { + const defaultBreakoutName = intl.formatMessage(intlMessages.defaultBreakoutName, { + roomNumber: breakoutNum, + }); + + titleSegments.push(meetingName === defaultBreakoutName ? `${breakoutNum}` : meetingName); + } else { + titleSegments.push(meetingName); + } + } + + if (activeViewTitle) titleSegments.push(activeViewTitle); + + return titleSegments.join(' - '); + }, [activeViewTitle, intl, meeting]); + + useEffect(() => { + document.title = documentTitle; + }, [documentTitle]); + + useEffect(() => { + const handleDocumentTitleViewChanged = (event: Event) => { + const customEvent = event as CustomEvent<{ activeTitle: string | null }>; + setRegisteredViewTitle(customEvent.detail.activeTitle); + }; + + window.addEventListener(DOCUMENT_TITLE_VIEW_CHANGED, handleDocumentTitleViewChanged); + return () => window.removeEventListener(DOCUMENT_TITLE_VIEW_CHANGED, handleDocumentTitleViewChanged); + }, []); + + return null; +}; + +export default DocumentTitleManager; diff --git a/bigbluebutton-html5/imports/ui/components/app/document-title-manager/service.ts b/bigbluebutton-html5/imports/ui/components/app/document-title-manager/service.ts new file mode 100644 index 000000000000..07f0fc3340bc --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/app/document-title-manager/service.ts @@ -0,0 +1,52 @@ +export const DOCUMENT_TITLE_VIEW_CHANGED = 'bbb-document-title-view-changed'; + +type DocumentTitleView = { + title: string; + sequence: number; +}; + +const registeredTitleViews = new Map(); +let sequence = 0; + +const notifyDocumentTitleViewChanged = () => { + window.dispatchEvent(new CustomEvent(DOCUMENT_TITLE_VIEW_CHANGED, { + detail: { + activeTitle: getActiveDocumentTitleView(), + }, + })); +}; + +export const getActiveDocumentTitleView = (): string | null => { + const views = Array.from(registeredTitleViews.values()); + if (views.length === 0) return null; + + views.sort((a, b) => b.sequence - a.sequence); + return views[0].title; +}; + +export const registerDocumentTitleView = (id: string, title: string) => { + if (!id || !title) return; + + const existingView = registeredTitleViews.get(id); + if (existingView?.title === title) return; + + registeredTitleViews.set(id, { + title, + sequence: existingView?.sequence || sequence + 1, + }); + + if (!existingView) sequence += 1; + notifyDocumentTitleViewChanged(); +}; + +export const unregisterDocumentTitleView = (id: string) => { + if (!registeredTitleViews.delete(id)) return; + notifyDocumentTitleViewChanged(); +}; + +let idSequence = 0; + +export const createDocumentTitleViewId = (prefix: string) => { + idSequence += 1; + return `${prefix}-${idSequence}`; +}; diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/captions/styles.ts b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/captions/styles.ts index 9d4c48b21300..e1a14469407a 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/captions/styles.ts +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-graphql/audio-captions/captions/styles.ts @@ -5,7 +5,7 @@ import { import { colorGrayLabel, colorWhite, - colorGrayLighter, + colorBorder, colorPrimary, } from '/imports/ui/stylesheets/styled-components/palette'; @@ -18,7 +18,7 @@ const CaptionsSelector = styled.div` const Select = styled.select` background-color: ${colorWhite}; - border: 0.1rem solid ${colorGrayLighter}; + border: 0.1rem solid ${colorBorder}; border-radius: ${borderSize}; color: ${colorGrayLabel}; width: 100%; diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-modal/styles.js b/bigbluebutton-html5/imports/ui/components/audio/audio-modal/styles.js index d6ab45013bde..d94a926b0461 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-modal/styles.js +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-modal/styles.js @@ -35,6 +35,20 @@ const AudioModalButton = styled(Button)` } } + // The bbb-icons unmute (microphone) glyph has a taller ink box than the listen (headphone) + // glyph at the same font-size: mic ink is ~61px tall vs the headphone's ~49px at 3.5rem, so + // even with their centers aligned the mic overhangs the headphone by ~6px at each end and reads + // as a different size inside its circle. scale(0.8) (rounded from 49/61 ~= 0.803) shrinks the mic + // to the headphone's ink height about the glyph's geometric center (transform-origin defaults to + // center), so the two icons occupy the same vertical band. Uniform scale keeps the mic's natural + // proportions - scaleY alone flattens it, and a smaller font-size overshoots and re-anchors the + // glyph on the text baseline, dropping its center below the headphone's. Scoped to the unmute + // glyph inside AudioModalButton so the icon reused elsewhere (mute toggle, audio test) is + // untouched. + & span:first-child i.icon-bbb-unmute { + transform: scale(0.8); + } + // When hovering over a button of class audioBtn, change the border colour of first span-child &:hover span:first-child, &:focus span:first-child { diff --git a/bigbluebutton-html5/imports/ui/components/audio/device-selector/styles.js b/bigbluebutton-html5/imports/ui/components/audio/device-selector/styles.js index 69932a1c6970..50e4e7a76527 100644 --- a/bigbluebutton-html5/imports/ui/components/audio/device-selector/styles.js +++ b/bigbluebutton-html5/imports/ui/components/audio/device-selector/styles.js @@ -5,13 +5,13 @@ import { import { colorGrayLabel, colorWhite, - colorGrayLighter, + colorBorder, colorPrimary, } from '/imports/ui/stylesheets/styled-components/palette'; const Select = styled.select` background-color: ${colorWhite}; - border: 0.1rem solid ${colorGrayLighter}; + border: 0.1rem solid ${colorBorder}; border-radius: ${borderSize}; color: ${colorGrayLabel}; width: 100%; diff --git a/bigbluebutton-html5/imports/ui/components/bn-shared-notes/component.tsx b/bigbluebutton-html5/imports/ui/components/bn-shared-notes/component.tsx index a4654792d7a1..9a5fcdab6615 100644 --- a/bigbluebutton-html5/imports/ui/components/bn-shared-notes/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/bn-shared-notes/component.tsx @@ -32,9 +32,12 @@ import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette'; import { useBlockNoteLocaleLanguage, useHocuspocusProvider } from './hooks'; import useMeeting from '/imports/ui/core/hooks/useMeeting'; import useCurrentUser from '../../core/hooks/useCurrentUser'; +import useNotesLastRead from '/imports/ui/components/notes/hooks/useNotesLastRead'; import logger from '/imports/startup/client/logger'; import { notify } from '../../services/notification'; import TextAlignSelect from './text-align-select/component'; +import MarkdownImportModal from './markdown-import-modal/component'; +import { useSharedNotesImport } from './import-context'; // Force-retain `Awareness` against a webpack tree-shaking interaction that // otherwise drops this class while keeping its `extends Observable` expression, @@ -107,6 +110,40 @@ const fixCursorAtOriginExtension = Extension.create({ }, }); +// TODO: remove this workaround once y-prosemirror's cursor decoration can set `marks: []` +// (the upstream-correct fix is `marks: []` on the cursor `Decoration.widget` in +// y-prosemirror/src/plugins/cursor-plugin.js; related: https://github.com/yjs/y-prosemirror/issues/174). +// The remote collaboration cursor is a ProseMirror *widget decoration*. y-prosemirror +// renders it with `side: 10` and no `marks`, so ProseMirror wraps the widget in the +// marks of the node that follows the caret. When a remote user's caret sits inside (or +// at the edge of) a link, that following node carries the `link` mark, so the cursor +// widget — and therefore the user's name and the U+2060 word-joiner separators around +// it — is rendered *inside* the , polluting the link's visible text (issue #25225). +// +// y-prosemirror hardcodes the decoration spec and BlockNote exposes no hook for it, so +// we use BlockNote's supported `renderCursor` hook to render a cursor that carries no +// document text: the name lives in a `data-cursor-name` attribute shown via the CSS +// `::after` rule below (pseudo-content is never part of `textContent`), and the U+2060 +// separators are omitted. The widget may still be positioned inside the link mark, but +// it no longer leaks the name (or any separator characters) into the link's text/href. +const renderCollaborationCursor = (user: { name: string; color: string }) => { + const cursorElement = document.createElement('span'); + cursorElement.classList.add('bn-collaboration-cursor__base'); + + const caret = document.createElement('span'); + caret.classList.add('bn-collaboration-cursor__caret'); + caret.setAttribute('style', `background-color: ${user.color}`); + + const label = document.createElement('span'); + label.classList.add('bn-collaboration-cursor__label'); + label.setAttribute('style', `background-color: ${user.color}`); + label.setAttribute('data-cursor-name', user.name ?? ''); + + caret.appendChild(label); + cursorElement.appendChild(caret); + return cursorElement; +}; + const intlMessages = defineMessages({ payloadSizeError: { id: 'app.notes.blocknote.payloadSizeError', @@ -170,6 +207,8 @@ function BlockNoteApp(props: BlockNoteAppProps): React.ReactElement { const intl = useIntl(); + const { isImportModalOpen, closeImportModal } = useSharedNotesImport(); + const blockNoteLocale = useBlockNoteLocaleLanguage(); const [notificationErrorMessage, setNotificationErrorMessage] = React.useState(null); @@ -237,6 +276,7 @@ function BlockNoteApp(props: BlockNoteAppProps): React.ReactElement { name: userName || '', color: userColor || '', }, + renderCursor: renderCollaborationCursor, }, schema, dictionary: { @@ -377,6 +417,12 @@ function BlockNoteApp(props: BlockNoteAppProps): React.ReactElement { .bn-collaboration-cursor__label { color: ${colorWhite} !important; } + /* The collaborator's name is held in a data attribute (see + renderCollaborationCursor) and rendered as pseudo-content so it never + becomes part of a surrounding link's text/href — issue #25225. */ + .bn-collaboration-cursor__label::after { + content: attr(data-cursor-name); + } .bn-collaboration-cursor__caret { overflow: visible !important; } @@ -478,14 +524,25 @@ function BlockNoteApp(props: BlockNoteAppProps): React.ReactElement { )} + {isImportModalOpen && editable && ( + + )} ); } -function BlockNoteContainer(): React.ReactElement { +interface BlockNoteContainerProps { + isVisible: boolean; +} + +function BlockNoteContainer({ isVisible }: BlockNoteContainerProps): React.ReactElement { const { error, isAuthenticating, hocuspocusProvider, connectionClosed, handleRetry, isSynced, } = useHocuspocusProvider(); + const { markNotesAsRead } = useNotesLastRead(); const { data: currentUser } = useCurrentUser((user) => ({ color: user.color, @@ -506,6 +563,18 @@ function BlockNoteContainer(): React.ReactElement { const renderBlockNote = !error && !isAuthenticating && hocuspocusProvider && !connectionClosed && isSynced && !!currentUser; + + // The notes are read when the synced editor is on screen. Mirror the + // etherpad pad (pads-graphql/component.tsx): mark as read on show and on + // hide - the panel stays mounted for NOTES_UNMOUNT_DELAY after closing, + // and edits arriving in that window must stay unread. + React.useEffect(() => { + if (!renderBlockNote) return () => {}; + if (isVisible) markNotesAsRead(); + return () => { + if (isVisible) markNotesAsRead(); + }; + }, [renderBlockNote, isVisible, markNotesAsRead]); return ( {(hasError) && ( diff --git a/bigbluebutton-html5/imports/ui/components/bn-shared-notes/import-context.tsx b/bigbluebutton-html5/imports/ui/components/bn-shared-notes/import-context.tsx new file mode 100644 index 000000000000..29363b204c47 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/bn-shared-notes/import-context.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext } from 'react'; + +// Bridges the notes kebab menu (which owns the "Import from Markdown" trigger) and +// the BlockNote editor (which owns the document). The kebab lives in a separate +// subtree from the editor, so the open/close state is lifted to the shared Notes +// parent and shared through this context. The modal itself is rendered next to the +// editor so it can call editor.replaceBlocks directly, keeping the editor instance +// encapsulated inside bn-shared-notes. +export interface SharedNotesImportContextValue { + isImportModalOpen: boolean; + openImportModal: () => void; + closeImportModal: () => void; +} + +export const SharedNotesImportContext = createContext({ + isImportModalOpen: false, + openImportModal: () => {}, + closeImportModal: () => {}, +}); + +export const useSharedNotesImport = (): SharedNotesImportContextValue => useContext(SharedNotesImportContext); diff --git a/bigbluebutton-html5/imports/ui/components/bn-shared-notes/markdown-import-modal/component.tsx b/bigbluebutton-html5/imports/ui/components/bn-shared-notes/markdown-import-modal/component.tsx new file mode 100644 index 000000000000..302fb826d6f9 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/bn-shared-notes/markdown-import-modal/component.tsx @@ -0,0 +1,381 @@ +import * as React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { BlockNoteEditor } from '@blocknote/core'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { DropzoneRenderFunction } from 'react-dropzone'; +import ModalSimple from '/imports/ui/components/common/modal/simple/component'; +import Button from '/imports/ui/components/common/button/component'; +import Icon from '/imports/ui/components/common/icon/component'; +import Styled from './styles'; + +// A Markdown file larger than this is rejected before it is read into memory. +const MAX_FILE_SIZE = 1024 * 1024; // 1MB +const ACCEPTED_EXTENSIONS = ['.md', '.markdown']; +// Comma-separated form for the native file picker's `accept` attribute (react-dropzone +// types it as a single string). The array above stays the source of truth for the +// authoritative extension check in loadFile. +const ACCEPTED_EXTENSIONS_ATTR = ACCEPTED_EXTENSIONS.join(','); + +// data-test is a valid DOM data-* attribute but not part of the typed +// InputHTMLAttributes. Spreading it from a named object lets it through the +// dropzone inputProps without a type cast. +const FILE_INPUT_PROPS = { 'data-test': 'notesImportMarkdownFileInput' }; + +type ImportError = 'invalidType' | 'tooLarge' | 'readFailed' | 'empty' | 'parseFailed'; + +// How the parsed markdown is written into the current notes. `append` (the default) +// adds it after the existing content; `replace` overwrites the whole document. +type ImportMode = 'append' | 'replace'; + +const intlMessages = defineMessages({ + title: { + id: 'app.notes.importModal.title', + description: 'Title for the import from markdown modal', + }, + placeholder: { + id: 'app.notes.importModal.placeholder', + description: 'Placeholder for the markdown import textarea', + }, + importModeLabel: { + id: 'app.notes.importModal.importMode.label', + description: 'Label for the append/replace import mode selector', + }, + importModeAppendLabel: { + id: 'app.notes.importModal.importMode.append.label', + description: 'Label for the append import mode option', + }, + importModeAppendDescription: { + id: 'app.notes.importModal.importMode.append.description', + description: 'Description of the append import mode option', + }, + importModeReplaceLabel: { + id: 'app.notes.importModal.importMode.replace.label', + description: 'Label for the replace import mode option', + }, + importModeReplaceDescription: { + id: 'app.notes.importModal.importMode.replace.description', + description: 'Description of the replace import mode option', + }, + importLabel: { + id: 'app.notes.importModal.import', + description: 'Label for the import confirmation button', + }, + cancelLabel: { + id: 'app.notes.importModal.cancel', + description: 'Label for the cancel button', + }, + dropzoneLabel: { + id: 'app.notes.importModal.dropzone.label', + description: 'Instruction to drag and drop a markdown file', + }, + dropzoneBrowse: { + id: 'app.notes.importModal.dropzone.browse', + description: 'Call to action to open the file browser', + }, + dropzoneHint: { + id: 'app.notes.importModal.dropzone.hint', + description: 'Hint listing the accepted file extensions', + }, + dropzoneActive: { + id: 'app.notes.importModal.dropzone.active', + description: 'Shown while a file is being dragged over the dropzone', + }, + orDivider: { + id: 'app.notes.importModal.orDivider', + description: 'Divider between the dropzone and the paste textarea', + }, + removeFile: { + id: 'app.notes.importModal.fileLoaded.remove', + description: 'Label for the button that removes the loaded file', + }, + errorInvalidType: { + id: 'app.notes.importModal.error.invalidType', + description: 'Error shown when a non-markdown file is selected', + }, + errorTooLarge: { + id: 'app.notes.importModal.error.tooLarge', + description: 'Error shown when the selected file exceeds the size limit', + }, + errorReadFailed: { + id: 'app.notes.importModal.error.readFailed', + description: 'Error shown when the file could not be read', + }, + errorEmpty: { + id: 'app.notes.importModal.error.empty', + description: 'Message shown when the selected file has no content', + }, + errorParseFailed: { + id: 'app.notes.importModal.error.parseFailed', + description: 'Error shown when the markdown could not be parsed into blocks', + }, +}); + +const errorMessageIds: Record = { + invalidType: 'errorInvalidType', + tooLarge: 'errorTooLarge', + readFailed: 'errorReadFailed', + empty: 'errorEmpty', + parseFailed: 'errorParseFailed', +}; + +// Human readable file size for the loaded-file chip. +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +interface MarkdownImportModalProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + editor: BlockNoteEditor; + onClose: () => void; +} + +const MarkdownImportModal: React.FC = ({ editor, onClose }) => { + const intl = useIntl(); + // `markdown` is the single source of truth fed to the parse pipeline, whether it + // came from an uploaded file or from the textarea below. + const [markdown, setMarkdown] = React.useState(''); + const [fileName, setFileName] = React.useState(null); + const [fileSize, setFileSize] = React.useState(null); + const [error, setError] = React.useState(null); + // Default to `append` so importing never silently destroys existing notes. + const [importMode, setImportMode] = React.useState('append'); + + const clearFile = React.useCallback(() => { + setFileName(null); + setFileSize(null); + }, []); + + const loadFile = React.useCallback(async (file: File) => { + const name = file.name.toLowerCase(); + const isMarkdown = ACCEPTED_EXTENSIONS.some((ext) => name.endsWith(ext)); + // Error paths never touch `markdown`: text the user may have typed in the textarea + // must survive a rejected drop. Only the chip (file metadata) is cleared. + if (!isMarkdown) { + setError('invalidType'); + clearFile(); + return; + } + if (file.size > MAX_FILE_SIZE) { + setError('tooLarge'); + clearFile(); + return; + } + + try { + const text = await file.text(); + setFileName(file.name); + setFileSize(file.size); + setMarkdown(text); + // Empty file: keep the chip so the user sees what they loaded, but flag it and + // leave the import button disabled (markdown is empty). + setError(text.trim().length === 0 ? 'empty' : null); + } catch { + setError('readFailed'); + clearFile(); + } + }, [clearFile]); + + // react-dropzone (the legacy version this repo pins, as used by the presentation + // uploader) hands onDrop both the accepted and the rejected files. `accept` only + // hints the native file picker to default to .md/.markdown; it is not authoritative. + // A file that fails the hint lands in rejectedFiles, so it is still forwarded to + // loadFile, which is the source of truth for the extension check and the error. + const onDrop = (acceptedFiles: File[], rejectedFiles: File[]) => { + const file = acceptedFiles[0] ?? rejectedFiles?.[0]; + if (file) loadFile(file); + }; + + const applyImport = async () => { + try { + // On the client BlockNote editor this is synchronous, but await keeps it correct + // if a future version returns a Promise. + const blocks = await editor.tryParseMarkdownToBlocks(markdown); + // Both paths mutate the shared Yjs fragment, so the change propagates through + // Hocuspocus to every connected client. + if (importMode === 'replace') { + editor.replaceBlocks(editor.document, blocks); + } else { + // Append: insert the parsed blocks right after the last existing block. The + // document always has at least one (trailing) block to anchor to. + const lastBlock = editor.document[editor.document.length - 1]; + editor.insertBlocks(blocks, lastBlock, 'after'); + } + onClose(); + } catch (e) { + // Malformed markdown (or a parser edge case) rejects the parse promise. Surface + // the error and keep the modal open so the user can fix the syntax and retry, + // instead of the modal silently freezing. + // eslint-disable-next-line no-console + console.error('Markdown import failed', e); + setError('parseFailed'); + } + }; + + const handleTextareaChange = (e: React.ChangeEvent) => { + setMarkdown(e.target.value); + // Typing supersedes any file-load error/message (e.g. filling in an empty file). + setError(null); + }; + + const handleRemoveFile = () => { + clearFile(); + setMarkdown(''); + setError(null); + }; + + // The red dashed border only applies to hard errors, not the empty-file notice. + const hasHardError = error !== null && error !== 'empty'; + + // react-dropzone's legacy render-prop children. Typed via DropzoneRenderFunction so + // isDragActive is a plain local (destructured in the body, not an inline param type + // that react/no-unused-prop-types misreads as a component's declared props). + const renderDropzoneContent: DropzoneRenderFunction = (renderProps) => { + const { isDragActive } = renderProps; + return ( + <> + + {isDragActive ? ( + + {intl.formatMessage(intlMessages.dropzoneActive)} + + ) : ( + + {intl.formatMessage(intlMessages.dropzoneLabel)} + {' '} + {intl.formatMessage(intlMessages.dropzoneBrowse)} + + )} + + {intl.formatMessage(intlMessages.dropzoneHint)} + + + ); + }; + + return ( + + + + {/* styled(Dropzone) merges the div's ReactNode children with dropzone's + render-function children into an intersection that excludes functions, so + the valid render-prop is passed through React.ReactNode. */} + {renderDropzoneContent as unknown as React.ReactNode} + + + {fileName && ( + + + {fileName} + {fileSize !== null && {formatBytes(fileSize)}} + + + + + )} + + {error && ( + + {intl.formatMessage(intlMessages[errorMessageIds[error]])} + + )} + + {intl.formatMessage(intlMessages.orDivider)} + + + + {intl.formatMessage(intlMessages.importModeLabel)} + + + + + + + + + + + + ); +}; + +export default MarkdownImportModal; diff --git a/bigbluebutton-html5/imports/ui/components/bn-shared-notes/markdown-import-modal/styles.ts b/bigbluebutton-html5/imports/ui/components/bn-shared-notes/markdown-import-modal/styles.ts new file mode 100644 index 000000000000..5b6e7ed234cc --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/bn-shared-notes/markdown-import-modal/styles.ts @@ -0,0 +1,229 @@ +import styled from 'styled-components'; +// eslint-disable-next-line import/no-extraneous-dependencies +import Dropzone from 'react-dropzone'; +import Icon from '/imports/ui/components/common/icon/component'; +import { + colorGray, + colorGrayLight, + colorGrayLighter, + colorGrayLightest, + colorDanger, + colorPrimary, + colorOffWhite, + colorText, +} from '/imports/ui/stylesheets/styled-components/palette'; + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 0.75rem; + min-width: 20rem; +`; + +interface DropzoneProps { + $hasError: boolean; +} + +// Primary import path: the same react-dropzone target the presentation uploader uses. +// The `.isDragActive` class is applied by Dropzone via the activeClassName prop. +// Compact padding keeps the stacked layout tight next to the optional textarea below. +const DropzoneRoot = styled(Dropzone)` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.25rem; + padding: 0.75rem; + text-align: center; + cursor: pointer; + border: 2px dashed ${colorGrayLighter}; + border-radius: 0.25rem; + color: ${colorGray}; + transition: border-color 0.15s ease, background-color 0.15s ease; + + &:hover, + &:focus-within { + border-color: ${colorPrimary}; + } + + &.isDragActive { + border-color: ${colorPrimary}; + background-color: rgba(15, 112, 215, 0.08); + } + + ${({ $hasError }) => $hasError && ` + border-color: ${colorDanger}; + `} +`; + +const DropzoneIcon = styled(Icon)` + font-size: 1.5rem; + line-height: 1; + color: ${colorGrayLight}; +`; + +const DropzoneLabel = styled.span` + font-size: 0.875rem; +`; + +const Browse = styled.span` + color: ${colorPrimary}; + text-decoration: underline; +`; + +const DropzoneHint = styled.span` + font-size: 0.75rem; + color: ${colorGrayLight}; +`; + +const Divider = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; + color: ${colorGrayLight}; + font-size: 0.75rem; + + &::before, + &::after { + content: ''; + flex: 1; + height: 1px; + background-color: ${colorGrayLightest}; + } +`; + +const FileChip = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background-color: ${colorOffWhite}; + border: 1px solid ${colorGrayLightest}; + border-radius: 0.25rem; + font-size: 0.875rem; +`; + +const FileName = styled.span` + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; +`; + +const FileSize = styled.span` + color: ${colorGrayLight}; + font-size: 0.75rem; + white-space: nowrap; +`; + +const FileRemove = styled.button` + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + background: transparent; + border: none; + border-radius: 0.25rem; + cursor: pointer; + color: ${colorGray}; + + &:hover { + color: ${colorDanger}; + } + + &:focus-visible { + outline: 2px solid ${colorPrimary}; + } +`; + +const ErrorText = styled.p` + margin: 0; + color: ${colorDanger}; + font-size: 0.8125rem; + font-weight: 600; +`; + +const Textarea = styled.textarea` + width: 100%; + min-height: 8rem; + resize: vertical; + padding: 0.5rem; + border: 1px solid ${colorGrayLighter}; + border-radius: 0.25rem; + font-family: monospace; + font-size: 0.875rem; +`; + +// Append/Replace selector. Native radio inputs styled with the palette, matching +// the self-contained styled-components approach the rest of this modal uses. +const ModeGroup = styled.div` + display: flex; + flex-direction: column; + gap: 0.5rem; +`; + +const ModeLegend = styled.span` + color: ${colorGray}; + font-size: 0.8125rem; + font-weight: 600; +`; + +const ModeOption = styled.label` + display: flex; + align-items: flex-start; + gap: 0.5rem; + cursor: pointer; + + & > input[type='radio'] { + margin-top: 0.2rem; + accent-color: ${colorPrimary}; + cursor: pointer; + } +`; + +const ModeText = styled.span` + display: flex; + flex-direction: column; + line-height: 1.2; +`; + +const ModeOptionLabel = styled.span` + color: ${colorText}; + font-size: 0.875rem; + font-weight: 600; +`; + +const ModeOptionDescription = styled.span` + color: ${colorGrayLight}; + font-size: 0.75rem; +`; + +const Actions = styled.div` + display: flex; + justify-content: flex-end; + gap: 0.5rem; +`; + +export default { + Container, + Dropzone: DropzoneRoot, + DropzoneIcon, + DropzoneLabel, + Browse, + DropzoneHint, + Divider, + FileChip, + FileName, + FileSize, + FileRemove, + ErrorText, + Textarea, + ModeGroup, + ModeLegend, + ModeOption, + ModeText, + ModeOptionLabel, + ModeOptionDescription, + Actions, +}; diff --git a/bigbluebutton-html5/imports/ui/components/breakout-room/breakout-room/styles.ts b/bigbluebutton-html5/imports/ui/components/breakout-room/breakout-room/styles.ts index 87abe9dba6e7..7435b4cbc8d2 100644 --- a/bigbluebutton-html5/imports/ui/components/breakout-room/breakout-room/styles.ts +++ b/bigbluebutton-html5/imports/ui/components/breakout-room/breakout-room/styles.ts @@ -8,13 +8,12 @@ import { contentSidebarBottomScrollPadding, } from '/imports/ui/stylesheets/styled-components/general'; import { + colorBorder, colorPrimary, colorGray, colorDanger, userListBg, colorWhite, - colorGrayLighter, - colorBlueLight, colorBlueAux, listItemBgHover, colorText, @@ -190,7 +189,7 @@ const SetTimeContainer = styled.div` const SetDurationInput = styled.input` flex: 1; - border: 1px solid ${colorGrayLighter}; + border: 1px solid ${colorBorder}; width: 50%; text-align: center; padding: .25rem; @@ -205,7 +204,7 @@ const SetDurationInput = styled.input` &:focus { border-radius: ${borderSize}; - box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary}; + box-shadow: 0 0 0 ${borderSize} ${colorPrimary}, inset 0 0 0 1px ${colorPrimary}; } &:disabled, @@ -283,7 +282,7 @@ const Input = styled(TextareaAutosize)` line-height: 1; min-height: 2.5rem; max-height: 10rem; - border: 1px solid ${colorGrayLighter}; + border: 1px solid ${colorBorder}; &:disabled, &[disabled] { @@ -294,7 +293,7 @@ const Input = styled(TextareaAutosize)` &:focus { border-radius: ${borderSize}; - box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary}; + box-shadow: 0 0 0 ${borderSize} ${colorPrimary}, inset 0 0 0 1px ${colorPrimary}; } &:hover, diff --git a/bigbluebutton-html5/imports/ui/components/breakout-room/create-breakout-room/breakout-room-user-assignment/room-user-list/styles.ts b/bigbluebutton-html5/imports/ui/components/breakout-room/create-breakout-room/breakout-room-user-assignment/room-user-list/styles.ts index e5b10e2a5af9..f9f7dd884afc 100644 --- a/bigbluebutton-html5/imports/ui/components/breakout-room/create-breakout-room/breakout-room-user-assignment/room-user-list/styles.ts +++ b/bigbluebutton-html5/imports/ui/components/breakout-room/create-breakout-room/breakout-room-user-assignment/room-user-list/styles.ts @@ -2,8 +2,8 @@ import styled from 'styled-components'; import Button from '/imports/ui/components/common/button/component'; import { TextElipsis, TitleElipsis } from '/imports/ui/stylesheets/styled-components/placeholders'; import { + colorBorder, colorWhite, - colorGrayLighter, colorGrayLight, } from '/imports/ui/stylesheets/styled-components/palette'; import { borderSize } from '/imports/ui/stylesheets/styled-components/general'; @@ -124,7 +124,7 @@ const SelectUserScreen = styled.div` const Header = styled.header` display: flex; padding: ${lineHeightComputed} 0; - border-bottom: ${borderSize} solid ${colorGrayLighter}; + border-bottom: ${borderSize} solid ${colorBorder}; margin: 0 1rem 0 1rem; `; diff --git a/bigbluebutton-html5/imports/ui/components/breakout-room/create-breakout-room/styles.ts b/bigbluebutton-html5/imports/ui/components/breakout-room/create-breakout-room/styles.ts index f7b9cf8688e2..1f58be2d31dd 100644 --- a/bigbluebutton-html5/imports/ui/components/breakout-room/create-breakout-room/styles.ts +++ b/bigbluebutton-html5/imports/ui/components/breakout-room/create-breakout-room/styles.ts @@ -8,10 +8,10 @@ import HoldButton from '/imports/ui/components/presentation/presentation-toolbar import Button from '/imports/ui/components/common/button/component'; import { FlexRow, FlexColumn } from '/imports/ui/stylesheets/styled-components/placeholders'; import { + colorBorder, colorDanger, colorGray, colorGrayLight, - colorGrayLighter, colorWhite, colorPrimary, colorBlueLight, @@ -236,7 +236,7 @@ const InputRoomsLabel = styled.label` const GeneralSelect = ` background-color: ${colorWhite}; color: ${colorGray}; - border: 1px solid ${colorGrayLighter}; + border: 1px solid ${colorBorder}; border-radius: ${borderRadius}; width: 100%; padding-top: .25rem; @@ -292,7 +292,7 @@ const LabelText = styled.p` const DurationInput = styled.input` background-color: ${colorWhite}; color: ${colorGray}; - border: 1px solid ${colorGrayLighter}; + border: 1px solid ${colorBorder}; border-radius: ${borderRadius}; width: 100%; text-align: left; diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-editing-warning/styles.ts b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-editing-warning/styles.ts index d55c0b599fe6..b52e7b7cce41 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-editing-warning/styles.ts +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-editing-warning/styles.ts @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import { colorGrayLight, colorWhite } from '/imports/ui/stylesheets/styled-components/palette'; +import { colorBorder, colorGrayLight, colorWhite } from '/imports/ui/stylesheets/styled-components/palette'; import { xlPadding, xsPadding } from '/imports/ui/stylesheets/styled-components/general'; export const Root = styled.div` @@ -49,7 +49,7 @@ export const Cancel = styled.button` } &:focus { - box-shadow: inset 0 0 0.125rem ${colorGrayLight}; + box-shadow: inset 0 0 0.125rem ${colorBorder}; } `; diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-form/styles.ts b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-form/styles.ts index 934d64085ba4..ef8fd934d7d4 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-form/styles.ts +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-form/styles.ts @@ -8,6 +8,7 @@ import { colorGrayDark, colorBorder, colorWhite, + colorPrimary, } from '/imports/ui/stylesheets/styled-components/palette'; import { smPaddingX, @@ -190,7 +191,16 @@ const InputWrapper = styled.div` gap: 3px; cursor: text; + [dir='ltr'] & { + border-radius: 0.75rem 0 0 0.75rem; + } + + [dir='rtl'] & { + border-radius: 0 0.75rem 0.75rem 0; + } + &:focus-within { + border-color: ${colorPrimary}; box-shadow: 0 0 0 ${xsPadding} ${colorBorder}; } diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/text-content/styles.ts b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/text-content/styles.ts index f00a4517244a..a8d914d99c16 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/text-content/styles.ts +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-content/text-content/styles.ts @@ -1,7 +1,7 @@ import styled, { css } from 'styled-components'; import { colorDangerDark, - colorGrayLightest, + colorBorder, colorOffWhite, colorText, } from '/imports/ui/stylesheets/styled-components/palette'; @@ -43,7 +43,7 @@ export const ChatMessage = styled.div` & pre:has(code), p code:not(pre > code) { background-color: ${colorOffWhite}; - border: solid 1px ${colorGrayLightest}; + border: solid 1px ${colorBorder}; border-radius: 4px; padding: 2px; margin: 0; diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-reactions/reaction-item/styles.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-reactions/reaction-item/styles.tsx index 570fee1640bf..58e853b99a73 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-reactions/reaction-item/styles.tsx +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-reactions/reaction-item/styles.tsx @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import { colorGrayLightest, colorOffWhite, colorGrayLighter } from '/imports/ui/stylesheets/styled-components/palette'; +import { colorBorder, colorOffWhite, colorPrimary } from '/imports/ui/stylesheets/styled-components/palette'; const EmojiWrapper = styled.button<{ highlighted: boolean }>` background: none; @@ -8,7 +8,7 @@ const EmojiWrapper = styled.button<{ highlighted: boolean }>` line-height: 1; display: flex; flex-wrap: nowrap; - border: 1px solid ${colorGrayLightest}; + border: 1px solid ${colorBorder}; cursor: pointer; ${({ highlighted }) => highlighted && ` @@ -26,7 +26,7 @@ const EmojiWrapper = styled.button<{ highlighted: boolean }>` } &:hover { - border: 1px solid ${colorGrayLighter}; + border: 1px solid ${colorPrimary}; } `; diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-replied/styles.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-replied/styles.tsx index 40852f0dca32..1e08fecc858c 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-replied/styles.tsx +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-replied/styles.tsx @@ -1,8 +1,9 @@ import styled from 'styled-components'; import { colorDangerDark, + colorBorder, colorGrayLight, - colorGrayLightest, colorOffWhite, colorPrimary, colorText, colorWhite, + colorOffWhite, colorPrimary, colorText, colorWhite, } from '/imports/ui/stylesheets/styled-components/palette'; import { $3xlPadding, smPadding } from '/imports/ui/stylesheets/styled-components/general'; @@ -10,7 +11,7 @@ const Container = styled.div` border-top-left-radius: 0.5rem; border-top-right-radius: 0.5rem; background-color: ${colorWhite}; - box-shadow: inset 0 0 0 1px ${colorGrayLightest}; + box-shadow: inset 0 0 0 1px ${colorBorder}; padding: ${smPadding} ${$3xlPadding}; position: relative; overflow: hidden; @@ -52,7 +53,7 @@ export const HtmlContent = styled.div` & pre:has(code), p code:not(pre > code) { background-color: ${colorOffWhite}; - border: solid 1px ${colorGrayLightest}; + border: solid 1px ${colorBorder}; border-radius: 4px; padding: 2px; margin: 0; diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-toolbar/styles.ts b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-toolbar/styles.ts index 5ff388493c70..8735a83793dc 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-toolbar/styles.ts +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/message-toolbar/styles.ts @@ -1,6 +1,6 @@ import styled, { css } from 'styled-components'; import { - colorGrayLighter, + colorBorder, colorGrayLightest, colorWhite, } from '/imports/ui/stylesheets/styled-components/palette'; @@ -51,7 +51,7 @@ const Container = styled.div` display: flex; border-radius: 1rem; background-color: ${colorWhite}; - box-shadow: 0 0.125rem 0.125rem 0 ${colorGrayLighter}; + box-shadow: 0 0.125rem 0.125rem 0 ${colorBorder}; padding: ${smPadding} ${lgPadding}; gap: ${smPadding}; `; @@ -61,7 +61,7 @@ const EmojiPickerWrapper = styled.div` bottom: calc(100% + 0.5rem); left: 0; right: 0; - border: 1px solid ${colorGrayLighter}; + border: 1px solid ${colorBorder}; border-radius: ${borderRadius}; box-shadow: 0 0.125rem 10px rgba(0,0,0,0.1); z-index: 1000; diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/styles.ts b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/styles.ts index 6451ce04a77a..d74e0f973f47 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/styles.ts +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-message-list/page/chat-message/styles.ts @@ -15,9 +15,10 @@ import { import { colorBlueLightest, colorGrayLight, - colorGrayLightest, + colorBorder, colorGrayDark, colorWhite, + colorSelectedCorrectAnswerText, emphasizedMessageBackgroundColor, highlightedMessageBorderColor, systemMessageBorderColor, @@ -69,7 +70,7 @@ export const ChatWrapper = styled.div` `} ${({ messageHighlight }) => messageHighlight && ` background-color: #fef9f1; - border-left: 2px solid #f5c67f; + border-left: 2px solid ${colorSelectedCorrectAnswerText}; border-radius: 0px 3px 3px 0px; padding: 8px 2px; `} @@ -189,7 +190,7 @@ export const PluginInformationMetadata = styled.div` export const DeleteMessage = styled.span` color: ${colorGrayLight}; padding: ${mdPadding} ${xlPadding}; - border: 1px solid ${colorGrayLightest}; + border: 1px solid ${colorBorder}; border-radius: 0.375rem; `; diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-reply-intention/styles.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-reply-intention/styles.tsx index 1b27b9dc485b..17384ab83915 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-reply-intention/styles.tsx +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/chat-reply-intention/styles.tsx @@ -1,7 +1,7 @@ import styled, { css } from 'styled-components'; import { colorDangerDark, - colorGrayLightest, colorOffWhite, + colorBorder, colorOffWhite, colorPrimary, colorText, colorWhite, @@ -14,7 +14,7 @@ import EmojiButton from '../chat-message-list/page/chat-message/message-toolbar/ const Container = styled.div<{ $hidden: boolean; $animations: boolean }>` border-radius: 0.375rem; background-color: ${colorWhite}; - box-shadow: inset 0 0 0 1px ${colorGrayLightest}; + box-shadow: inset 0 0 0 1px ${colorBorder}; display: flex; align-items: center; overflow: hidden; @@ -87,7 +87,7 @@ const HtmlContent = styled.div` & pre:has(code), p code:not(pre > code) { background-color: ${colorOffWhite}; - border: solid 1px ${colorGrayLightest}; + border: solid 1px ${colorBorder}; border-radius: 4px; padding: 2px; margin: 0; diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/component.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/component.tsx index 0d6cac2587ab..5668c2830a5c 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/component.tsx @@ -293,6 +293,9 @@ const ChatContainer: React.FC = () => { totalUnread: chat.totalUnread, public: chat.public, totalMessages: chat.totalMessages, + lastMessage: chat.lastMessage, + lastMessageAt: chat.lastMessageAt, + lastMessageDeletedByName: chat.lastMessageDeletedByName, }; }) as GraphqlDataHookSubscriptionResponse[]>; diff --git a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/private-chat-list/chat-list-item/component.tsx b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/private-chat-list/chat-list-item/component.tsx index 991dd9f62be8..f7bf385ec36b 100644 --- a/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/private-chat-list/chat-list-item/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/chat/chat-graphql/private-chat-list/chat-list-item/component.tsx @@ -8,17 +8,14 @@ import Styled from './styles'; import PrivateChatListHeader from '../private-chats-header/component'; import { Input, Layout } from '/imports/ui/components/layout/layoutTypes'; import { Chat } from '/imports/ui/Types/chat'; -import { useCreateUseSubscription } from '/imports/ui/core/hooks/createUseSubscription'; -import { Message } from '/imports/ui/Types/message'; -import { GraphqlDataHookSubscriptionResponse } from '/imports/ui/Types/hook'; -import { - CHAT_MESSAGE_PRIVATE_SUBSCRIPTION, -} from '/imports/ui/components/chat/chat-graphql/chat-message-list/page/queries'; const intlMessages = defineMessages({ privateChatUnkownUser: { id: 'app.userList.chatListItem.unknownParticipant', }, + privateChatDeletedMessage: { + id: 'app.chat.deleteMessage', + }, privateChatAriaLabelNoUnread: { id: 'app.userList.chatListItem.noUnread', }, @@ -64,22 +61,6 @@ const PrivateChatListItem = (props: PrivateChatListItemProps) => { const CHAT_CONFIG = window.meetingClientSettings.public.chat; const PUBLIC_GROUP_CHAT_ID = CHAT_CONFIG.public_group_id; - const chatQuery = CHAT_MESSAGE_PRIVATE_SUBSCRIPTION; - const totalMessages = chat.totalMessages || 0; - // Ensure offset is never negative (happens when chat is empty) - const offset = totalMessages > 0 ? totalMessages - 1 : 0; - const defaultVariables = { - offset, - limit: 1, - }; // to get only the last message from private chat - const variables = { ...defaultVariables, requestedChatId: chat.chatId }; - // Skip subscription if chat has no messages - const skipSubscription = totalMessages === 0; - const useChatMessageSubscription = useCreateUseSubscription(chatQuery, variables); - const { - data: chatMessageData, - } = useChatMessageSubscription((msg) => msg, skipSubscription) as GraphqlDataHookSubscriptionResponse; - useEffect(() => { // Clear any previous timeout to prevent multiple executions clearTimeout(chatCountTimeoutRef.current); @@ -136,10 +117,17 @@ const PrivateChatListItem = (props: PrivateChatListItemProps) => { ); const ariaLabel = unreadCount > 0 ? unreadMessagesLabel : noUnreadMessagesLabel; - // Handle empty chats (no messages yet) - const hasMessages = chatMessageData && chatMessageData.length > 0; - const lastMessage = hasMessages ? chatMessageData[0]?.message : ''; - const lastMessageTime = hasMessages ? new Date(chatMessageData[0]?.createdAt) : null; + // The last message preview now comes from the chats subscription itself (issue 25416), + // so it renders with the item on the first paint instead of after a separate per-item + // subscription that mounted the row a moment later and shifted the list. + const hasLastMessage = chat.lastMessageAt != null; + // A soft-deleted last message keeps its row with message=NULL and deletedByUserId set; + // reuse the existing localized "deleted by {userName}" label shown in the message list. + const isLastMessageDeleted = hasLastMessage && chat.lastMessage == null; + const lastMessage = isLastMessageDeleted + ? intl.formatMessage(intlMessages.privateChatDeletedMessage, { userName: chat.lastMessageDeletedByName ?? '' }) + : (chat.lastMessage ?? ''); + const lastMessageTime = chat.lastMessageAt ? new Date(chat.lastMessageAt) : null; return ( { /> )} - {(hasMessages || unreadMessagesToDisplay > 0) && ( + {(hasLastMessage || unreadMessagesToDisplay > 0) && ( {lastMessage} diff --git a/bigbluebutton-html5/imports/ui/components/common/modal/fullscreen/component.jsx b/bigbluebutton-html5/imports/ui/components/common/modal/fullscreen/component.jsx index a203f5ba5d56..94c757dd57e2 100644 --- a/bigbluebutton-html5/imports/ui/components/common/modal/fullscreen/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/common/modal/fullscreen/component.jsx @@ -2,6 +2,11 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl } from 'react-intl'; import Styled from './styles'; +import { + createDocumentTitleViewId, + registerDocumentTitleView, + unregisterDocumentTitleView, +} from '/imports/ui/components/app/document-title-manager/service'; const intlMessages = defineMessages({ modalClose: { @@ -38,6 +43,10 @@ const propTypes = { }), preventClosing: PropTypes.bool, shouldCloseOnOverlayClick: PropTypes.bool, + documentTitle: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.string, + ]), }; const defaultProps = { @@ -50,20 +59,31 @@ const defaultProps = { disabled: false, }, preventClosing: false, + documentTitle: false, }; class ModalFullscreen extends PureComponent { constructor(props) { super(props); this.previousFocus = null; + this.documentTitleViewId = createDocumentTitleViewId('fullscreen-modal'); this.handleAction = this.handleAction.bind(this); } + componentDidMount() { + this.updateDocumentTitleView(); + } + componentDidUpdate(prevProps) { const { isOpen } = this.props; if (!prevProps.isOpen && isOpen) { this.previousFocus = document.activeElement; } + this.updateDocumentTitleView(); + } + + componentWillUnmount() { + unregisterDocumentTitleView(this.documentTitleViewId); } handleAction(name) { @@ -95,6 +115,32 @@ class ModalFullscreen extends PureComponent { }, 0); } + getDocumentTitle() { + const { + documentTitle, + title, + } = this.props; + + if (!documentTitle) return null; + if (typeof documentTitle === 'string') return documentTitle; + return title; + } + + updateDocumentTitleView() { + const { + isOpen, + preventClosing, + } = this.props; + const documentTitle = this.getDocumentTitle(); + + if ((isOpen || preventClosing) && documentTitle) { + registerDocumentTitleView(this.documentTitleViewId, documentTitle); + return; + } + + unregisterDocumentTitleView(this.documentTitleViewId); + } + render() { const { intl, @@ -103,6 +149,7 @@ class ModalFullscreen extends PureComponent { dismiss, className, children, + documentTitle, isOpen, preventClosing, ...otherProps @@ -119,7 +166,7 @@ class ModalFullscreen extends PureComponent { id="fsmodal" isOpen={isOpen || preventClosing} contentLabel={title} - overlayClassName={"fullscreenModalOverlay"} + overlayClassName="fullscreenModalOverlay" {...otherProps} > diff --git a/bigbluebutton-html5/imports/ui/components/common/modal/fullscreen/styles.js b/bigbluebutton-html5/imports/ui/components/common/modal/fullscreen/styles.js index 66467cccc5a2..943c65cbefe0 100644 --- a/bigbluebutton-html5/imports/ui/components/common/modal/fullscreen/styles.js +++ b/bigbluebutton-html5/imports/ui/components/common/modal/fullscreen/styles.js @@ -12,7 +12,7 @@ import { fontSizeLarger, } from '/imports/ui/stylesheets/styled-components/typography'; import { - colorGrayLightest, + colorBorder, colorText, } from '/imports/ui/stylesheets/styled-components/palette'; @@ -34,7 +34,7 @@ const FullscreenModal = styled(Styled.BaseModal)` const Header = styled.header` display: flex; padding: ${lineHeightComputed} 0; - border-bottom: ${borderSize} solid ${colorGrayLightest}; + border-bottom: ${borderSize} solid ${colorBorder}; `; const Title = styled.h1` diff --git a/bigbluebutton-html5/imports/ui/components/common/modal/header/styles.js b/bigbluebutton-html5/imports/ui/components/common/modal/header/styles.js index 47bcb3d9551d..1b8c758c0ca7 100644 --- a/bigbluebutton-html5/imports/ui/components/common/modal/header/styles.js +++ b/bigbluebutton-html5/imports/ui/components/common/modal/header/styles.js @@ -3,8 +3,8 @@ import Button from '/imports/ui/components/common/button/component'; import { TitleElipsis } from '/imports/ui/stylesheets/styled-components/placeholders'; import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints'; import { + colorBorder, colorGrayDark, - colorGrayLighter, colorText, } from '/imports/ui/stylesheets/styled-components/palette'; import { @@ -36,7 +36,7 @@ const Header = styled.header` ${({ $hideBorder }) => !$hideBorder && ` padding: calc(${lineHeightComputed} / 2) 0; - border-bottom: ${borderSize} solid ${colorGrayLighter}; + border-bottom: ${borderSize} solid ${colorBorder}; `} `; diff --git a/bigbluebutton-html5/imports/ui/components/common/modal/simple/component.jsx b/bigbluebutton-html5/imports/ui/components/common/modal/simple/component.jsx index f1c22780debb..b3540d710c29 100755 --- a/bigbluebutton-html5/imports/ui/components/common/modal/simple/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/common/modal/simple/component.jsx @@ -4,6 +4,11 @@ import { defineMessages, injectIntl } from 'react-intl'; import FocusTrap from 'focus-trap-react'; import Styled from './styles'; import deviceInfo from '/imports/utils/deviceInfo'; +import { + createDocumentTitleViewId, + registerDocumentTitleView, + unregisterDocumentTitleView, +} from '/imports/ui/components/app/document-title-manager/service'; const intlMessages = defineMessages({ modalClose: { @@ -29,6 +34,10 @@ const propTypes = { width: PropTypes.string, height: PropTypes.string, padding: PropTypes.string, + documentTitle: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.string, + ]), }; const defaultProps = { @@ -41,6 +50,7 @@ const defaultProps = { overlayClassName: 'modalOverlay', headerPosition: 'inner', modalIsOpen: false, + documentTitle: false, }; class ModalSimple extends Component { @@ -48,6 +58,7 @@ class ModalSimple extends Component { super(props); this.modalRef = React.createRef(); this.previousFocus = null; + this.documentTitleViewId = createDocumentTitleViewId('simple-modal'); this.handleDismiss = this.handleDismiss.bind(this); this.handleRequestClose = this.handleRequestClose.bind(this); this.handleOutsideClick = this.handleOutsideClick.bind(this); @@ -55,6 +66,7 @@ class ModalSimple extends Component { componentDidMount() { document.addEventListener('mousedown', this.handleOutsideClick, false); + this.updateDocumentTitleView(); } componentDidUpdate(prevProps) { @@ -62,10 +74,12 @@ class ModalSimple extends Component { if (!prevProps.modalIsOpen && modalIsOpen) { this.previousFocus = document.activeElement; } + this.updateDocumentTitleView(); } componentWillUnmount() { document.removeEventListener('mousedown', this.handleOutsideClick, false); + unregisterDocumentTitleView(this.documentTitleViewId); } handleDismiss() { @@ -96,6 +110,33 @@ class ModalSimple extends Component { } } + getDocumentTitle() { + const { + contentLabel, + documentTitle, + title, + } = this.props; + + if (!documentTitle) return null; + if (typeof documentTitle === 'string') return documentTitle; + return title || contentLabel || null; + } + + updateDocumentTitleView() { + const { + isOpen, + modalIsOpen, + } = this.props; + const documentTitle = this.getDocumentTitle(); + + if ((isOpen || modalIsOpen) && documentTitle) { + registerDocumentTitleView(this.documentTitleViewId, documentTitle); + return; + } + + unregisterDocumentTitleView(this.documentTitleViewId); + } + render() { const { id, @@ -108,6 +149,7 @@ class ModalSimple extends Component { onRequestClose, shouldShowCloseButton, contentLabel, + documentTitle, headerPosition, 'data-test': dataTest, children, diff --git a/bigbluebutton-html5/imports/ui/components/common/toast/component.jsx b/bigbluebutton-html5/imports/ui/components/common/toast/component.jsx index 1e06523211ea..6fbf23543109 100755 --- a/bigbluebutton-html5/imports/ui/components/common/toast/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/common/toast/component.jsx @@ -1,10 +1,11 @@ import React from 'react'; import PropTypes from 'prop-types'; import Icon from '/imports/ui/components/common/icon/component'; +import { PluginButtonIcon } from '/imports/ui/components/plugins/plugin-icon/styles'; import Styled from './styles'; const propTypes = { - icon: PropTypes.string, + icon: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), message: PropTypes.node.isRequired, }; @@ -16,6 +17,21 @@ const defaultIcons = { default: 'about', }; +const renderIcon = (icon, type) => { + if (icon && typeof icon === 'object' && 'svgContent' in icon) { + return ( + + {icon.svgContent} + + ); + } + + let iconName = icon; + if (icon && typeof icon === 'object' && 'iconName' in icon) iconName = icon.iconName; + + return ; +}; + const Toast = ({ icon = null, type, @@ -31,7 +47,7 @@ const Toast = ({ {icon !== false && ( - + {renderIcon(icon, type)} )} diff --git a/bigbluebutton-html5/imports/ui/components/common/toast/styles.js b/bigbluebutton-html5/imports/ui/components/common/toast/styles.js index b151d6a007df..0b37a1deef72 100644 --- a/bigbluebutton-html5/imports/ui/components/common/toast/styles.js +++ b/bigbluebutton-html5/imports/ui/components/common/toast/styles.js @@ -113,6 +113,14 @@ const ToastIcon = styled.div` `} `; +const ToastCustomIcon = styled.span` + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +`; + const ToastMessage = styled.div` margin-top: auto; margin-bottom: auto; @@ -215,6 +223,7 @@ export default { CloseIcon, ToastContainer, ToastIcon, + ToastCustomIcon, ToastMessage, BackgroundColorInherit, Separator, diff --git a/bigbluebutton-html5/imports/ui/components/connection-status/component.jsx b/bigbluebutton-html5/imports/ui/components/connection-status/component.jsx index 743f781e4716..097e7ad9cf68 100755 --- a/bigbluebutton-html5/imports/ui/components/connection-status/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/connection-status/component.jsx @@ -91,8 +91,8 @@ const ConnectionStatus = ({ connectionStatus.setMinRtt(networkRtt); const clientNowEpoch = Date.now(); const oneWay = networkRtt / 2; // aproximation NTP - // Not allow negative skew - const skew = Math.max(0, ((serverEpochMsec * 1000) + oneWay) - clientNowEpoch); + // Allow negative skew to correct a client clock that is ahead of the server + const skew = ((serverEpochMsec * 1000) + oneWay) - clientNowEpoch; logger.debug({ logCode: 'latency_skew_calc' }, 'Latency between server and client (skew ms): %d (serverEpochMsec=%d, clientNowEpoch=%d, oneWay=%d)', skew, serverEpochMsec, clientNowEpoch, oneWay); setTimeSync(skew); timeSyncRef.current = skew; diff --git a/bigbluebutton-html5/imports/ui/components/connection-status/modal/styles.js b/bigbluebutton-html5/imports/ui/components/connection-status/modal/styles.js index cda4fba2b715..d3f233130eba 100644 --- a/bigbluebutton-html5/imports/ui/components/connection-status/modal/styles.js +++ b/bigbluebutton-html5/imports/ui/components/connection-status/modal/styles.js @@ -1,14 +1,13 @@ import styled from 'styled-components'; import ModalSimple from '/imports/ui/components/common/modal/simple/component'; import { + colorBorder, colorOffWhite, colorGrayDark, - colorGrayLightest, colorPrimary, colorWhite, btnPrimaryActiveBg, colorDanger, - colorBorder, } from '/imports/ui/stylesheets/styled-components/palette'; import { smPaddingX, @@ -41,7 +40,7 @@ const Item = styled.li` display: flex; width: 100%; height: 4rem; - border-bottom: 1px solid ${colorGrayLightest}; + border-bottom: 1px solid ${colorBorder}; ${({ last }) => last && ` border: none; diff --git a/bigbluebutton-html5/imports/ui/components/external-video-player/custom-players/arc-player.jsx b/bigbluebutton-html5/imports/ui/components/external-video-player/custom-players/arc-player.jsx index e87a24da95e0..818fa43497cc 100644 --- a/bigbluebutton-html5/imports/ui/components/external-video-player/custom-players/arc-player.jsx +++ b/bigbluebutton-html5/imports/ui/components/external-video-player/custom-players/arc-player.jsx @@ -1,5 +1,5 @@ -import loadScript from 'load-script'; import React, { Component } from 'react' +import getSDK from './get-sdk'; const MATCH_URL = new RegExp("https?:\/\/(.*)(instructuremedia.com)(\/embed)?\/([-abcdef0-9]+)"); @@ -7,44 +7,6 @@ const SDK_URL = 'https://files.instructuremedia.com/instructure-media-script/ins const EMBED_PATH = "/embed/"; -// Util function to load an external SDK or return the SDK if it is already loaded -// From https://github.com/CookPete/react-player/blob/master/src/utils.js -const resolves = {}; -export function getSDK (url, sdkGlobal, sdkReady = null, isLoaded = () => true, fetchScript = loadScript) { - if (window[sdkGlobal] && isLoaded(window[sdkGlobal])) { - return Promise.resolve(window[sdkGlobal]) - } - return new Promise((resolve, reject) => { - // If we are already loading the SDK, add the resolve - // function to the existing array of resolve functions - if (resolves[url]) { - resolves[url].push(resolve); - return - } - resolves[url] = [resolve]; - const onLoaded = sdk => { - // When loaded, resolve all pending promises - resolves[url].forEach(resolve => resolve(sdk)) - }; - if (sdkReady) { - const previousOnReady = window[sdkReady]; - window[sdkReady] = function () { - if (previousOnReady) previousOnReady(); - onLoaded(window[sdkGlobal]) - } - } - fetchScript(url, err => { - if (err) { - reject(err); - } - window[sdkGlobal] = url; - if (!sdkReady) { - onLoaded(window[sdkGlobal]) - } - }) - }) -} - export class ArcPlayer extends Component { static displayName = 'ArcPlayer'; diff --git a/bigbluebutton-html5/imports/ui/components/external-video-player/custom-players/get-sdk.js b/bigbluebutton-html5/imports/ui/components/external-video-player/custom-players/get-sdk.js new file mode 100644 index 000000000000..024dd85f152d --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/external-video-player/custom-players/get-sdk.js @@ -0,0 +1,54 @@ +import loadScript from 'load-script'; + +// Util function to load an external SDK or return the SDK if it is already loaded +// From https://github.com/CookPete/react-player/blob/master/src/utils.js +const resolves = {}; +const rejects = {}; +export function getSDK(url, sdkGlobal, sdkReady = null, isLoaded = () => true, + fetchScript = loadScript) { + if (window[sdkGlobal] && isLoaded(window[sdkGlobal])) { + return Promise.resolve(window[sdkGlobal]); + } + return new Promise((resolve, reject) => { + // If we are already loading the SDK, add the resolve/reject + // functions to the existing arrays of pending handlers + if (resolves[url]) { + resolves[url].push(resolve); + rejects[url].push(reject); + return; + } + resolves[url] = [resolve]; + rejects[url] = [reject]; + const flushQueues = () => { + const pending = { resolves: resolves[url] || [], rejects: rejects[url] || [] }; + delete resolves[url]; + delete rejects[url]; + return pending; + }; + const onLoaded = (sdk) => { + // When loaded, resolve all pending promises + flushQueues().resolves.forEach((pendingResolve) => pendingResolve(sdk)); + }; + if (sdkReady) { + const previousOnReady = window[sdkReady]; + window[sdkReady] = function onSDKReady() { + if (previousOnReady) previousOnReady(); + onLoaded(window[sdkGlobal]); + }; + } + fetchScript(url, (err) => { + if (err) { + // Reject every pending caller and clear the queues so a later + // getSDK call for the same url starts a fresh load (retryable) + flushQueues().rejects.forEach((pendingReject) => pendingReject(err)); + return; + } + window[sdkGlobal] = url; + if (!sdkReady) { + onLoaded(window[sdkGlobal]); + } + }); + }); +} + +export default getSDK; diff --git a/bigbluebutton-html5/imports/ui/components/external-video-player/custom-players/panopto.jsx b/bigbluebutton-html5/imports/ui/components/external-video-player/custom-players/panopto.jsx index d7a886631b25..b7c8692000af 100644 --- a/bigbluebutton-html5/imports/ui/components/external-video-player/custom-players/panopto.jsx +++ b/bigbluebutton-html5/imports/ui/components/external-video-player/custom-players/panopto.jsx @@ -1,16 +1,215 @@ -const MATCH_URL = /https?\:\/\/([^\/]+\/Panopto)(\/Pages\/Viewer\.aspx\?id=)([-a-zA-Z0-9]+)/; +import React, { Component } from 'react'; +import getSDK from './get-sdk'; -export class Panopto { +// Tenant-agnostic: matches any Panopto host (*.panopto.com, *.panopto.eu, self-hosted). +// The host capture is restricted to hostname characters plus an optional numeric +// port, so userinfo ("user@host") or other URL tricks can never reach the SDK. +// Extra query params after the id (&autoplay=false...) and fragments are allowed. +const MATCH_URL = /^https?:\/\/([a-zA-Z0-9.-]+(?::\d+)?)\/Panopto\/Pages\/Viewer\.aspx\?id=([-a-zA-Z0-9]+)(?:&[^#]*)?(?:#.*)?$/; + +const SDK_URL = 'https://developers.panopto.com/scripts/embedapi.min.js'; + +// The SDK script defines window.EmbedApi; 'PanoptoEmbedApi' is only the +// loaded-flag key used by getSDK (it must differ from the real global, +// which getSDK overwrites with the script URL). +const SDK_GLOBAL = 'PanoptoEmbedApi'; + +const PLAYER_STATE = { + ENDED: 0, + PLAYING: 1, + PAUSED: 2, +}; + +// Instance-specific suffix so two mounted players never share a DOM id +let playerInstanceCount = 0; + +export class PanoptoPlayer extends Component { + static displayName = 'PanoptoPlayer'; static canPlay = url => { return MATCH_URL.test(url) }; - static getSocialUrl(url) { - const m = url.match(MATCH_URL); - return 'https://' + m[1] + '/Podcast/Social/' + m[3] + '.mp4'; + constructor(props) { + super(props); + + this.player = this; + this._player = null; + playerInstanceCount += 1; + this.containerId = `panoptoPlayerContainer-${playerInstanceCount}`; + + this.onIframeReady = this.onIframeReady.bind(this); + this.onReady = this.onReady.bind(this); + this.onStateChange = this.onStateChange.bind(this); + } + + componentDidMount () { + this.props.onMount && this.props.onMount(this) + } + + load() { + new Promise((resolve, reject) => { + this.render(); + resolve(); + }) + .then(() => { return getSDK(SDK_URL, SDK_GLOBAL) }) + .then(() => { + const m = this.props.url.match(MATCH_URL); + + if (!m) { + return; + } + + // The SDK builds the Embed.aspx iframe src itself, only from the + // regex-validated host (m[1]) and session id (m[2]) captured above - + // never from the raw user input - and drives it over postMessage. + this._player = new window.EmbedApi(this.containerId, { + width: '100%', + height: '100%', + serverName: m[1], + sessionId: m[2], + videoParams: { + // same semantics as the youtube playerVars autoplay: 1 - the embed + // starts on share and the presenter's onPlay broadcast follows + autoplay: true, + interactivity: 'none', + showtitle: false, + showbrand: false, + offerviewer: false, + }, + events: { + onIframeReady: this.onIframeReady, + onReady: this.onReady, + onStateChange: this.onStateChange, + }, + }); + }) + .catch((err) => { + if (this.props.onError) { + this.props.onError(err); + } + }); } -} -export default Panopto; + onIframeReady() { + // Dismiss the embed splash screen so the player loads and starts + // emitting state updates (required before any playback control works) + this._player.loadVideo(); + } + + onReady() { + this.props.onReady(); + } + + onStateChange(state) { + if (state === PLAYER_STATE.PLAYING) { + this.props.onPlay(); + } else if (state === PLAYER_STATE.PAUSED) { + this.props.onPause(); + } else if (state === PLAYER_STATE.ENDED) { + this.props.onEnded(); + } + } + + play() { + if (this._player) { + this._player.playVideo(); + } + } + + pause() { + if (this._player) { + this._player.pauseVideo(); + } + } + + stop() { + if (this._player) { + this._player.stopVideo(); + } + } + + seekTo(seconds) { + if (this._player) { + this._player.seekTo(seconds); + } + } + + setVolume(fraction) { + if (this._player) { + this._player.setVolume(fraction); + } + } + + getVolume() { + return this._player?.getVolume() ?? 1; + } + + setLoop(loop) { + } + + mute() { + if (this._player) { + this._player.muteVideo(); + } + } + + unmute() { + if (this._player) { + this._player.unmuteVideo(); + } + } + + isMuted() { + return this._player?.isMuted() ?? false; + } + + getDuration() { + return this._player?.getDuration() ?? 0; + } + + getCurrentTime () { + return this._player?.getCurrentTime() ?? 0; + } + + getSecondsLoaded () { + // The Embed API does not expose buffered ranges + return 0; + } + + getPlaybackRate () { + return this._player?.getPlaybackRate() ?? 1; + } + + setPlaybackRate (rate) { + if (this._player) { + this._player.setPlaybackRate(rate); + } + } + + render () { + const style = { + width: '100%', + height: '100%', + margin: 0, + padding: 0, + border: 0, + overflow: 'hidden', + backgroundColor: 'black', + }; + + return ( +
{ + this.container = container; + }} + > +
+ ) + } +} +export default PanoptoPlayer; diff --git a/bigbluebutton-html5/imports/ui/components/external-video-player/custom-players/peertube.jsx b/bigbluebutton-html5/imports/ui/components/external-video-player/custom-players/peertube.jsx index 49b6da90c31a..04b648641e56 100644 --- a/bigbluebutton-html5/imports/ui/components/external-video-player/custom-players/peertube.jsx +++ b/bigbluebutton-html5/imports/ui/components/external-video-player/custom-players/peertube.jsx @@ -1,49 +1,11 @@ -import loadScript from 'load-script'; import React, { Component } from 'react' +import getSDK from './get-sdk'; //To work with PeerTube >=v3.3 URL patterns const MATCH_URL = new RegExp("(https?)://(.*)(/videos/watch/|/w/)(.*)"); const SDK_URL = 'https://unpkg.com/@peertube/embed-api@0.0.4/build/player.min.js'; -// Util function to load an external SDK or return the SDK if it is already loaded -// From https://github.com/CookPete/react-player/blob/master/src/utils.js -const resolves = {}; -export function getSDK (url, sdkGlobal, sdkReady = null, isLoaded = () => true, fetchScript = loadScript) { - if (window[sdkGlobal] && isLoaded(window[sdkGlobal])) { - return Promise.resolve(window[sdkGlobal]) - } - return new Promise((resolve, reject) => { - // If we are already loading the SDK, add the resolve - // function to the existing array of resolve functions - if (resolves[url]) { - resolves[url].push(resolve); - return - } - resolves[url] = [resolve]; - const onLoaded = sdk => { - // When loaded, resolve all pending promises - resolves[url].forEach(resolve => resolve(sdk)) - }; - if (sdkReady) { - const previousOnReady = window[sdkReady]; - window[sdkReady] = function () { - if (previousOnReady) previousOnReady(); - onLoaded(window[sdkGlobal]) - } - } - fetchScript(url, err => { - if (err) { - reject(err); - } - window[sdkGlobal] = url; - if (!sdkReady) { - onLoaded(window[sdkGlobal]) - } - }) - }) -} - export class PeerTubePlayer extends Component { static displayName = 'PeerTubePlayer'; diff --git a/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/component.tsx b/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/component.tsx index 598623ae568c..c94b7adb4438 100644 --- a/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/external-video-player/external-video-player-graphql/component.tsx @@ -42,6 +42,7 @@ import { calculateCurrentTime } from '/imports/ui/components/external-video-play import PeerTube from '../custom-players/peertube'; import { ArcPlayer } from '../custom-players/arc-player'; +import Panopto from '../custom-players/panopto'; import getStorageSingletonInstance from '/imports/ui/services/storage'; const AUTO_PLAY_BLOCK_DETECTION_TIMEOUT_SECONDS = 5; @@ -97,6 +98,8 @@ interface ExternalVideoPlayerProps { Styled.VideoPlayer.addCustomPlayer(PeerTube); // @ts-ignore - ArcPlayer is not typed Styled.VideoPlayer.addCustomPlayer(ArcPlayer); +// @ts-ignore - Panopto is not typed +Styled.VideoPlayer.addCustomPlayer(Panopto); const truncateTime = (time: number) => (time < 1 ? 0 : time); diff --git a/bigbluebutton-html5/imports/ui/components/external-video-player/service.ts b/bigbluebutton-html5/imports/ui/components/external-video-player/service.ts index 3d1982f284ff..672397e85c3b 100644 --- a/bigbluebutton-html5/imports/ui/components/external-video-player/service.ts +++ b/bigbluebutton-html5/imports/ui/components/external-video-player/service.ts @@ -1,5 +1,6 @@ import ReactPlayer from 'react-player'; import { MutationFunction } from '@apollo/client'; + import { ExternalVideo } from '/imports/ui/Types/meeting'; const YOUTUBE_SHORTS_REGEX = new RegExp(/^(?:https?:\/\/)?(?:www\.)?(youtube\.com\/shorts)\/.+$/); diff --git a/bigbluebutton-html5/imports/ui/components/join-handler/guest-wait/component.tsx b/bigbluebutton-html5/imports/ui/components/join-handler/guest-wait/component.tsx index b0b2f314d895..1cc1582b3f9b 100644 --- a/bigbluebutton-html5/imports/ui/components/join-handler/guest-wait/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/join-handler/guest-wait/component.tsx @@ -94,6 +94,8 @@ const GuestWait: React.FC = (props) => { const lobbyMessageRef = useRef(''); const positionInWaitingQueueRef = useRef(''); const loadingContextInfo = useContext(LoadingContext); + const showPositionInWaitingQueue = window.meetingClientSettings + .public.app.showGuestLobbyWaitingQueuePosition !== false; const updateLobbyMessage = useCallback((message: string | null) => { if (!message) { @@ -172,7 +174,7 @@ const GuestWait: React.FC = (props) => { // WAIT updateLobbyMessage(guestLobbyMessage || ''); - if (positionInWaitingQueue) { + if (showPositionInWaitingQueue && positionInWaitingQueue) { updatePositionInWaitingQueue(positionInWaitingQueue); } }, [ @@ -181,6 +183,7 @@ const GuestWait: React.FC = (props) => { logoutUrl, positionInWaitingQueue, intl, + showPositionInWaitingQueue, updateLobbyMessage, updatePositionInWaitingQueue, ]); @@ -191,9 +194,11 @@ const GuestWait: React.FC = (props) => { {intl.formatMessage(intlMessages.windowTitle)} - -

{positionMessage}

-
+ {showPositionInWaitingQueue && ( + +

{positionMessage}

+
+ )} {hasCustomMessage && ( diff --git a/bigbluebutton-html5/imports/ui/components/nav-bar/component.jsx b/bigbluebutton-html5/imports/ui/components/nav-bar/component.jsx index 610a4442402a..585392916ce3 100755 --- a/bigbluebutton-html5/imports/ui/components/nav-bar/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/nav-bar/component.jsx @@ -19,16 +19,14 @@ import Icon from '/imports/ui/components/common/icon/icon-ts/component'; import { PluginButtonIcon } from '/imports/ui/components/plugins/plugin-icon/styles'; import SessionStorage from '../../services/storage/session'; import { ModalRegistration } from '../../core/singletons/modalController'; +import browserInfo from '/imports/utils/browserInfo'; +import deviceInfo from '/imports/utils/deviceInfo'; const intlMessages = defineMessages({ defaultBreakoutName: { id: 'app.createBreakoutRoom.room', description: 'default breakout room name', }, - leaveMeetingLabel: { - id: 'app.navBar.leaveMeetingBtnLabel', - description: 'Leave meeting button label', - }, openDetailsTooltip: { id: 'app.navBar.openDetailsTooltip', description: 'Open details tooltip', @@ -48,6 +46,7 @@ const propTypes = { breakoutNum: PropTypes.number, breakoutName: PropTypes.string, meetingName: PropTypes.string, + shortcuts: PropTypes.string, pluginNavBarItems: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string, })).isRequired, @@ -84,8 +83,12 @@ const renderPluginItems = (pluginItems) => { disabled={pluginItem.disabled} label={pluginItem.label} aria-label={pluginItem.tooltip} - color="primary" - tooltip={pluginItem.tooltip} + color={pluginItem.color || 'primary'} + circle={pluginItem.circle === true} + hideLabel={pluginItem.hideLabel === true} + size={pluginItem.size || 'md'} + style={pluginItem.style} + tooltipLabel={pluginItem.tooltip} onClick={pluginItem.onClick} dataTest={pluginItem.dataTest} {...navBarIconProps} @@ -157,6 +160,7 @@ class NavBar extends Component { breakoutNum, breakoutName, meetingName, + shortcuts: TOGGLE_USERLIST_AK, } = this.props; if (breakoutNum && breakoutNum > 0) { @@ -172,6 +176,21 @@ class NavBar extends Component { } } } + + const { isFirefox } = browserInfo; + const { isMacos } = deviceInfo; + + // accessKey U does not work on firefox for macOS for some unknown reason + if (isMacos && isFirefox && TOGGLE_USERLIST_AK === 'U') { + document.addEventListener('keyup', (event) => { + const { key, code } = event; + const eventKey = key?.toUpperCase(); + const eventCode = code; + if (event?.altKey && (eventKey === TOGGLE_USERLIST_AK || eventCode === `Key${TOGGLE_USERLIST_AK}`)) { + this.handleToggleUserList(); + } + }); + } } componentDidUpdate() { diff --git a/bigbluebutton-html5/imports/ui/components/nav-bar/container.jsx b/bigbluebutton-html5/imports/ui/components/nav-bar/container.jsx index 5f320d306190..fa51f1a9fdd2 100755 --- a/bigbluebutton-html5/imports/ui/components/nav-bar/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/nav-bar/container.jsx @@ -1,8 +1,8 @@ import React, { useContext } from 'react'; import { defineMessages, useIntl } from 'react-intl'; +import { useReactiveVar } from '@apollo/client'; import Auth from '/imports/ui/services/auth'; import getFromUserSettings from '/imports/ui/services/users-settings'; -import { useReactiveVar } from '@apollo/client'; import NavBar from './component'; import { layoutDispatch, layoutSelectOutput } from '../layout/context'; import { PluginsContext } from '/imports/ui/components/components-data/plugin-context/context'; @@ -33,7 +33,6 @@ const NavBarContainer = (props) => { const hideNavBar = getFromUserSettings('bbb_hide_nav_bar', false); const PUBLIC_CONFIG = window.meetingClientSettings.public; - const CLIENT_TITLE = getFromUserSettings('bbb_client_title', PUBLIC_CONFIG.app.clientTitle); const IS_DIRECT_LEAVE_BUTTON_ENABLED = getFromUserSettings( 'bbb_direct_leave_button', PUBLIC_CONFIG.app.defaultSettings.application.directLeaveButton, @@ -44,32 +43,15 @@ const NavBarContainer = (props) => { ); let meetingTitle; - let breakoutNum; - let breakoutName; - let meetingName; const connected = useReactiveVar(connectionStatus.getConnectedStatusVar()); const { data: meeting } = useMeeting((m) => ({ name: m.name, meetingId: m.meetingId, - breakoutPolicies: { - sequence: m.breakoutPolicies.sequence, - }, })); if (meeting) { meetingTitle = meeting.name; - const titleString = `${CLIENT_TITLE} - ${meetingTitle}`; - document.title = titleString; - registerTitleView(intl.formatMessage(intlMessages.defaultViewLabel)); - - if (meeting.breakoutPolicies) { - breakoutNum = meeting.breakoutPolicies.sequence; - if (breakoutNum > 0) { - breakoutName = meetingTitle; - meetingName = meetingTitle.replace(`(${breakoutName})`, '').trim(); - } - } } if (hideNavBar || navBar.display === false) return null; @@ -90,9 +72,6 @@ const NavBarContainer = (props) => { pluginNavBarItems, meetingId: meeting?.meetingId, presentationTitle: meetingTitle, - breakoutNum, - breakoutName, - meetingName, isDirectLeaveButtonEnabled: IS_DIRECT_LEAVE_BUTTON_ENABLED, // TODO: Remove/Replace isConnected: connected, diff --git a/bigbluebutton-html5/imports/ui/components/notes/component.tsx b/bigbluebutton-html5/imports/ui/components/notes/component.tsx index 7de75f623b6e..b61c4bcd7ae3 100644 --- a/bigbluebutton-html5/imports/ui/components/notes/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/notes/component.tsx @@ -20,6 +20,7 @@ import { layoutSelect, } from '/imports/ui/components/layout/context'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; +import useMeeting from '/imports/ui/core/hooks/useMeeting'; import useHasPermission from './hooks/useHasPermission'; import Styled from './styles'; import { PIN_NOTES } from './mutations'; @@ -40,6 +41,7 @@ import { NotesRenderModeType } from './types'; import { NOTES_ID, NOTES_UNMOUNT_DELAY } from './service'; import { GET_PAD_ID, GetPadIdQueryResponse } from './queries'; import BlockNoteContainer from '../bn-shared-notes/component'; +import { SharedNotesImportContext } from '../bn-shared-notes/import-context'; const intlMessages = defineMessages({ title: { @@ -70,6 +72,7 @@ interface NotesGraphqlProps { ignoreDelayforUnmount: boolean; isRTL: boolean; handlePinSharedNotes: (pinned: boolean) => void; + shouldShowSharedNotesOnPresentationArea: boolean; } const NotesGraphql: React.FC = (props) => { @@ -86,13 +89,23 @@ const NotesGraphql: React.FC = (props) => { amIPresenter, ignoreDelayforUnmount, handlePinSharedNotes, + shouldShowSharedNotesOnPresentationArea, } = props; const [shouldRenderNotes, setShouldRenderNotes] = useState(isVisible); + const [isImportModalOpen, setIsImportModalOpen] = useState(false); const intl = useIntl(); const isHidden = (isOnMediaArea && (sharedNotesOutput.width === 0 || sharedNotesOutput.height === 0)) || (!isVisible && !ignoreDelayforUnmount); + // Shared between the kebab menu (opens the modal) and the BlockNote editor + // (renders the modal and applies the import). See import-context.tsx. + const importContextValue = React.useMemo(() => ({ + isImportModalOpen, + openImportModal: () => setIsImportModalOpen(true), + closeImportModal: () => setIsImportModalOpen(false), + }), [isImportModalOpen]); + const timeoutRef = useRef(); useEffect(() => { if (isVisible) { @@ -145,13 +158,14 @@ const NotesGraphql: React.FC = (props) => { const isEtherpadSharedNotes = sharedNotesEditor === 'etherpad'; - return shouldRenderNotes && ( - + return (shouldRenderNotes || shouldShowSharedNotesOnPresentationArea) && ( + + {!isOnMediaArea ? ( <> = (props) => { closeButtonDataTest="hideNotesLabel" customRightButton={( )} @@ -182,8 +196,9 @@ const NotesGraphql: React.FC = (props) => { amIPresenter={amIPresenter} isVisible={isVisible} /> - ) : } - + ) : } + + ); }; @@ -196,6 +211,10 @@ const NotesContainerGraphql: React.FC = (props) => { presenter: user.presenter, })); + const { data: currentMeeting } = useMeeting((meeting) => ({ + componentsFlags: meeting.componentsFlags, + })); + const cameraDock = layoutSelectInput((i: Input) => i.cameraDock); const sharedNotesOutput = layoutSelectOutput((i: Output) => i.sharedNotes); const sidebarContent = layoutSelectInput((i: Input) => i.sidebarContent); @@ -216,6 +235,9 @@ const NotesContainerGraphql: React.FC = (props) => { const isOnMediaArea = renderMode === NotesRenderMode.PINNED; const isGridLayout = useStorageKey('isGridEnabled'); + const shouldShowSharedNotesOnPresentationArea = isGridLayout ? !!currentMeeting?.componentsFlags?.isSharedNotesPinned + && isSidebarContentOpen : !!currentMeeting?.componentsFlags?.isSharedNotesPinned; + const [pinSharedNotes] = useMutation(PIN_NOTES); const [stopExternalVideoShare] = useMutation(EXTERNAL_VIDEO_STOP); const isScreenBroadcasting = useIsScreenBroadcasting(); @@ -255,6 +277,7 @@ const NotesContainerGraphql: React.FC = (props) => { amIPresenter={amIPresenter} isRTL={isRTL} handlePinSharedNotes={handlePinSharedNotes} + shouldShowSharedNotesOnPresentationArea={shouldShowSharedNotesOnPresentationArea} /> ); }; diff --git a/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/component.tsx b/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/component.tsx index fb553ec3f250..49e5f0378692 100644 --- a/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/component.tsx +++ b/bigbluebutton-html5/imports/ui/components/notes/notes-dropdown/component.tsx @@ -15,6 +15,7 @@ import useDeduplicatedSubscription from '/imports/ui/core/hooks/useDeduplicatedS import { useIsPresentationEnabled } from '/imports/ui/services/features'; import { NOTES_ARE_PINNABLE } from '../service'; import Auth from '/imports/ui/services/auth'; +import { useSharedNotesImport } from '../../bn-shared-notes/import-context'; const DEBOUNCE_TIMEOUT = 15000; @@ -27,6 +28,14 @@ const intlMessages = defineMessages({ id: 'app.notes.notesDropdown.exportAsPDF', description: 'Export shared notes as a PDF file', }, + exportAsMarkdownLabel: { + id: 'app.notes.notesDropdown.exportAsMarkdown', + description: 'Export shared notes as a Markdown file', + }, + importFromMarkdownLabel: { + id: 'app.notes.notesDropdown.importFromMarkdown', + description: 'Import shared notes content from Markdown', + }, pinNotes: { id: 'app.notes.notesDropdown.pinNotes', description: 'Label for pin shared notes button', @@ -57,15 +66,33 @@ const NotesDropdownGraphql: React.FC = (props) => { } = props; const [converterButtonDisabled, setConverterButtonDisabled] = useState(false); const intl = useIntl(); + const { openImportModal } = useSharedNotesImport(); + // meetingClientSettings is augmented onto Window; globalThis needs the cast to see it. + const clientSettings = (globalThis as unknown as Window).meetingClientSettings; + const IMPORT_MARKDOWN_ENABLED = clientSettings.public.sharedNotes.importMarkdownEnabled; + const EXPORT_MARKDOWN_ENABLED = clientSettings.public.sharedNotes.exportMarkdownEnabled; const getAvailableActions = () => { const uploadIcon = 'upload'; const pinIcon = 'presentation'; const downloadIcon = 'download'; + const importIcon = 'copy'; const menuItems = []; if (amIPresenter) { + if (!isEtherpadSharedNotes && IMPORT_MARKDOWN_ENABLED) { + menuItems.push( + { + key: uniqueId('notes-option-'), + icon: importIcon, + dataTest: 'importNotesFromMarkdown', + label: intl.formatMessage(intlMessages.importFromMarkdownLabel), + onClick: () => openImportModal(), + }, + ); + } + menuItems.push( { key: uniqueId('notes-option-'), @@ -98,6 +125,20 @@ const NotesDropdownGraphql: React.FC = (props) => { }, }, ); + + if (EXPORT_MARKDOWN_ENABLED) { + menuItems.push( + { + key: uniqueId('notes-option-'), + icon: downloadIcon, + dataTest: 'exportNotesAsMarkdown', + label: intl.formatMessage(intlMessages.exportAsMarkdownLabel), + onClick: () => { + window.open(`https://${hocuspocusServerHostname}/hocuspocus/api/documents/${padId}/export/md?sessionToken=${sessionToken}`); + }, + }, + ); + } } if (amIPresenter && NOTES_ARE_PINNABLE()) { diff --git a/bigbluebutton-html5/imports/ui/components/poll/styles.ts b/bigbluebutton-html5/imports/ui/components/poll/styles.ts index ce8f5ee84815..235a0a658c01 100644 --- a/bigbluebutton-html5/imports/ui/components/poll/styles.ts +++ b/bigbluebutton-html5/imports/ui/components/poll/styles.ts @@ -27,8 +27,8 @@ import { import { colorText, colorBlueLight, + colorBorder, colorGray, - colorGrayLight, colorGrayLighter, colorGrayLightest, colorDanger, @@ -54,7 +54,6 @@ import { colorSelectedCorrectAnswerBgActive, colorGreen600, colorGreen100, - colorBlueLighter, colorBlueLightest, } from '/imports/ui/stylesheets/styled-components/palette'; import { @@ -92,7 +91,7 @@ const PollOptionInput = styled.input` &:focus { outline: none; border-radius: ${borderSize}; - box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary}; + box-shadow: 0 0 0 ${borderSize} ${colorPrimary}, inset 0 0 0 1px ${colorPrimary}; } width: 100%; @@ -101,8 +100,8 @@ const PollOptionInput = styled.input` padding: calc(${smPaddingY} * 2) ${smPaddingX}; border-radius: ${borderRadius}; font-size: ${fontSizeBase}; - border: 1px solid ${colorGrayLighter}; - box-shadow: 0 0 0 1px ${colorGrayLighter}; + border: 1px solid ${colorBorder}; + box-shadow: 0 0 0 1px ${colorBorder}; ${({ isCorrect }) => isCorrect && ` background-color: rgb(240, 253, 244); @@ -145,7 +144,7 @@ const PollQuestionArea = styled.textarea` &:focus { outline: none; border-radius: ${borderSize}; - box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary}; + box-shadow: 0 0 0 ${borderSize} ${colorPrimary}, inset 0 0 0 1px ${colorPrimary}; } width: 100%; @@ -154,8 +153,8 @@ const PollQuestionArea = styled.textarea` padding: calc(${smPaddingY} * 2) ${smPaddingX}; border-radius: ${borderRadius}; font-size: ${fontSizeBase}; - border: 1px solid ${colorGrayLighter}; - box-shadow: 0 0 0 1px ${colorGrayLighter}; + border: 1px solid ${colorBorder}; + box-shadow: 0 0 0 1px ${colorBorder}; ${({ hasError }) => hasError && ` border-color: ${colorDanger}; @@ -191,7 +190,7 @@ const ResponseType = styled.div` // @ts-ignore - Button is a JS Component const PollConfigButton = styled(Button)` - border: solid ${colorGrayLight} 1px; + border: solid ${colorBorder} 1px; min-height: ${pollInputHeight}; font-size: ${fontSizeBase}; white-space: pre-wrap; @@ -438,7 +437,7 @@ const AnonymousRow = styled(Row)` const ResultLeft = styled.td` padding: 0 .5rem 0 0; - border-bottom: 1px solid ${colorGrayLightest}; + border-bottom: 1px solid ${colorBorder}; [dir="rtl"] & { padding: 0 0 0 .5rem; @@ -477,14 +476,14 @@ const Left = styled.div` const Center = styled.div` position: relative; flex: 3; - border-left: 1px solid ${colorGrayLighter}; + border-left: 1px solid ${colorBorder}; border-right : none; width: 100%; height: 100%; [dir="rtl"] & { border-left: none; - border-right: 1px solid ${colorGrayLighter}; + border-right: 1px solid ${colorBorder}; } padding: ${smPaddingY}; @@ -719,7 +718,7 @@ const QuizCorrectAnswerCheckbox = styled.input` appearance: none; aspect-ratio: 1; background: var(--backgroundColor, Field); - border: 1px solid var(--borderColor, ${colorGrayLight}); + border: 1px solid var(--borderColor, ${colorBorder}); border-radius: 50%; box-sizing: border-box; font-size: 1em; @@ -764,7 +763,7 @@ const InfoBoxContainer = styled.div` color: ${colorBlueLight}; background-color: ${colorBlueLightest}; - border: 1px solid ${colorBlueLighter}; + border: 1px solid ${colorPrimary}; ${({ isQuiz }) => isQuiz && ` background-color: ${colorInfoBoxQuizBg}; diff --git a/bigbluebutton-html5/imports/ui/components/polling/styles.ts b/bigbluebutton-html5/imports/ui/components/polling/styles.ts index 0a554674a349..33ada47c16c2 100644 --- a/bigbluebutton-html5/imports/ui/components/polling/styles.ts +++ b/bigbluebutton-html5/imports/ui/components/polling/styles.ts @@ -23,9 +23,7 @@ import { } from '/imports/ui/stylesheets/styled-components/typography'; import { colorText, - colorBlueLight, - colorGrayLighter, - colorOffWhite, + colorBorder, colorGrayDark, colorWhite, colorPrimary, @@ -76,7 +74,7 @@ const TypedResponseInput = styled.input` &:focus { outline: none; border-radius: ${borderSize}; - box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, + box-shadow: 0 0 0 ${borderSize} ${colorPrimary}, inset 0 0 0 1px ${colorPrimary}; } @@ -85,8 +83,8 @@ const TypedResponseInput = styled.input` padding: calc(${smPaddingY} * 2.5) calc(${smPaddingX} * 1.25); border-radius: ${borderRadius}; font-size: ${fontSizeBase}; - border: 1px solid ${colorGrayLighter}; - box-shadow: 0 0 0 1px ${colorGrayLighter}; + border: 1px solid ${colorBorder}; + box-shadow: 0 0 0 1px ${colorBorder}; margin-bottom: 1rem; `; @@ -158,7 +156,7 @@ const PollingContainer = styled.aside<{ autoWidth: boolean }>` position: absolute; z-index: ${pollIndex}; - border: 1px solid ${colorOffWhite}; + border: 1px solid ${colorBorder}; border-radius: ${borderRadius}; box-shadow: ${colorGrayDark} 0px 0px ${lgPaddingY}; align-items: center; diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/component.jsx index fd7864363363..9fdfc6d60df4 100755 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/component.jsx @@ -252,6 +252,10 @@ class PresentationToolbar extends PureComponent { key={ppbId} style={{ marginLeft: '2px', ...ppb.style }} label={ppb.label} + color={ppb.color || 'default'} + circle={ppb.circle === true} + hideLabel={ppb.hideLabel === true} + size={ppb.size || 'md'} onClick={ppb.onClick} tooltipLabel={ppb.tooltip} dataTest={ppb.dataTest} @@ -343,9 +347,6 @@ class PresentationToolbar extends PureComponent { allowInfiniteWhiteboard, allowInfiniteWhiteboardInBreakouts, infiniteWhiteboardIcon, - resetSlide, - zoomChanger, - tldrawAPI, maxNumberOfActiveUsers, numberOfJoinedUsers, isMobile, diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/styles.js b/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/styles.js index 0315fa8ba53a..709f7ebfd8ed 100644 --- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/styles.js +++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-toolbar/styles.js @@ -3,10 +3,10 @@ import QuickPollDropdownContainer from '/imports/ui/components/actions-bar/quick import { colorPrimary, colorOffWhite, - colorBlueLightest, toolbarButtonColor, colorWhite, colorGrayDark, + colorBlueLightest, toolbarButtonColorDisabled, } from '/imports/ui/stylesheets/styled-components/palette'; import { @@ -28,6 +28,7 @@ const PresentationToolbarWrapper = styled.div` background-color: ${colorOffWhite}; border-top: 1px solid ${colorBlueLightest}; border-radius: 0 0 ${lgBorderRadius} ${lgBorderRadius}; + min-width: fit-content; width: 100%; bottom: 0px; display: grid; diff --git a/bigbluebutton-html5/imports/ui/components/settings/component.jsx b/bigbluebutton-html5/imports/ui/components/settings/component.jsx index ac9fcf9f4f6c..8039ad5f274a 100644 --- a/bigbluebutton-html5/imports/ui/components/settings/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/settings/component.jsx @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; + import React, { Component } from 'react'; import { defineMessages, injectIntl } from 'react-intl'; import Langmap from 'langmap'; import About from '/imports/ui/components/settings/submenus/about/component'; @@ -412,6 +412,26 @@ class Settings extends Component { width={modalWidth} height={modalHeight} modalIsOpen={isOpen} + documentTitle={intl.formatMessage(intlMessages.SettingsLabel)} + confirm={{ + callback: () => { + this.updateSettings(current, intlMessages.savedAlertLabel, setLocalSettings); + + if (saved.application.locale !== current.application.locale) { + const { language } = formatLocaleCode(saved.application.locale); + const newLanguage = current.application.locale; + setUseCurrentLocale(newLanguage); + document.body.classList.remove(`lang-${language}`); + } + + /* We need to use setIsOpen(false) here to prevent submenu state updates, + * from re-opening the modal. + */ + setIsOpen(false); + }, + label: intl.formatMessage(intlMessages.SaveLabel), + description: intl.formatMessage(intlMessages.SaveLabelDesc), + }} dismiss={{ callback: this.handleClose, }} diff --git a/bigbluebutton-html5/imports/ui/components/settings/submenus/application/styles.js b/bigbluebutton-html5/imports/ui/components/settings/submenus/application/styles.js index 10221c32ac66..ee479149319f 100644 --- a/bigbluebutton-html5/imports/ui/components/settings/submenus/application/styles.js +++ b/bigbluebutton-html5/imports/ui/components/settings/submenus/application/styles.js @@ -1,9 +1,9 @@ import styled from 'styled-components'; import { + colorBorder, colorGrayLabel, colorPrimary, colorWhite, - colorBorder, } from '/imports/ui/stylesheets/styled-components/palette'; import { borderSize, borderSizeLarge, lgBorderRadius } from '/imports/ui/stylesheets/styled-components/general'; import SpinnerStyles from '/imports/ui/components/common/loading-screen/styles'; @@ -93,6 +93,7 @@ const LocalesDropdownSelect = styled.div` background-color: ${colorWhite}; border: ${borderSize} solid ${colorBorder}; border-radius: ${lgBorderRadius}; + border-bottom: 0.1rem solid ${colorBorder}; color: ${colorGrayLabel}; width: 100%; height: 3.5rem; diff --git a/bigbluebutton-html5/imports/ui/components/settings/submenus/styles.js b/bigbluebutton-html5/imports/ui/components/settings/submenus/styles.js index 7e835fe3b2a8..29c1892be80b 100644 --- a/bigbluebutton-html5/imports/ui/components/settings/submenus/styles.js +++ b/bigbluebutton-html5/imports/ui/components/settings/submenus/styles.js @@ -1,10 +1,10 @@ import styled from 'styled-components'; import { + colorBorder, colorGrayDark, colorGrayLabel, colorPrimary, colorWhite, - colorGrayLighter, } from '/imports/ui/stylesheets/styled-components/palette'; import { Switch } from '@mui/material'; import { styled as materialStyled } from '@mui/material/styles'; @@ -83,7 +83,7 @@ const Select = styled.select` background-color: ${colorWhite}; border: ${borderSize} solid ${colorWhite}; border-radius: ${borderSize}; - border-bottom: 0.1rem solid ${colorGrayLighter}; + border-bottom: 0.1rem solid ${colorBorder}; color: ${colorGrayLabel}; width: 100%; height: 1.75rem; diff --git a/bigbluebutton-html5/imports/ui/components/text-input/styles.js b/bigbluebutton-html5/imports/ui/components/text-input/styles.js index 92f4fd0fa658..8cee10c8c048 100644 --- a/bigbluebutton-html5/imports/ui/components/text-input/styles.js +++ b/bigbluebutton-html5/imports/ui/components/text-input/styles.js @@ -7,8 +7,7 @@ import { } from '/imports/ui/stylesheets/styled-components/general'; import { colorText, - colorGrayLighter, - colorBlueLight, + colorBorder, colorPrimary, } from '/imports/ui/stylesheets/styled-components/palette'; import { fontSizeBase } from '/imports/ui/stylesheets/styled-components/typography'; @@ -34,8 +33,8 @@ const TextArea = styled(TextareaAutosize)` font-size: ${fontSizeBase}; min-height: 2.5rem; max-height: 10rem; - border: 1px solid ${colorGrayLighter}; - box-shadow: 0 0 0 1px ${colorGrayLighter}; + border: 1px solid ${colorBorder}; + box-shadow: 0 0 0 1px ${colorBorder}; &:hover { outline: transparent; @@ -53,7 +52,7 @@ const TextArea = styled(TextareaAutosize)` &:focus { outline: none; border-radius: ${borderSize}; - box-shadow: 0 0 0 ${borderSize} ${colorBlueLight}, inset 0 0 0 1px ${colorPrimary}; + box-shadow: 0 0 0 ${borderSize} ${colorPrimary}, inset 0 0 0 1px ${colorPrimary}; } `; diff --git a/bigbluebutton-html5/imports/ui/components/video-preview/styles.ts b/bigbluebutton-html5/imports/ui/components/video-preview/styles.ts index 535d934f4c0a..948415f75acd 100644 --- a/bigbluebutton-html5/imports/ui/components/video-preview/styles.ts +++ b/bigbluebutton-html5/imports/ui/components/video-preview/styles.ts @@ -8,6 +8,7 @@ import { lgPaddingY, } from '/imports/ui/stylesheets/styled-components/general'; import { + colorBorder, colorGrayLabel, colorWhite, colorBlack, @@ -123,7 +124,7 @@ const Select = styled.select` background-color: ${colorWhite}; border: ${borderSize} solid ${colorWhite}; border-radius: ${borderSize}; - border-bottom: 0.1rem solid ${colorGrayLighter}; + border-bottom: 0.1rem solid ${colorBorder}; color: ${colorGrayLabel}; width: 100%; height: 1.75rem; diff --git a/bigbluebutton-html5/imports/ui/components/video-preview/virtual-background/styles.js b/bigbluebutton-html5/imports/ui/components/video-preview/virtual-background/styles.js index a375940b2646..55b96251ba5a 100644 --- a/bigbluebutton-html5/imports/ui/components/video-preview/virtual-background/styles.js +++ b/bigbluebutton-html5/imports/ui/components/video-preview/virtual-background/styles.js @@ -6,11 +6,11 @@ import { smPaddingY, } from '/imports/ui/stylesheets/styled-components/general'; import { + colorBorder, userThumbnailBorder, btnPrimaryBorder, btnDefaultColor, colorGrayLabel, - colorGrayLighter, colorPrimary, colorWhite, } from '/imports/ui/stylesheets/styled-components/palette'; @@ -93,7 +93,7 @@ const Select = styled.select` background-color: ${colorWhite}; border: ${borderSize} solid ${colorWhite}; border-radius: ${borderSize}; - border-bottom: 0.1rem solid ${colorGrayLighter}; + border-bottom: 0.1rem solid ${colorBorder}; color: ${colorGrayLabel}; width: 100%; height: 1.75rem; diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx index ca91a278bbec..3d8cd9f6b82e 100644 --- a/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/whiteboard/component.jsx @@ -2266,6 +2266,17 @@ const Whiteboard = React.memo((props) => { React.useEffect(() => { const formattedPageId = parseInt(curPageIdRef.current, 10); if (tlEditorRef.current && formattedPageId !== 0) { + // If a viewer is mid-edit (select.editing_shape) when the slide changes, + // the store mutation below (cleanupStore + setCurrentPage) removes the shape + // being edited out from under tldraw, leaving the editor in editing_shape with + // a dangling editingShapeId. The next pointer-down then hits EditingShape's + // `Expected an editing shape!` assertion and crashes the client (issue 25332). + // Commit the in-progress edit first so tldraw exits editing_shape (running + // EditingShape.onExit) while the shape still exists. Guarded so a normal slide + // change (no active edit) never resets the presenter's current tool. + if (tlEditorRef.current.getEditingShape()) { + tlEditorRef.current.complete(); + } tlEditorRef.current.store.mergeRemoteChanges(() => { tlEditorRef.current.batch(() => { const currentPageId = `page:${formattedPageId}`; diff --git a/bigbluebutton-html5/imports/ui/core/graphql/queries/chatSubscription.ts b/bigbluebutton-html5/imports/ui/core/graphql/queries/chatSubscription.ts index 266543e905ae..4c0621a646bd 100644 --- a/bigbluebutton-html5/imports/ui/core/graphql/queries/chatSubscription.ts +++ b/bigbluebutton-html5/imports/ui/core/graphql/queries/chatSubscription.ts @@ -23,6 +23,9 @@ const CHATS_SUBSCRIPTION = gql` totalUnread public lastSeenAt + lastMessage + lastMessageAt + lastMessageDeletedByName pinnedMessageId pinnedAt pinnedBy { diff --git a/bigbluebutton-html5/imports/ui/core/initial-values/meetingClientSettings.ts b/bigbluebutton-html5/imports/ui/core/initial-values/meetingClientSettings.ts index 73b682ad34d6..4ed9b38b5ae7 100644 --- a/bigbluebutton-html5/imports/ui/core/initial-values/meetingClientSettings.ts +++ b/bigbluebutton-html5/imports/ui/core/initial-values/meetingClientSettings.ts @@ -42,6 +42,7 @@ export const meetingClientSettingsInitialValues: MeetingClientSettings = { skipMeetingEnded: false, dynamicGuestPolicy: true, enableGuestLobbyMessage: true, + showGuestLobbyWaitingQueuePosition: true, guestPolicyExtraAllowOptions: false, alwaysShowWaitingRoomUI: true, enableLimitOfViewersInWebcam: false, @@ -226,6 +227,8 @@ export const meetingClientSettingsInitialValues: MeetingClientSettings = { maxDocumentChars: 99999, maxLengthForContentUpdate: 512, staticFormattingToolbar: true, + importMarkdownEnabled: false, + exportMarkdownEnabled: false, }, externalVideoPlayer: { enabled: true, diff --git a/bigbluebutton-html5/imports/ui/stylesheets/styled-components/palette.js b/bigbluebutton-html5/imports/ui/stylesheets/styled-components/palette.js index 87dd7ce872d0..8ed6c18b20fd 100644 --- a/bigbluebutton-html5/imports/ui/stylesheets/styled-components/palette.js +++ b/bigbluebutton-html5/imports/ui/stylesheets/styled-components/palette.js @@ -49,7 +49,7 @@ const colorLink = `var(--color-link, ${colorPrimary})`; const listItemBgHover = `var(--list-item-bg-hover, ${colorBlueAux})`; const colorTipBg = 'var(--color-tip-bg, #333333)'; -const itemFocusBorder = `var(--item-focus-border, ${colorBlueLighter})`; +const itemFocusBorder = `var(--item-focus-border, ${colorPrimary})`; const btnDefaultColor = `var(--btn-default-color, ${colorGray})`; const btnDefaultBg = `var(--btn-default-bg, ${colorWhite})`; @@ -93,15 +93,15 @@ const btnMutedBg = `var(--btn-muted-bg, ${colorMutedBackground})`; const toolbarButtonColor = `var(--toolbar-button-color, ${btnDefaultColor})`; const toolbarButtonColorDisabled = `var(--toolbar-button-color, ${colorGrayLight})`; -const userThumbnailBorder = `var(--user-thumbnail-border, ${colorGrayLight})`; +const userThumbnailBorder = `var(--user-thumbnail-border, ${colorBorder})`; const loaderBg = `var(--loader-bg, ${colorGrayDark})`; const loaderBullet = `var(--loader-bullet, ${colorWhite})`; const systemMessageBackgroundColor = 'var(--system-message-background-color, #F9FBFC)'; -const systemMessageBorderColor = 'var(--system-message-border-color, #C5CDD4)'; +const systemMessageBorderColor = `var(--system-message-border-color, ${colorBorder})`; const systemMessageFontColor = `var(--system-message-font-color, ${colorGrayDark})`; const highlightedMessageBackgroundColor = 'var(--system-message-background-color, #fef9f1)'; -const highlightedMessageBorderColor = 'var(--system-message-border-color, #B5D3F7)'; +const highlightedMessageBorderColor = `var(--highlighted-message-border-color, ${colorBorder})`; const emphasizedMessageBackgroundColor = 'var(--emphasized-message-background-color, #E9F1F9)'; const colorHeading = `var(--color-heading, ${colorGrayDark})`; const palettePlaceholderText = 'var(--palette-placeholder-text, #787675)'; @@ -122,7 +122,7 @@ const colorContentBackground = 'var(--color-content-background, #1B2A3A)'; const dropdownBg = `var(--dropdown-bg, ${colorWhite})`; -const pollStatsBorderColor = 'var(--poll-stats-border-color, #D4D9DF)'; +const pollStatsBorderColor = `var(--poll-stats-border-color, ${colorBorder})`; const pollBlue = `var(--poll-blue, ${colorPrimary})`; const toastDefaultColor = `var(--toast-default-color, ${colorWhite})`; @@ -163,7 +163,7 @@ const colorToggleBgDisabledDarkTheme = 'var(--toggle-bg-disabled-dark-theme, #90 const colorInfoBoxQuizText = 'var(--color-info-box-quiz-text, #15803D)'; const colorInfoBoxQuizBg = 'var(--color-info-box-quiz-bg, #F0FDF4)'; -const colorInfoBoxQuizBorder = 'var(--color-info-box-quiz-border, #BBF7D0)'; +const colorInfoBoxQuizBorder = `var(--color-info-box-quiz-border, ${colorSuccess})`; const colorSelectedCorrectAnswerText = 'var(--color-selected-correct-answer-text, #A16207)'; const colorSelectedCorrectAnswerBg = 'var(--color-selected-correct-answer-bg, #FEF9C3)'; diff --git a/bigbluebutton-html5/private/config/settings.yml b/bigbluebutton-html5/private/config/settings.yml index 2b38d35e4be7..4ccc05d0c0cf 100755 --- a/bigbluebutton-html5/private/config/settings.yml +++ b/bigbluebutton-html5/private/config/settings.yml @@ -62,6 +62,9 @@ public: skipMeetingEnded: false dynamicGuestPolicy: true enableGuestLobbyMessage: true + # Show the guest user's position in the lobby waiting queue. + # Set to false when users should not see their place in line. + showGuestLobbyWaitingQueuePosition: true guestPolicyExtraAllowOptions: false alwaysShowWaitingRoomUI: true enableLimitOfViewersInWebcam: false @@ -878,6 +881,14 @@ public: # editor and always visible. When disabled, BlockNote's default floating # toolbar appears only when text is selected. staticFormattingToolbar: true + # When enabled, the "Import from Markdown" option is shown in the shared + # notes options menu, letting presenters import Markdown content. Defaults + # to false so a minor update does not add a new menu button unexpectedly. + importMarkdownEnabled: false + # When enabled, the "Export notes as Markdown" option is shown in the + # shared notes options menu. Defaults to false so a minor update does not + # add a new menu button unexpectedly. + exportMarkdownEnabled: false media: audio: # defaultFullAudioBridge: bridge to be used by full audio mechanism. diff --git a/bigbluebutton-html5/public/locales/en.json b/bigbluebutton-html5/public/locales/en.json index 350a4a621cc0..a782f0ce3e7d 100755 --- a/bigbluebutton-html5/public/locales/en.json +++ b/bigbluebutton-html5/public/locales/en.json @@ -163,9 +163,31 @@ "app.notes.disabled": "Pinned on media area", "app.notes.notesDropdown.covertAndUpload": "Convert notes to presentation", "app.notes.notesDropdown.exportAsPDF": "Export notes as PDF", + "app.notes.notesDropdown.exportAsMarkdown": "Export notes as Markdown", + "app.notes.notesDropdown.importFromMarkdown": "Import from Markdown", "app.notes.notesDropdown.pinNotes": "Pin notes onto whiteboard", "app.notes.notesDropdown.unpinNotes": "Unpin notes", "app.notes.notesDropdown.notesOptions": "Notes options", + "app.notes.importModal.title": "Import from Markdown", + "app.notes.importModal.placeholder": "Paste your Markdown content here", + "app.notes.importModal.importMode.label": "Import mode", + "app.notes.importModal.importMode.append.label": "Append", + "app.notes.importModal.importMode.append.description": "Add to the end of the current notes", + "app.notes.importModal.importMode.replace.label": "Replace", + "app.notes.importModal.importMode.replace.description": "Replace the current notes entirely", + "app.notes.importModal.import": "Import", + "app.notes.importModal.cancel": "Cancel", + "app.notes.importModal.dropzone.label": "Drag and drop a Markdown file here", + "app.notes.importModal.dropzone.browse": "browse files", + "app.notes.importModal.dropzone.hint": "Accepted files: .md, .markdown", + "app.notes.importModal.dropzone.active": "Drop the file to load it", + "app.notes.importModal.orDivider": "or paste it below", + "app.notes.importModal.fileLoaded.remove": "Remove file", + "app.notes.importModal.error.invalidType": "Please select a .md or .markdown file", + "app.notes.importModal.error.tooLarge": "This file is too large", + "app.notes.importModal.error.readFailed": "Could not read the file", + "app.notes.importModal.error.empty": "The selected file is empty", + "app.notes.importModal.error.parseFailed": "Could not parse the markdown. Check the syntax and try again", "app.notes.blocknote.payloadSizeError": "Clipboard content too large ({sizeKB}KB). Maximum size for pasting is {maxKB}KB.", "app.notes.blocknote.maxCharCountError": "Input limit reached. You cannot type more than {maxCharCount} characters.", "app.appsGallery.title": "Apps Gallery", diff --git a/bigbluebutton-tests/playwright/accessibility/document-title.spec.ts b/bigbluebutton-tests/playwright/accessibility/document-title.spec.ts new file mode 100644 index 000000000000..3b86225d8156 --- /dev/null +++ b/bigbluebutton-tests/playwright/accessibility/document-title.spec.ts @@ -0,0 +1,91 @@ +import { expect } from '@playwright/test'; + +import { openPrivateChat } from '../chat/util'; +import { elements as e } from '../core/elements'; +import { test } from '../core/setup/fixtures'; +import { openSettings } from '../options/util'; +import { MultiUsers } from '../user/multiusers'; + +test.describe.parallel('Accessible routing', { tag: '@ci' }, () => { + test('updates the document title for active client views', async ({ browser, context, page }, testInfo) => { + const session = new MultiUsers(browser, context); + await session.initModPage(page, { testInfo }); + const { modPage } = session; + + await expect(modPage.page, 'initial meeting title should include the active view').toHaveTitle( + / - (User list|Public Chat|Default presentation view)$/, + ); + + if (modPage.settings?.chatEnabled) { + if (!(await modPage.page.locator(e.hidePublicChat).isVisible())) { + if (!(await modPage.page.locator(e.chatButton).first().isVisible())) { + await modPage.waitAndClick(e.userListToggleBtn); + } + + if (!(await modPage.page.locator(e.hidePublicChat).isVisible())) { + await modPage.waitAndClick(e.chatButton); + } + } + + await modPage.hasElement(e.hidePublicChat, 'should display public chat'); + await expect(modPage.page, 'public chat should be reflected in the document title').toHaveTitle( + / - Public Chat$/, + ); + } + + if (modPage.settings?.sharedNotesEnabled) { + await modPage.waitAndClick(e.sharedNotesSidebarButton); + await modPage.hasElement(e.sharedNotesBackground, 'should display shared notes'); + await expect(modPage.page, 'shared notes should be reflected in the document title').toHaveTitle( + / - Shared Notes$/, + ); + } + + if (modPage.settings?.pollEnabled) { + // 4.0 opens the poll panel from the sidebar button (3.0 used the actions menu item), + // and the open panel shows the minimize button (3.0's hidePollDesc no longer exists). + await modPage.waitAndClick(e.pollSidebarButton); + await modPage.hasElement(e.minimizePolling, 'should display the polling panel'); + await expect(modPage.page, 'polling should be reflected in the document title').toHaveTitle(/ - Polling$/); + } + + // 4.0 opens the manage-presentations view from the media-area menu (3.0 used the actions "+" menu). + await modPage.waitAndClick(e.mediaAreaButton); + await modPage.waitAndClick(e.managePresentations); + await modPage.hasElement(e.presentationFileUpload, 'should display the presentation upload view'); + await expect(modPage.page, 'presentation upload should be reflected in the document title').toHaveTitle( + / - Upload Presentation$/, + ); + }); + + test('updates the document title for private chat', async ({ browser, context, page }, testInfo) => { + const session = new MultiUsers(browser, context); + await session.initPages(page, testInfo); + const { modPage } = session; + + test.skip(!modPage.settings?.chatEnabled, 'Chat is disabled'); + await openPrivateChat(modPage); + await modPage.hasElement(e.hidePrivateChat, 'should display private chat'); + await expect(modPage.page, 'private chat participant should be reflected in the document title').toHaveTitle( + / - Private Chat with Attendee$/, + ); + }); + + test('updates the document title for route-like modals', async ({ browser, context, page }, testInfo) => { + const session = new MultiUsers(browser, context); + await session.initModPage(page, { testInfo }); + const { modPage } = session; + + await openSettings(modPage); + await expect(modPage.page, 'settings modal should be reflected in the document title').toHaveTitle(/ - Settings$/); + // 4.0 settings modal is dismissed via the Save button (3.0 used a generic modal dismiss). + await modPage.waitAndClick(e.saveSettingsButton); + + // 4.0 opens breakout-room creation from the sidebar button (3.0 went through the manageUsers + // menu). The creation panel is the BREAKOUT sidebar content, which drives the document title. + await modPage.waitAndClick(e.breakoutRoomSidebarButton); + await expect(modPage.page, 'breakout room creation should be reflected in the document title').toHaveTitle( + / - Breakout Rooms$/, + ); + }); +}); diff --git a/bigbluebutton-tests/playwright/accessibility/nonTextContrast.spec.ts b/bigbluebutton-tests/playwright/accessibility/nonTextContrast.spec.ts new file mode 100644 index 000000000000..bbc195b0b8a1 --- /dev/null +++ b/bigbluebutton-tests/playwright/accessibility/nonTextContrast.spec.ts @@ -0,0 +1,21 @@ +import { expect } from '@playwright/test'; + +import { Page } from '../core/page'; +import { test } from '../core/setup/fixtures'; +import { findNonTextContrastViolations, formatNonTextContrastViolations } from './nonTextContrast'; + +test.describe.parallel('Accessibility', { tag: '@ci' }, () => { + test('visible non-text boundaries have sufficient contrast', async ({ browser, page }, testInfo) => { + const meetingPage = new Page(browser, page, testInfo); + await meetingPage.init(true, { testInfo }); + + const violations = await findNonTextContrastViolations(meetingPage.page); + + await testInfo.attach('non-text-contrast-violations.json', { + body: JSON.stringify(violations, null, 2), + contentType: 'application/json', + }); + + expect(violations, formatNonTextContrastViolations(violations)).toEqual([]); + }); +}); diff --git a/bigbluebutton-tests/playwright/accessibility/nonTextContrast.ts b/bigbluebutton-tests/playwright/accessibility/nonTextContrast.ts new file mode 100644 index 000000000000..36ac0135646b --- /dev/null +++ b/bigbluebutton-tests/playwright/accessibility/nonTextContrast.ts @@ -0,0 +1,468 @@ +import { Page as PlaywrightPage } from '@playwright/test'; + +const MIN_NON_TEXT_CONTRAST_RATIO = 3; + +export interface NonTextContrastViolation { + selector: string; + element: string; + elementName: string; + ancestorContext: string; + domPath: string; + property: string; + color: string; + backgroundColor: string; + adjacentTo: string; + ratio: number; + requiredRatio: number; + cssRule?: string; + cssDeclaration?: string; + stylesheet?: string; + text: string; +} + +export interface NonTextContrastOptions { + ignoredSelectors?: string[]; + minRatio?: number; +} + +export function formatNonTextContrastViolations(violations: NonTextContrastViolation[]): string { + const details = violations + .slice(0, 20) + .map((violation) => + [ + `${violation.elementName} (${violation.selector})`, + `${violation.property}: ${violation.color}`, + `${violation.adjacentTo}: ${violation.backgroundColor}`, + `ratio: ${violation.ratio}:1`, + violation.ancestorContext ? `context: ${violation.ancestorContext}` : undefined, + violation.cssRule ? `rule: ${violation.cssRule}` : undefined, + violation.cssDeclaration ? `declaration: ${violation.cssDeclaration}` : undefined, + violation.stylesheet ? `stylesheet: ${violation.stylesheet}` : undefined, + ] + .filter(Boolean) + .join(' | '), + ) + .join('\n'); + + const suffix = violations.length > 20 ? `\n...and ${violations.length - 20} more` : ''; + return `Found ${violations.length} non-text contrast issue(s):\n${details}${suffix}`; +} + +export async function findNonTextContrastViolations( + page: PlaywrightPage, + options: NonTextContrastOptions = {}, +): Promise { + const { ignoredSelectors = [], minRatio = MIN_NON_TEXT_CONTRAST_RATIO } = options; + return page.evaluate( + ({ ignoredSelectors: ignored, minRatio: minimumRatio }) => { + type Rgba = { + r: number; + g: number; + b: number; + a: number; + }; + + type Candidate = { + property: string; + color: string; + isBorder: boolean; + sourceProperties: string[]; + }; + + type CssSource = { + cssRule?: string; + cssDeclaration?: string; + stylesheet?: string; + }; + + type Violation = { + selector: string; + element: string; + elementName: string; + ancestorContext: string; + domPath: string; + property: string; + color: string; + backgroundColor: string; + adjacentTo: string; + ratio: number; + requiredRatio: number; + cssRule?: string; + cssDeclaration?: string; + stylesheet?: string; + text: string; + }; + + const TRANSPARENT_ALPHA_THRESHOLD = 0.01; + const MIN_VISIBLE_SIZE = 1; + + const parseCssColor = (color: string): Rgba | null => { + const match = color.match(/^rgba?\((.*)\)$/); + if (!match) return null; + + const parts = match[1] + .replace('/', ' ') + .split(/[,\s]+/) + .filter(Boolean); + + if (parts.length < 3) return null; + + const readChannel = (value: string) => { + if (value.endsWith('%')) return (parseFloat(value) / 100) * 255; + return parseFloat(value); + }; + + const alpha = parts[3] === undefined ? 1 : parseFloat(parts[3]); + return { + r: readChannel(parts[0]), + g: readChannel(parts[1]), + b: readChannel(parts[2]), + a: Number.isNaN(alpha) ? 1 : alpha, + }; + }; + + const isTransparent = (color: Rgba | null) => !color || color.a <= TRANSPARENT_ALPHA_THRESHOLD; + + const composite = (foreground: Rgba, background: Rgba): Rgba => { + const alpha = foreground.a + background.a * (1 - foreground.a); + if (alpha <= TRANSPARENT_ALPHA_THRESHOLD) return { r: 255, g: 255, b: 255, a: 1 }; + + return { + r: (foreground.r * foreground.a + background.r * background.a * (1 - foreground.a)) / alpha, + g: (foreground.g * foreground.a + background.g * background.a * (1 - foreground.a)) / alpha, + b: (foreground.b * foreground.a + background.b * background.a * (1 - foreground.a)) / alpha, + a: alpha, + }; + }; + + const normalize = (value: number) => { + const channel = value / 255; + return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + }; + + const luminance = (color: Rgba) => + 0.2126 * normalize(color.r) + 0.7152 * normalize(color.g) + 0.0722 * normalize(color.b); + + const contrastRatio = (first: Rgba, second: Rgba) => { + const firstLuminance = luminance(first); + const secondLuminance = luminance(second); + const lighter = Math.max(firstLuminance, secondLuminance); + const darker = Math.min(firstLuminance, secondLuminance); + return (lighter + 0.05) / (darker + 0.05); + }; + + const colorToRgbString = (color: Rgba) => + `rgb(${Math.round(color.r)}, ${Math.round(color.g)}, ${Math.round(color.b)})`; + + const isSameColor = (first: Rgba, second: Rgba) => + Math.round(first.r) === Math.round(second.r) && + Math.round(first.g) === Math.round(second.g) && + Math.round(first.b) === Math.round(second.b) && + Math.round(first.a * 100) === Math.round(second.a * 100); + + const effectiveBackground = (element: Element | null): Rgba => { + const base = { r: 255, g: 255, b: 255, a: 1 }; + if (!element) return base; + + const chain: Element[] = []; + let current: Element | null = element; + while (current) { + chain.push(current); + current = current.parentElement; + } + + return chain.reverse().reduce((background, item) => { + const color = parseCssColor(window.getComputedStyle(item).backgroundColor); + if (isTransparent(color)) return background; + return composite(color as Rgba, background); + }, base); + }; + + const cssEscape = (value: string) => { + if (window.CSS?.escape) return window.CSS.escape(value); + return value.replace(/[^a-zA-Z0-9_-]/g, '\\$&'); + }; + + const selectorFor = (element: Element) => { + const dataTest = element.getAttribute('data-test'); + if (dataTest) return `${element.tagName.toLowerCase()}[data-test="${dataTest}"]`; + if (element.id) return `${element.tagName.toLowerCase()}#${cssEscape(element.id)}`; + + const parent = element.parentElement; + const tagName = element.tagName.toLowerCase(); + if (!parent) return tagName; + + const siblings = Array.from(parent.children).filter((child) => child.tagName === element.tagName); + const index = siblings.indexOf(element) + 1; + return `${tagName}:nth-of-type(${index})`; + }; + + const domPathFor = (element: Element) => { + const path: string[] = []; + let current: Element | null = element; + + while (current && current !== document.body) { + path.unshift(selectorFor(current)); + current = current.parentElement; + } + + return `body > ${path.join(' > ')}`; + }; + + const textFor = (element: Element) => (element.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 80); + + const attributeNameFor = (element: Element) => { + const labelledBy = element.getAttribute('aria-labelledby'); + if (labelledBy) { + const name = labelledBy + .split(/\s+/) + .map((id) => document.getElementById(id)?.textContent?.trim()) + .filter(Boolean) + .join(' '); + if (name) return name; + } + + return ( + element.getAttribute('aria-label') || + element.getAttribute('title') || + element.getAttribute('alt') || + element.getAttribute('placeholder') || + element.getAttribute('name') || + '' + ); + }; + + const labelFor = (element: Element) => { + const dataTest = element.getAttribute('data-test'); + if (dataTest) return `[data-test="${dataTest}"]`; + if (element.id) return `#${element.id}`; + if (element.className && typeof element.className === 'string') + return `.${element.className.trim().split(/\s+/)[0]}`; + return element.tagName.toLowerCase(); + }; + + const elementNameFor = (element: Element) => { + const parts = [element.tagName.toLowerCase()]; + const role = element.getAttribute('role'); + const dataTest = element.getAttribute('data-test'); + const { id } = element; + const name = attributeNameFor(element) || textFor(element); + + if (role) parts.push(`role="${role}"`); + if (dataTest) parts.push(`data-test="${dataTest}"`); + if (id) parts.push(`id="${id}"`); + if (name) parts.push(`name="${name.slice(0, 80)}"`); + + return parts.join(' '); + }; + + const contextLabelFor = (element: Element) => { + const dataTest = element.getAttribute('data-test'); + const role = element.getAttribute('role'); + const ariaLabel = element.getAttribute('aria-label'); + const { id } = element; + const tagName = element.tagName.toLowerCase(); + + if (dataTest) return `${tagName}[data-test="${dataTest}"]`; + if (id) return `${tagName}#${id}`; + if (role) return `${tagName}[role="${role}"]`; + if (ariaLabel) return `${tagName}[aria-label="${ariaLabel}"]`; + return ''; + }; + + const ancestorContextFor = (element: Element) => { + const context: string[] = []; + let current = element.parentElement; + + while (current && current !== document.body && context.length < 5) { + const label = contextLabelFor(current); + if (label) context.unshift(label); + current = current.parentElement; + } + + return context.join(' > '); + }; + + const stylesheetNameFor = (styleSheet: CSSStyleSheet) => { + if (styleSheet.href) return styleSheet.href; + + const { ownerNode } = styleSheet; + if (ownerNode instanceof Element) { + const dataStyled = ownerNode.getAttribute('data-styled'); + const { id } = ownerNode; + const nodeName = ownerNode.nodeName.toLowerCase(); + if (dataStyled) return `${nodeName}[data-styled="${dataStyled}"]`; + if (id) return `${nodeName}#${id}`; + return nodeName; + } + + return 'inline stylesheet'; + }; + + const findCssSourceInRules = ( + element: Element, + candidate: Candidate, + rules: CSSRuleList, + styleSheet: CSSStyleSheet, + ): CssSource | null => { + for (let index = rules.length - 1; index >= 0; index--) { + const rule = rules[index]; + let source: CssSource | null = null; + + if ('cssRules' in rule) { + source = findCssSourceInRules(element, candidate, (rule as CSSGroupingRule).cssRules, styleSheet); + } + + if (!source && rule instanceof CSSStyleRule) { + let matches = false; + try { + matches = element.matches(rule.selectorText); + } catch { + matches = false; + } + + const hasSourceProperty = + matches && candidate.sourceProperties.some((property) => rule.style.getPropertyValue(property)); + + if (hasSourceProperty) { + const property = candidate.sourceProperties.find((sourceProperty) => + rule.style.getPropertyValue(sourceProperty), + ); + const value = property ? rule.style.getPropertyValue(property).trim() : ''; + source = { + cssRule: rule.selectorText, + cssDeclaration: property && value ? `${property}: ${value}` : undefined, + stylesheet: stylesheetNameFor(styleSheet), + }; + } + } + + if (source) return source; + } + + return null; + }; + + const findCssSource = (element: Element, candidate: Candidate): CssSource => { + const styleAttribute = element.getAttribute('style') || ''; + const hasInlineSource = candidate.sourceProperties.some((property) => styleAttribute.includes(property)); + if (hasInlineSource) return { cssRule: 'style attribute', stylesheet: 'inline style' }; + + for (let index = document.styleSheets.length - 1; index >= 0; index--) { + const styleSheet = document.styleSheets[index] as CSSStyleSheet; + try { + const source = findCssSourceInRules(element, candidate, styleSheet.cssRules, styleSheet); + if (source) return source; + } catch { + // Cross-origin stylesheets can block cssRules access. + } + } + + return {}; + }; + + const isVisible = (element: Element) => { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return ( + style.display !== 'none' && + style.visibility !== 'hidden' && + parseFloat(style.opacity) > 0 && + rect.width >= MIN_VISIBLE_SIZE && + rect.height >= MIN_VISIBLE_SIZE + ); + }; + + const isIgnored = (element: Element) => + ignored.some((selector) => { + try { + return element.matches(selector) || Boolean(element.closest(selector)); + } catch { + return false; + } + }); + + const borderCandidates = (style: CSSStyleDeclaration): Candidate[] => { + const sides = ['top', 'right', 'bottom', 'left'] as const; + return sides.flatMap((side) => { + const width = parseFloat(style.getPropertyValue(`border-${side}-width`)); + const borderStyle = style.getPropertyValue(`border-${side}-style`); + const color = style.getPropertyValue(`border-${side}-color`); + if (width < 1 || borderStyle === 'none' || borderStyle === 'hidden') return []; + return [ + { + property: `border-${side}-color`, + color, + isBorder: true, + sourceProperties: [`border-${side}-color`, `border-${side}`, 'border-color', 'border'], + }, + ]; + }); + }; + + const outlineCandidates = (style: CSSStyleDeclaration): Candidate[] => { + const width = parseFloat(style.outlineWidth); + if (width < 1 || style.outlineStyle === 'none' || style.outlineStyle === 'hidden') return []; + return [ + { + property: 'outline-color', + color: style.outlineColor, + isBorder: false, + sourceProperties: ['outline-color', 'outline'], + }, + ]; + }; + + const elements = Array.from(document.body.querySelectorAll('*')); + const violations: Violation[] = []; + + elements.forEach((element) => { + if (!isVisible(element) || isIgnored(element)) return; + if (element.closest('svg, canvas, video, iframe')) return; + + const style = window.getComputedStyle(element); + const outerBackground = effectiveBackground(element.parentElement); + const innerBackground = effectiveBackground(element); + const candidates = [...borderCandidates(style), ...outlineCandidates(style)]; + + candidates.forEach((candidate) => { + const parsedColor = parseCssColor(candidate.color); + if (isTransparent(parsedColor)) return; + + const adjacentColors = [{ name: 'outer background', color: outerBackground }]; + if (candidate.isBorder && !isSameColor(innerBackground, outerBackground)) { + adjacentColors.push({ name: 'inner background', color: innerBackground }); + } + + adjacentColors.forEach((adjacentColor) => { + const color = composite(parsedColor as Rgba, adjacentColor.color); + const ratio = contrastRatio(color, adjacentColor.color); + if (ratio >= minimumRatio) return; + const source = findCssSource(element, candidate); + + violations.push({ + selector: selectorFor(element), + element: labelFor(element), + elementName: elementNameFor(element), + ancestorContext: ancestorContextFor(element), + domPath: domPathFor(element), + property: candidate.property, + color: colorToRgbString(color), + backgroundColor: colorToRgbString(adjacentColor.color), + adjacentTo: adjacentColor.name, + ratio: Number(ratio.toFixed(2)), + requiredRatio: minimumRatio, + cssRule: source.cssRule, + cssDeclaration: source.cssDeclaration, + stylesheet: source.stylesheet, + text: textFor(element), + }); + }); + }); + }); + + return violations; + }, + { ignoredSelectors, minRatio }, + ); +} diff --git a/bigbluebutton-tests/playwright/chat/chat.spec.ts b/bigbluebutton-tests/playwright/chat/chat.spec.ts index 6de03481d226..4bbe5af5109d 100644 --- a/bigbluebutton-tests/playwright/chat/chat.spec.ts +++ b/bigbluebutton-tests/playwright/chat/chat.spec.ts @@ -2,6 +2,7 @@ import { test } from '../core/setup/fixtures'; import { Chat } from './chat'; import { Jumbomoji } from './jumbomoji'; import { MessageActions } from './messageActions'; +import { PrivateChatListPreview } from './privateChatListPreview'; test.describe.parallel('Chat', { tag: '@ci' }, () => { // https://docs.bigbluebutton.org/3.0/testing/release-testing/#public-message-automated @@ -168,6 +169,23 @@ test.describe.parallel('Chat', { tag: '@ci' }, () => { await jumbomoji.verifyJumbomoji(); }); + test('Private chat preview renders at first paint', async ({ browser, context, page }, testInfo) => { + const preview = new PrivateChatListPreview(browser, context); + await preview.initModPage(page, { testInfo }); + await preview.initUserPageWithDelayedPreview(testInfo); + await preview.previewRendersAtFirstPaint(); + }); + + test('Private chat preview shows deleted label for a soft-deleted last message', async ({ + browser, + context, + page, + }, testInfo) => { + const preview = new PrivateChatListPreview(browser, context); + await preview.initPages(page, testInfo); + await preview.deletedLastMessageRendersDeletedLabel(); + }); + test.describe('Message actions', () => { test.describe('Edit', () => { test('Edit a message using the toolbar button', async ({ browser, context, page }, testInfo) => { diff --git a/bigbluebutton-tests/playwright/chat/privateChatListPreview.ts b/bigbluebutton-tests/playwright/chat/privateChatListPreview.ts new file mode 100644 index 000000000000..8854f1a1c079 --- /dev/null +++ b/bigbluebutton-tests/playwright/chat/privateChatListPreview.ts @@ -0,0 +1,122 @@ +import { expect, TestInfo } from '@playwright/test'; + +import { ELEMENT_WAIT_LONGER_TIME, ELEMENT_WAIT_TIME } from '../core/constants'; +import { elements as e } from '../core/elements'; +import { Page } from '../core/page'; +import { MultiUsers } from '../user/multiusers'; +import { openPrivateChat } from './util'; + +// Hold back the per-item chat_message_private frames long enough to be deterministic. +const PREVIEW_FRAME_DELAY = ELEMENT_WAIT_TIME; +// The preview must appear well before the delayed per-item frame would arrive. +const PREVIEW_ASSERT_TIMEOUT = Math.floor(ELEMENT_WAIT_TIME * 0.3); + +export class PrivateChatListPreview extends MultiUsers { + // Regression test for issue 25416: the private chats list rendered each item header + // immediately but gated the message preview behind a separate per-item subscription + // (chat_message_private). When that subscription resolved a moment later the preview + // mounted and the item grew, making the list jump. + // + // The fix brings the last message into the chats subscription itself (v_chat.lastMessage + // via a LATERAL join), so the preview is available with the item on the first paint and + // the per-item subscription is gone. + // + // The attendee page routes the graphql WebSocket and delays only the chat_message_private + // frames. On the pre-fix client the preview is gated by those (now delayed) frames, so the + // preview text is absent at first paint and this test fails. On the fixed client the preview + // arrives with the chats subscription (not delayed), so it is present from the first paint. + async initUserPageWithDelayedPreview(testInfo: TestInfo) { + const rawPage = await this.context.newPage(); + await rawPage.routeWebSocket(/\/graphql/, (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => server.send(message)); + server.onMessage((message) => { + const asText = typeof message === 'string' ? message : ''; + if (asText.includes('chat_message_private')) { + setTimeout(() => ws.send(message), PREVIEW_FRAME_DELAY); + } else { + ws.send(message); + } + }); + }); + this.userPage = new Page(this.browser, rawPage, testInfo); + await this.userPage.init(false, { fullName: 'Attendee', meetingId: this.modPage.meetingId }); + } + + async previewRendersAtFirstPaint() { + // Moderator opens a private chat with the attendee and sends one message. + await openPrivateChat(this.modPage); + await this.modPage.hasElement( + e.hidePrivateChat, + 'should display the hide private chat element when the moderator opens a private chat', + ); + // prevent a race condition when running on a deployed server + await this.modPage.page.waitForTimeout(500); + await this.modPage.fill(e.chatBox, e.message1); + await this.modPage.waitAndClick(e.sendButton); + + // Attendee opens the private chats list. The item appears from the chats subscription, + // which (unlike the delayed per-item frames) is not held back. + await this.userPage.waitAndClick(e.privateChatButton); + await this.userPage.hasElement( + e.privateChatItem, + 'should display the private chat item when the attendee receives a private message', + ); + + const chatItem = this.userPage.page.locator(e.privateChatItem).first(); + + // The preview text must be present essentially immediately, well before the delayed + // per-item frame would arrive. On the pre-fix client this window elapses with no preview. + await expect( + chatItem.locator(e.privateChatListContent), + 'should render the last message preview at first paint, without waiting for a per-item subscription', + ).toContainText(e.message1, { timeout: PREVIEW_ASSERT_TIMEOUT }); + + // No loading placeholder is rendered: the preview is real from the first paint. + await expect( + chatItem.locator('.react-loading-skeleton'), + 'should not render a loading skeleton placeholder in the preview slot', + ).toHaveCount(0); + } + + async deletedLastMessageRendersDeletedLabel() { + // Moderator opens a private chat with the attendee and sends one message. + await openPrivateChat(this.modPage); + await this.modPage.hasElement( + e.hidePrivateChat, + 'should display the hide private chat element when the moderator opens a private chat', + ); + await this.modPage.page.waitForTimeout(500); + await this.modPage.fill(e.chatBox, e.message1); + await this.modPage.waitAndClick(e.sendButton); + await this.modPage.hasText( + e.chatUserMessageText, + e.message1, + 'should display the message sent by the moderator inside the private chat', + ); + + // Soft-delete the last message (message row is kept with message=NULL and deletedByUserId set). + const lastMessageItem = this.modPage.page.locator(e.chatMessageItem).last(); + await lastMessageItem.hover(); + await this.modPage.waitAndClick(e.deleteMessageButton); + await this.modPage.hasElement(e.simpleModal, 'should display the delete message confirmation modal'); + await this.modPage.waitAndClick(e.confirmDeleteChatMessageButton); + await expect( + lastMessageItem, + 'should display the deleted label inside the private chat after deleting the last message', + ).toContainText(`This message has been deleted by ${this.modPage.username}`); + + // Back on the private chats list, the preview slot must show the same deleted label + // (lastMessage is NULL but lastMessageAt is set), not an empty preview. + await this.modPage.waitAndClick(e.privateChatBackButton); + await this.modPage.hasElement( + e.privateChatItem, + 'should display the private chat item again on the private chats list', + ); + const chatItem = this.modPage.page.locator(e.privateChatItem).first(); + await expect( + chatItem.locator(e.privateChatListContent), + 'should render the deleted-message label in the private chat preview slot', + ).toContainText(`This message has been deleted by ${this.modPage.username}`, { timeout: ELEMENT_WAIT_LONGER_TIME }); + } +} diff --git a/bigbluebutton-tests/playwright/core/elements.ts b/bigbluebutton-tests/playwright/core/elements.ts index b1e640091a5c..25d42e8019b2 100644 --- a/bigbluebutton-tests/playwright/core/elements.ts +++ b/bigbluebutton-tests/playwright/core/elements.ts @@ -174,6 +174,7 @@ export const elements = { publicUnreadIndicator: 'span[data-test="publicUnreadIndicator"]', privateUnreadIndicator: 'span[data-test="privateUnreadIndicator"]', privateChats: 'div[data-test="private-user-list-header"]', + privateChatListContent: 'div[data-test="private-user-list-content"]', privateChat: 'div[data-test="messageContent"] p>>nth=1', hidePublicChat: 'button[data-test="hidePublicChat"]', hidePrivateChat: 'button[data-test="hidePrivateChat"]', @@ -282,6 +283,20 @@ export const elements = { currentSlideText: 'span[id="currentSlideText"]', notesOptions: 'button[data-test="notesOptionsMenu"]', exportNotesAsPDF: '[data-test="exportNotesAsPDF"]', + exportNotesAsMarkdown: '[data-test="exportNotesAsMarkdown"]', + importNotesFromMarkdown: '[data-test="importNotesFromMarkdown"]', + notesImportMarkdownTextarea: 'textarea[data-test="notesImportMarkdownTextarea"]', + notesImportMarkdownConfirm: 'button[data-test="notesImportMarkdownConfirm"]', + notesImportMarkdownCancel: 'button[data-test="notesImportMarkdownCancel"]', + notesImportMarkdownModal: '[data-test="notesImportMarkdownModal"]', + notesImportMarkdownModeGroup: '[data-test="notesImportMarkdownModeGroup"]', + notesImportMarkdownAppendMode: 'input[data-test="notesImportMarkdownAppendMode"]', + notesImportMarkdownReplaceMode: 'input[data-test="notesImportMarkdownReplaceMode"]', + notesImportMarkdownDropzone: '[data-test="notesImportMarkdownDropzone"]', + notesImportMarkdownFileInput: 'input[data-test="notesImportMarkdownFileInput"]', + notesImportMarkdownFileLoaded: '[data-test="notesImportMarkdownFileLoaded"]', + notesImportMarkdownFileRemove: 'button[data-test="notesImportMarkdownFileRemove"]', + notesImportMarkdownError: '[data-test="notesImportMarkdownError"]', showMoreSharedNotesButton: 'span[class="show-more-icon-btn"]', exportSharedNotesButton: 'li[data-key="import_export"] button', exportPlainButton: 'a[id="exportplaina"] span', @@ -380,6 +395,7 @@ export const elements = { errorNoValueInput: 'div[data-test="errorNoValueInput"]', smartSlides1: 'smartSlidesPresentation.pdf', smartSlides2: 'SmartSlides.pdf', + smartSlidesBugRepro1: 'smart-slides-bug-repro-1.pdf', responsePollQuestion: 'div[data-test="pollQuestion"]', firstPollAnswerOptionBtn: `${pollAnswersOption}>>nth=0`, secondPollAnswerOptionBtn: `${pollAnswersOption}>>nth=1`, @@ -691,6 +707,9 @@ export const elements = { wbMoveToFront: 'button[data-testid="menu-item.bring-to-front"]', wbPaste: 'button[data-testid="menu-item.paste"]', wbTextTrue: 'div[data-hastext="true"]', + // tldraw renders this textarea only while a shape is actively being edited + // (select.editing_shape state) - used to gate on "is editing", not just "has text" + wbEditingTextArea: 'textarea.tl-text-input', wbDrawnArrow: 'div[data-shape-type="arrow"]', wbAutoHideToggleBtn: 'input[data-test="whiteboardToolbarAutoHideToggleBtn"]', turnInfiniteWhiteboardOn: 'button[data-test="turnInfiniteWhiteboardOn"]', diff --git a/bigbluebutton-tests/playwright/core/helpers.ts b/bigbluebutton-tests/playwright/core/helpers.ts index 28fd691de468..f09c36accf79 100644 --- a/bigbluebutton-tests/playwright/core/helpers.ts +++ b/bigbluebutton-tests/playwright/core/helpers.ts @@ -144,6 +144,24 @@ export async function createMeeting( return xmlResponse.response.meetingID[0]; } +// Create a meeting sending an xml `` payload in the POST body (e.g. +// sharedNotesInitialContentJson / sharedNotesInitialContentMarkdown). The checksum +// only covers the query string, so createMeetingUrl still yields a valid URL. +export async function createMeetingWithModules( + modulesXml: string, + createParameter?: string, + customMeetingId?: string, +): Promise { + const url = createMeetingUrl(createParameter, customMeetingId); + const response = await axios.post(url, modulesXml, { + adapter: 'http', + headers: { 'Content-Type': 'application/xml' }, + }); + expect(response.status).toEqual(200); + const xmlResponse = await xml2js.parseStringPromise(response.data); + return xmlResponse.response.meetingID[0]; +} + export function getJoinURL({ meetingID, fullName, options }: GetJoinUrlProp): string { const { isModerator, joinParameter, skipSessionDetailsModal } = options || {}; diff --git a/bigbluebutton-tests/playwright/core/media/smart-slides-bug-repro-1.pdf b/bigbluebutton-tests/playwright/core/media/smart-slides-bug-repro-1.pdf new file mode 100644 index 000000000000..b8b3d7173b7c Binary files /dev/null and b/bigbluebutton-tests/playwright/core/media/smart-slides-bug-repro-1.pdf differ diff --git a/bigbluebutton-tests/playwright/core/page.ts b/bigbluebutton-tests/playwright/core/page.ts index ec587681a0ce..4684140ec1d2 100644 --- a/bigbluebutton-tests/playwright/core/page.ts +++ b/bigbluebutton-tests/playwright/core/page.ts @@ -105,8 +105,8 @@ export class Page { shouldCloseAudioModal = true, fullName, meetingId, - createModules, createParameter: callerCreateParameter, + createModules, joinParameter, customMeetingId, skipSessionDetailsModal = true, diff --git a/bigbluebutton-tests/playwright/polling/poll.ts b/bigbluebutton-tests/playwright/polling/poll.ts index 9101e6f5c60b..61ab97a4a3bd 100644 --- a/bigbluebutton-tests/playwright/polling/poll.ts +++ b/bigbluebutton-tests/playwright/polling/poll.ts @@ -433,6 +433,71 @@ export class Polling extends MultiUsers { ); } + // Regression test for issue #25320 (Bug 1), lettered-poll slide: the Smart Slides / Quick Poll + // parser used to strip parenthetical clauses from the question and collapse the surrounding + // spaces (e.g. "the dose (one per day) for adults" became "the dosefor adults"). The + // parenthetical and its spaces must now be preserved. + async parentheticalQuestionLetterPoll() { + await this.modPage.waitForSelector(e.whiteboard, ELEMENT_WAIT_LONGER_TIME); + await util.uploadSPresentationForTestingPolls(this.modPage, e.smartSlidesBugRepro1); + await this.userPage.hasElement(e.userListItem, 'should display the user list item for the attendee'); + await this.modPage.closeAllToastNotifications(); + await this.modPage.page.waitForTimeout(5000); + + // Slide 1 — lettered poll: "What is the dose (one per day) for adults?" + await this.modPage.selectSlide('Slide 1'); + await this.modPage.hasElement( + e.quickPoll, + 'should display the quick poll button once the smart slides deck is converted', + ELEMENT_WAIT_EXTRA_LONG_TIME, + ); + await this.modPage.waitAndClick(e.quickPoll, ELEMENT_WAIT_LONGER_TIME); + await expect( + this.modPage.page.locator(e.pollQuestionArea), + 'the quick poll question should keep the "(one per day)" clause and its surrounding spaces', + ).toHaveValue(/dose \(one per day\) for adults/, { timeout: ELEMENT_WAIT_TIME }); + await this.modPage.waitAndClick(e.startPoll); + await this.modPage.hasText( + e.currentPollQuestion, + /dose \(one per day\) for adults/, + 'the started poll question should retain the parenthetical clause', + ); + } + + // Regression test for issue #25320 (Bug 1), typed-response slide: the parenthetical must be + // preserved and the surrounding words must not be joined together ("How long (in min) for ..." + // used to become "How longfor ..."). + // NOTE: the superscript flattening (mg/m³ -> mg/m3) is a separate, out-of-scope issue (Bug 2); + // we only assert that the parenthetical and its surrounding spaces are kept. + async parentheticalQuestionTypedResponse() { + await this.modPage.waitForSelector(e.whiteboard, ELEMENT_WAIT_LONGER_TIME); + await util.uploadSPresentationForTestingPolls(this.modPage, e.smartSlidesBugRepro1); + await this.userPage.hasElement(e.userListItem, 'should display the user list item for the attendee'); + await this.modPage.closeAllToastNotifications(); + await this.modPage.page.waitForTimeout(5000); + + // Slide 4 — typed response: "How long (in min) for a 2 mg/m3 sample?" + await this.modPage.selectSlide('Slide 4'); + await this.modPage.hasElement( + e.quickPoll, + 'should display the quick poll button once the smart slides deck is converted', + ELEMENT_WAIT_EXTRA_LONG_TIME, + ); + // Let the current slide propagate to the quick-poll dropdown before triggering it, otherwise + // the click can capture the previous slide's (stale) parsed question. + await this.modPage.page.waitForTimeout(3000); + await this.modPage.waitAndClick(e.quickPoll, ELEMENT_WAIT_LONGER_TIME); + const typedQuestion = this.modPage.page.locator(e.pollQuestionArea); + await expect( + typedQuestion, + 'the typed-response question should keep the "(in min)" clause', + ).toHaveValue(/How long \(in min\) for/, { timeout: ELEMENT_WAIT_TIME }); + await expect( + typedQuestion, + 'the words around the parenthetical must not be joined together (no "longfor")', + ).not.toHaveValue(/longfor/, { timeout: ELEMENT_WAIT_TIME }); + } + async pollResultsOnChat() { const { pollChatMessage } = this.modPage.settings || {}; diff --git a/bigbluebutton-tests/playwright/polling/polling.spec.ts b/bigbluebutton-tests/playwright/polling/polling.spec.ts index e631fc5fabab..a293db426882 100644 --- a/bigbluebutton-tests/playwright/polling/polling.spec.ts +++ b/bigbluebutton-tests/playwright/polling/polling.spec.ts @@ -84,6 +84,14 @@ test.describe.parallel('Polling', { tag: '@ci' }, () => { await polling.typeResponse(); }); + test('Parenthetical question preserved - lettered poll - issue 25320', async () => { + await polling.parentheticalQuestionLetterPoll(); + }); + + test('Parenthetical question preserved - typed response - issue 25320', async () => { + await polling.parentheticalQuestionTypedResponse(); + }); + test('Hiding pools - Poll anywhere in the slide', async () => { await polling.pollAnywhereSlide(); }); diff --git a/bigbluebutton-tests/playwright/recording/recording.ts b/bigbluebutton-tests/playwright/recording/recording.ts index ee03b0a9db8d..d480f3f02a7d 100644 --- a/bigbluebutton-tests/playwright/recording/recording.ts +++ b/bigbluebutton-tests/playwright/recording/recording.ts @@ -6,7 +6,7 @@ import { elements as e, playbackElements } from '../core/elements'; import { getRecordings } from '../core/endpoints'; import { Page } from '../core/page'; import { skipSlide } from '../presentation/util'; -import { getNotesLocator, startSharedNotes } from '../sharednotes/etherpad/util'; +import { getBlockNoteEditorLocator, startSharedNotesBlockNote } from '../sharednotes/blocknote/util'; import { MultiUsers } from '../user/multiusers'; export class Recording extends MultiUsers { @@ -74,8 +74,9 @@ export class Recording extends MultiUsers { await skipSlide(this.modPage); // type on shared notes - await startSharedNotes(this.modPage); - const notesLocator = getNotesLocator(this.modPage); + await startSharedNotesBlockNote(this.modPage); + const notesLocator = getBlockNoteEditorLocator(this.modPage); + await notesLocator.click(); await notesLocator.pressSequentially(e.testMessage); await expect(notesLocator, 'should contain the typed text on shared notes').toContainText(e.testMessage, { timeout: ELEMENT_WAIT_TIME, diff --git a/bigbluebutton-tests/playwright/recording/recordingTimerClockSkew.spec.ts b/bigbluebutton-tests/playwright/recording/recordingTimerClockSkew.spec.ts new file mode 100644 index 000000000000..e6f8335bd45d --- /dev/null +++ b/bigbluebutton-tests/playwright/recording/recordingTimerClockSkew.spec.ts @@ -0,0 +1,92 @@ +import { expect } from '@playwright/test'; + +import { ELEMENT_WAIT_LONGER_TIME } from '../core/constants'; +import { elements as e } from '../core/elements'; +import { Page } from '../core/page'; +import { test } from '../core/setup/fixtures'; +import { constants as c } from '../parameters/constants'; + +// Regression test for the recording-timer clock-skew bug (issue #25312). +// When the moderator's system clock is ahead of the server, the recording timer used to +// jump straight to the clock offset (e.g. "14:00") right after Start Recording, instead of +// starting at "00:00". The recording itself was always correct - only the displayed timer +// was wrong, because the timeSync skew was clamped to a non-negative value and could not +// correct a client-ahead clock. +const CLOCK_SKEW_MINUTES = 14; +const CLOCK_SKEW_MS = CLOCK_SKEW_MINUTES * 60 * 1000; + +test.describe('Recording timer', { tag: '@ci' }, () => { + test('should start at zero when the moderator clock is ahead of the server', async ({ browser, page }) => { + // Simulate a moderator whose system clock is 14 minutes ahead of the server. + // This must run before any navigation so the client sees the skewed clock from the + // very first script it executes. + await page.addInitScript((skewMs) => { + const RealDate = Date; + const realNow = RealDate.now.bind(RealDate); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + class SkewedDate extends RealDate { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(...args: any[]) { + if (args.length === 0) { + super(realNow() + skewMs); + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + super(...(args as [])); + } + } + + static now(): number { + return realNow() + skewMs; + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).Date = SkewedDate; + }, CLOCK_SKEW_MS); + + const modPage = new Page(browser, page); + await modPage.init(true, { fullName: 'Moderator', createParameter: c.recordMeeting }); + + // Give the RTT worker time to send its first round-trip to /rtt-check. + // The timeSync skew is only set after the first RTT message arrives; without + // this wait the timer shows the raw client clock offset until the worker kicks in. + await modPage.page.waitForTimeout(5000); + + // start recording + const recordingIndicatorButton = modPage.page.locator(`${e.recordingIndicator} button`); + await modPage.waitForSelector(e.whiteboard, ELEMENT_WAIT_LONGER_TIME); + await modPage.hasElement(e.recordingIndicator, 'should display the recording indicator once joined'); + await modPage.waitAndClick(e.recordingIndicator); + await modPage.hasElement(e.simpleModal, 'should display the recording modal'); + await modPage.hasElement(e.yesButton, 'should display the "Yes" button in the recording modal'); + await modPage.waitAndClick(e.yesButton); + await expect( + recordingIndicatorButton, + 'recording indicator button should have a red background color when recording', + ).toHaveCSS('background-color', 'rgb(174, 16, 16)'); + + // The timer must start near 0:00. With the clock-skew bug it shows ~14:00 instead, + // because the clamped (>= 0) skew leaves the client's clock offset baked into the + // elapsed time. The skew correction is applied asynchronously after the first RTT + // round-trip, so we poll until the timer reflects the corrected value. + await expect + .poll( + async () => { + const text = await recordingIndicatorButton.textContent(); + const match = text?.match(/(\d{1,2}):(\d{2})/); + if (!match) return Infinity; + return Number(match[1]) * 60 + Number(match[2]); + }, + { + timeout: 15000, + message: 'recording timer should start near 0:00 after skew correction', + }, + ) + .toBeLessThan(60); + + // capture evidence of the displayed timer (named per run via EVIDENCE_TAG) + const evidenceTag = process.env.EVIDENCE_TAG || 'recording-timer'; + await modPage.page.screenshot({ path: `/tmp/evidence/${evidenceTag}.png` }); + }); +}); diff --git a/bigbluebutton-tests/playwright/sharednotes/blocknote/blocknote.spec.ts b/bigbluebutton-tests/playwright/sharednotes/blocknote/blocknote.spec.ts new file mode 100644 index 000000000000..97fe8a8a03e1 --- /dev/null +++ b/bigbluebutton-tests/playwright/sharednotes/blocknote/blocknote.spec.ts @@ -0,0 +1,22 @@ +import { initializePages } from '../../core/helpers'; +import { test } from '../../core/setup/fixtures'; +import { BlockNoteSharedNotes } from './blocknote'; + +test.describe.parallel('Shared Notes - BlockNote', { tag: '@ci' }, () => { + let blockNoteSharedNotes: BlockNoteSharedNotes; + + test.beforeEach(async ({ browser, context }, testInfo) => { + blockNoteSharedNotes = new BlockNoteSharedNotes(browser, context); + await initializePages(blockNoteSharedNotes, browser, { + isMultiUser: true, + createParameter: 'sharedNotesEditor=blocknote', + testInfo, + }); + }); + + // Regression test for #25225 — BlockNote: a remote user's collaboration-cursor + // name must not be embedded in a link when their caret is positioned inside it. + test("Collaboration cursor must not embed a user's name in a link", async () => { + await blockNoteSharedNotes.collaborationCursorMustNotEmbedInLink(); + }); +}); diff --git a/bigbluebutton-tests/playwright/sharednotes/blocknote/blocknote.ts b/bigbluebutton-tests/playwright/sharednotes/blocknote/blocknote.ts new file mode 100644 index 000000000000..5105b550a83f --- /dev/null +++ b/bigbluebutton-tests/playwright/sharednotes/blocknote/blocknote.ts @@ -0,0 +1,82 @@ +import { expect } from '@playwright/test'; + +import { ELEMENT_WAIT_LONGER_TIME } from '../../core/constants'; +import { elements as e } from '../../core/elements'; +import { MultiUsers } from '../../user/multiusers'; +import { + getBlockNoteEditorLocator, + getBlockNoteLinkLocator, + readLinkAndCursorState, + startBlockNoteSharedNotes, + WORD_JOINER, +} from './util'; + +// URL mirrors the link shown in the original #25225 report (it referenced issue 25175). +const LINK_URL = 'https://github.com/bigbluebutton/bigbluebutton/issues/25175'; + +export class BlockNoteSharedNotes extends MultiUsers { + // Reproduces issue #25225: when a remote user's caret sits inside a link, that + // user's collaboration-cursor name (and the U+2060 word-joiner separators around + // it) must NOT be embedded in the link's text or href as seen by other users. + async collaborationCursorMustNotEmbedInLink() { + const { sharedNotesEnabled } = this.modPage.settings || {}; + if (!sharedNotesEnabled) { + await this.modPage.hasElement(e.chatButton, 'should display the public chat button'); + await this.modPage.wasRemoved(e.sharedNotes, 'should not display the shared notes button'); + return; + } + + await startBlockNoteSharedNotes(this.modPage); + await startBlockNoteSharedNotes(this.userPage); + + // The moderator creates a link by typing a URL followed by a space (autolink). + const modEditor = getBlockNoteEditorLocator(this.modPage); + await modEditor.click(); + await this.modPage.page.keyboard.type(`${LINK_URL} `); + + // The link must sync to the attendee's editor. + await expect(getBlockNoteLinkLocator(this.userPage), 'should sync the pasted link to the attendee').toHaveCount(1, { + timeout: ELEMENT_WAIT_LONGER_TIME, + }); + + // Place the moderator's caret a few characters inside the link — keyboard only. + // (Clicking the link opens BlockNote's link toolbar, which steals editor focus and + // stops y-prosemirror from broadcasting the cursor.) After typing, the caret sits + // after the trailing space; stepping left past the space and into the URL makes the + // node *after* the caret link-marked, which is what makes y-prosemirror wrap the + // remote cursor widget in the link mark — the #25225 trigger. + for (let i = 0; i < 9; i += 1) { + await this.modPage.page.keyboard.press('ArrowLeft'); + } + + // Wait until the attendee actually renders the moderator's cursor *inside* the link. + // This single poll is the presence guard + the bug precondition + a sync wait: a + // remote cursor exists and sits within the (the exact #25225 condition), so a + // clean link cannot be a false pass. Reading via page.evaluate never focuses the + // attendee page, so the moderator editor keeps focus and keeps broadcasting. + // (A future upstream `marks: []` fix would render the widget *outside* the ; + // this precondition would then need relaxing.) + await expect + .poll(async () => (await readLinkAndCursorState(this.userPage)).cursorWidgetInsideLink, { + message: 'attendee should render the moderator cursor inside the link', + timeout: ELEMENT_WAIT_LONGER_TIME, + }) + .toBe(true); + const state = await readLinkAndCursorState(this.userPage); + + // The bug: the cursor name and U+2060 separators must not pollute the link text. + expect(state.linkTextHasWordJoiner, 'link text must not contain U+2060 word-joiner separator characters').toBe( + false, + ); + expect(state.linkText, "link text must not contain the other user's name").not.toContain(this.modPage.username); + expect(state.linkText, 'link text should equal the original URL').toBe(LINK_URL); + + // ...nor the href / navigation target. + expect(state.linkHref, "link href must not contain the other user's name").not.toContain(this.modPage.username); + expect(state.linkHref, 'link href must not contain U+2060').not.toContain(WORD_JOINER); + expect(state.linkHref, 'link href should equal the original URL').toBe(LINK_URL); + + await this.modPage.waitAndClick(e.hideNotesLabel); + await this.modPage.wasRemoved(e.hideNotesLabel, 'should not display the hide notes label'); + } +} diff --git a/bigbluebutton-tests/playwright/sharednotes/blocknote/markdown.spec.ts b/bigbluebutton-tests/playwright/sharednotes/blocknote/markdown.spec.ts new file mode 100644 index 000000000000..786b00d70f5f --- /dev/null +++ b/bigbluebutton-tests/playwright/sharednotes/blocknote/markdown.spec.ts @@ -0,0 +1,90 @@ +import { initializePages } from '../../core/helpers'; +import { test } from '../../core/setup/fixtures'; +import { INIT_MARKDOWN_HEADING, INIT_MARKDOWN_ITEM, markdownCreateParameter, MarkdownSharedNotes } from './markdown'; + +test.describe.parallel('Shared Notes - BlockNote Markdown', { tag: '@ci' }, () => { + test.describe('Export and import', () => { + let markdownSharedNotes: MarkdownSharedNotes; + + test.beforeEach(async ({ browser, context }, testInfo) => { + markdownSharedNotes = new MarkdownSharedNotes(browser, context); + await initializePages(markdownSharedNotes, browser, { + isMultiUser: true, + createParameter: 'sharedNotesEditor=blocknote', + testInfo, + }); + }); + + test('Export shared notes as Markdown', async () => { + await markdownSharedNotes.exportAsMarkdown(); + }); + + test('Import from Markdown in Append mode keeps the existing content', async () => { + await markdownSharedNotes.importFromMarkdownAppend(); + }); + + test('Import from Markdown in Replace mode overwrites the existing content', async () => { + await markdownSharedNotes.importFromMarkdownReplace(); + }); + + test('Import from Markdown cancel closes the modal and imports nothing', async () => { + await markdownSharedNotes.importFromMarkdownCancel(); + }); + + test('Import from Markdown propagates to other connected users', async () => { + await markdownSharedNotes.importFromMarkdownPropagatesToOtherUser(); + }); + + test('Import from Markdown loads a valid uploaded .md file', async () => { + await markdownSharedNotes.importFromMarkdownUploadFile(); + }); + + test('Import from Markdown rejects a non-markdown uploaded file', async () => { + await markdownSharedNotes.importFromMarkdownUploadWrongType(); + }); + + test('Import from Markdown keeps import disabled for an empty uploaded file', async () => { + await markdownSharedNotes.importFromMarkdownUploadEmptyFile(); + }); + }); + + test.describe('Init from Markdown create parameter', () => { + let markdownSharedNotes: MarkdownSharedNotes; + + test.beforeEach(async ({ browser, context }, testInfo) => { + markdownSharedNotes = new MarkdownSharedNotes(browser, context); + const initialMarkdown = `# ${INIT_MARKDOWN_HEADING}\n\n- ${INIT_MARKDOWN_ITEM}`; + await initializePages(markdownSharedNotes, browser, { + isMultiUser: false, + createParameter: markdownCreateParameter(initialMarkdown), + testInfo, + }); + }); + + test('Shared notes are seeded from the Markdown create parameter', async () => { + await markdownSharedNotes.initFromMarkdown(); + }); + }); + + test.describe('Init content precedence (JSON over Markdown)', () => { + let markdownSharedNotes: MarkdownSharedNotes; + + test.beforeEach(async ({ browser, context }) => { + // The meeting is created per-test with a POST modules payload, so nothing + // is created here. + markdownSharedNotes = new MarkdownSharedNotes(browser, context); + }); + + test('JSON initial content takes precedence over Markdown', async () => { + await markdownSharedNotes.precedenceJsonWinsOverMarkdown(); + }); + + test('Falls back to Markdown when the JSON initial content is invalid', async () => { + await markdownSharedNotes.invalidJsonFallsBackToMarkdown(); + }); + + test('Shared notes are seeded from the Markdown POST payload module', async () => { + await markdownSharedNotes.initFromMarkdownPayload(); + }); + }); +}); diff --git a/bigbluebutton-tests/playwright/sharednotes/blocknote/markdown.ts b/bigbluebutton-tests/playwright/sharednotes/blocknote/markdown.ts new file mode 100644 index 000000000000..d84004cab905 --- /dev/null +++ b/bigbluebutton-tests/playwright/sharednotes/blocknote/markdown.ts @@ -0,0 +1,334 @@ +import { expect } from '@playwright/test'; + +import { ELEMENT_WAIT_LONGER_TIME } from '../../core/constants'; +import { elements as e } from '../../core/elements'; +import { createMeetingWithModules } from '../../core/helpers'; +import { MultiUsers } from '../../user/multiusers'; +import { enableMarkdownNotesOptions, getBlockNoteEditorLocator, startBlockNoteSharedNotes } from './util'; + +// Markdown seeded through the sharedNotesInitialContentMarkdown create parameter. +// Kept in sync with the value passed in markdown.spec.ts. +export const INIT_MARKDOWN_HEADING = 'Seeded Markdown Heading'; +export const INIT_MARKDOWN_ITEM = 'seeded list item'; + +// Distinct texts so precedence can be asserted by which one renders. +export const JSON_WINS_TEXT = 'JSON content wins over markdown'; +export const MARKDOWN_LOSER_HEADING = 'Markdown that should lose'; +export const MARKDOWN_FALLBACK_HEADING = 'Markdown Fallback Heading'; +export const PAYLOAD_MARKDOWN_HEADING = 'Payload Markdown Heading'; + +// A valid single-paragraph BlockNote document (same structure documented for +// the sharedNotesInitialContentJson module in docs/development/api.md). +function jsonModule(text: string): string { + const blocks = [ + { + id: '00000000-0000-0000-0000-000000000000', + type: 'paragraph', + props: { textAlignment: 'left', backgroundColor: 'default', textColor: 'default' }, + content: [{ type: 'text', text, styles: {} }], + children: [], + }, + ]; + const json = JSON.stringify(blocks); + return ``; +} + +function invalidJsonModule(): string { + const bad = 'this is not valid blocknote json {{{ '; + return ``; +} + +function markdownModule(markdown: string): string { + return ``; +} + +// Build the create query string that seeds shared notes from a raw markdown param. +export function markdownCreateParameter(markdown: string): string { + return `sharedNotesEditor=blocknote&sharedNotesInitialContentMarkdown=${encodeURIComponent(markdown)}`; +} + +export class MarkdownSharedNotes extends MultiUsers { + // Feature: "Export as Markdown" kebab item downloads a .md file with the notes. + async exportAsMarkdown() { + await enableMarkdownNotesOptions(this.modPage); + await startBlockNoteSharedNotes(this.modPage); + + const editor = getBlockNoteEditorLocator(this.modPage); + await editor.click(); + const noteText = 'Hello Markdown Export'; + await this.modPage.page.keyboard.type(noteText); + await expect(editor, 'the typed note should be visible in the editor').toContainText(noteText); + + await this.modPage.waitAndClick(e.notesOptions); + const download = await this.modPage.handleDownload( + this.modPage.page.locator(e.exportNotesAsMarkdown), + undefined, + ELEMENT_WAIT_LONGER_TIME, + ); + + if (!download?.download) throw new Error('Markdown export download did not start'); + const extension = download.download.suggestedFilename().split('.').pop(); + expect(extension, 'the exported file should have a .md extension').toBe('md'); + expect(download.content, 'the exported markdown should contain the typed note').toContain(noteText); + } + + // Feature: a room created with sharedNotesInitialContentMarkdown renders the + // converted blocks (heading + list) when the shared notes open. + async initFromMarkdown() { + await startBlockNoteSharedNotes(this.modPage); + + const editor = getBlockNoteEditorLocator(this.modPage); + await expect(editor, 'should render the seeded heading').toContainText(INIT_MARKDOWN_HEADING, { + timeout: ELEMENT_WAIT_LONGER_TIME, + }); + await expect(editor, 'should render the seeded list item').toContainText(INIT_MARKDOWN_ITEM); + } + + // Feature: importing in "Replace" mode overwrites a non-empty document with the + // imported content, with no confirmation step. + async importFromMarkdownReplace() { + await enableMarkdownNotesOptions(this.modPage); + await startBlockNoteSharedNotes(this.modPage); + + const editor = getBlockNoteEditorLocator(this.modPage); + await editor.click(); + const originalText = 'Original content to be replaced'; + await this.modPage.page.keyboard.type(originalText); + await expect(editor).toContainText(originalText); + + await this.modPage.waitAndClick(e.notesOptions); + await this.modPage.waitAndClick(e.importNotesFromMarkdown); + + const importedMarkdown = '# Imported Heading\n\nImported paragraph body'; + await this.modPage.page.locator(e.notesImportMarkdownTextarea).fill(importedMarkdown); + + // Choose Replace, then import. There is no confirmation prompt anymore. + await this.modPage.page.locator(e.notesImportMarkdownReplaceMode).check(); + await this.modPage.waitAndClick(e.notesImportMarkdownConfirm); + + await expect(editor, 'the imported heading should render').toContainText('Imported Heading', { + timeout: ELEMENT_WAIT_LONGER_TIME, + }); + await expect(editor, 'the imported paragraph should render').toContainText('Imported paragraph body'); + await expect(editor, 'the original content should have been replaced').not.toContainText(originalText); + } + + // Feature: importing in "Append" mode (the default) keeps the existing content and + // adds the imported content after it. + async importFromMarkdownAppend() { + await enableMarkdownNotesOptions(this.modPage); + await startBlockNoteSharedNotes(this.modPage); + + const editor = getBlockNoteEditorLocator(this.modPage); + await editor.click(); + const originalText = 'Original content to keep'; + await this.modPage.page.keyboard.type(originalText); + await expect(editor).toContainText(originalText); + + await this.modPage.waitAndClick(e.notesOptions); + await this.modPage.waitAndClick(e.importNotesFromMarkdown); + + // Append is the default; assert it is pre-selected so the default is never + // silently destructive. + await expect( + this.modPage.page.locator(e.notesImportMarkdownAppendMode), + 'append should be the default mode', + ).toBeChecked(); + + const importedMarkdown = '# Appended Heading\n\nAppended paragraph body'; + await this.modPage.page.locator(e.notesImportMarkdownTextarea).fill(importedMarkdown); + await this.modPage.waitAndClick(e.notesImportMarkdownConfirm); + + await expect(editor, 'the original content should be kept').toContainText(originalText, { + timeout: ELEMENT_WAIT_LONGER_TIME, + }); + await expect(editor, 'the appended heading should render').toContainText('Appended Heading'); + await expect(editor, 'the appended paragraph should render').toContainText('Appended paragraph body'); + } + + // Feature: cancelling the import modal closes it and leaves the document untouched; + // nothing staged in the modal (textarea content, chosen mode) is imported. + async importFromMarkdownCancel() { + await enableMarkdownNotesOptions(this.modPage); + await startBlockNoteSharedNotes(this.modPage); + + const editor = getBlockNoteEditorLocator(this.modPage); + await editor.click(); + const originalText = 'Content that must survive a cancel'; + await this.modPage.page.keyboard.type(originalText); + await expect(editor).toContainText(originalText); + + await this.modPage.waitAndClick(e.notesOptions); + await this.modPage.waitAndClick(e.importNotesFromMarkdown); + + // Stage some markdown, then back out with Cancel instead of confirming. + const discardedHeading = 'Heading that should never be imported'; + await this.modPage.page.locator(e.notesImportMarkdownTextarea).fill(`# ${discardedHeading}`); + await this.modPage.waitAndClick(e.notesImportMarkdownCancel); + + // The modal closes and nothing is imported: the original content stays, and the + // staged markdown never reaches the editor. + await this.modPage.wasRemoved(e.notesImportMarkdownModal, 'the import modal should close on cancel'); + await expect(editor, 'the original content should be untouched').toContainText(originalText); + await expect(editor, 'the discarded markdown should not have been imported').not.toContainText(discardedHeading); + } + + // Feature: importing markdown propagates to every connected client through the + // shared Yjs document (proves the client-side replace is collaborative). + async importFromMarkdownPropagatesToOtherUser() { + await enableMarkdownNotesOptions(this.modPage); + await startBlockNoteSharedNotes(this.modPage); + await startBlockNoteSharedNotes(this.userPage); + + await this.modPage.waitAndClick(e.notesOptions); + await this.modPage.waitAndClick(e.importNotesFromMarkdown); + + const importedMarkdown = '# Shared Heading\n\nContent visible to everyone'; + await this.modPage.page.locator(e.notesImportMarkdownTextarea).fill(importedMarkdown); + // The presenter's document starts empty, so import applies without confirmation. + await this.modPage.waitAndClick(e.notesImportMarkdownConfirm); + + const userEditor = getBlockNoteEditorLocator(this.userPage); + await expect(userEditor, 'the attendee should see the imported heading').toContainText('Shared Heading', { + timeout: ELEMENT_WAIT_LONGER_TIME, + }); + await expect(userEditor, 'the attendee should see the imported body').toContainText('Content visible to everyone'); + } + + // Opens shared notes and the "Import from Markdown" modal as moderator. + private async openImportModal() { + await enableMarkdownNotesOptions(this.modPage); + await startBlockNoteSharedNotes(this.modPage); + await this.modPage.waitAndClick(e.notesOptions); + await this.modPage.waitAndClick(e.importNotesFromMarkdown); + } + + // Loads a file into the dropzone via the hidden file input (drag is not simulated + // because it is flaky; setInputFiles exercises the same onDrop path). + private async importFromMarkdownUpload({ + name, + mimeType, + buffer, + }: { + name: string; + mimeType: string; + buffer: Buffer; + }) { + await this.modPage.page.setInputFiles(e.notesImportMarkdownFileInput, { name, mimeType, buffer }); + } + + // Feature: uploading a valid .md file loads its content and imports it into the editor. + async importFromMarkdownUploadFile() { + await this.openImportModal(); + + const uploadedMarkdown = '# Uploaded Heading\n\nUploaded from a file'; + await this.importFromMarkdownUpload({ + name: 'notes.md', + mimeType: 'text/markdown', + buffer: Buffer.from(uploadedMarkdown), + }); + + // The loaded-file chip confirms the upload was accepted. + await this.modPage.hasElement(e.notesImportMarkdownFileLoaded, 'the loaded-file chip should appear'); + + // The presenter's document starts empty, so import applies without confirmation. + await this.modPage.waitAndClick(e.notesImportMarkdownConfirm); + + const editor = getBlockNoteEditorLocator(this.modPage); + await expect(editor, 'the uploaded heading should render').toContainText('Uploaded Heading', { + timeout: ELEMENT_WAIT_LONGER_TIME, + }); + await expect(editor, 'the uploaded body should render').toContainText('Uploaded from a file'); + } + + // Feature: uploading a non-markdown file surfaces an error and imports nothing. + async importFromMarkdownUploadWrongType() { + await this.openImportModal(); + + await this.importFromMarkdownUpload({ + name: 'notes.txt', + mimeType: 'text/plain', + buffer: Buffer.from('this is not markdown'), + }); + + await this.modPage.hasElement(e.notesImportMarkdownError, 'a wrong-type error should be shown'); + await expect( + this.modPage.page.locator(e.notesImportMarkdownConfirm), + 'import should stay disabled for a rejected file', + ).toHaveAttribute('aria-disabled', 'true'); + } + + // Feature: uploading an empty markdown file shows the chip but keeps import disabled. + async importFromMarkdownUploadEmptyFile() { + await this.openImportModal(); + + await this.importFromMarkdownUpload({ + name: 'empty.md', + mimeType: 'text/markdown', + buffer: Buffer.from(''), + }); + + await this.modPage.hasElement(e.notesImportMarkdownFileLoaded, 'the chip should appear for an empty file'); + await this.modPage.hasElement(e.notesImportMarkdownError, 'an empty-file notice should be shown'); + await expect( + this.modPage.page.locator(e.notesImportMarkdownConfirm), + 'import should be disabled for an empty file', + ).toHaveAttribute('aria-disabled', 'true'); + } + + // Creates a meeting with the given modules/params, then joins as moderator. + private async createAndJoin(modulesXml: string, createParameter: string) { + const meetingId = await createMeetingWithModules(modulesXml, createParameter); + const context = await this.browser.newContext(); + const page = await context.newPage(); + await this.initModPage(page, { meetingId }); + } + + // Feature: when both JSON and Markdown initial content are supplied, the JSON + // takes precedence and the Markdown is ignored. + async precedenceJsonWinsOverMarkdown() { + const markdown = `# ${MARKDOWN_LOSER_HEADING}`; + const createParameter = markdownCreateParameter(markdown); + await this.createAndJoin(jsonModule(JSON_WINS_TEXT), createParameter); + + await startBlockNoteSharedNotes(this.modPage); + const editor = getBlockNoteEditorLocator(this.modPage); + await expect(editor, 'the JSON initial content should be used').toContainText(JSON_WINS_TEXT, { + timeout: ELEMENT_WAIT_LONGER_TIME, + }); + await expect(editor, 'the markdown should be ignored when JSON is present').not.toContainText( + MARKDOWN_LOSER_HEADING, + ); + } + + // Feature: when the JSON initial content is invalid, seeding falls back to the + // Markdown instead of leaving the document empty. + async invalidJsonFallsBackToMarkdown() { + const markdown = `# ${MARKDOWN_FALLBACK_HEADING}`; + const createParameter = markdownCreateParameter(markdown); + await this.createAndJoin(invalidJsonModule(), createParameter); + + await startBlockNoteSharedNotes(this.modPage); + const editor = getBlockNoteEditorLocator(this.modPage); + await expect(editor, 'the markdown fallback should render when JSON is invalid').toContainText( + MARKDOWN_FALLBACK_HEADING, + { timeout: ELEMENT_WAIT_LONGER_TIME }, + ); + } + + // Feature: Markdown initial content can also be provided in the POST body via the + // sharedNotesInitialContentMarkdown xml module (for content too large for a query + // string), mirroring sharedNotesInitialContentJson. + async initFromMarkdownPayload() { + const markdown = `# ${PAYLOAD_MARKDOWN_HEADING}\n\n- ${INIT_MARKDOWN_ITEM}`; + await this.createAndJoin(markdownModule(markdown), 'sharedNotesEditor=blocknote'); + + await startBlockNoteSharedNotes(this.modPage); + const editor = getBlockNoteEditorLocator(this.modPage); + await expect(editor, 'the markdown from the POST payload should seed the document').toContainText( + PAYLOAD_MARKDOWN_HEADING, + { timeout: ELEMENT_WAIT_LONGER_TIME }, + ); + await expect(editor, 'the markdown list item should render').toContainText(INIT_MARKDOWN_ITEM); + } +} diff --git a/bigbluebutton-tests/playwright/sharednotes/blocknote/sharednotes.spec.ts b/bigbluebutton-tests/playwright/sharednotes/blocknote/sharednotes.spec.ts index a7ab4ccba237..fa6e67571ff8 100644 --- a/bigbluebutton-tests/playwright/sharednotes/blocknote/sharednotes.spec.ts +++ b/bigbluebutton-tests/playwright/sharednotes/blocknote/sharednotes.spec.ts @@ -64,4 +64,10 @@ test.describe.parallel('Shared Notes - BlockNote', { tag: '@ci' }, () => { await initializePages(sharedNotes, browser, { isMultiUser: true, createParameter: CREATE_PARAMETER, testInfo }); await sharedNotes.pinAndUnpinNotesOntoWhiteboard(); }); + + test('Unread indicator notifies users of new notes content', async ({ browser, context }, testInfo) => { + const sharedNotes = new BlockNoteSharedNotes(browser, context); + await initializePages(sharedNotes, browser, { isMultiUser: true, createParameter: CREATE_PARAMETER, testInfo }); + await sharedNotes.unreadNotesIndicator(); + }); }); diff --git a/bigbluebutton-tests/playwright/sharednotes/blocknote/sharednotes.ts b/bigbluebutton-tests/playwright/sharednotes/blocknote/sharednotes.ts index 5a1eb5b6c5d0..2ba6f4f2b2c5 100644 --- a/bigbluebutton-tests/playwright/sharednotes/blocknote/sharednotes.ts +++ b/bigbluebutton-tests/playwright/sharednotes/blocknote/sharednotes.ts @@ -3,7 +3,13 @@ import { expect, Response } from '@playwright/test'; import { ELEMENT_WAIT_EXTRA_LONG_TIME, ELEMENT_WAIT_LONGER_TIME, ELEMENT_WAIT_TIME } from '../../core/constants'; import { elements as e } from '../../core/elements'; import { MultiUsers } from '../../user/multiusers'; -import { getBlockNoteEditorLocator, getBlockNoteReadOnlyLocator, startSharedNotesBlockNote } from './util'; +import { + getBlockNoteEditorLocator, + getBlockNoteReadOnlyLocator, + hasNoUnreadNotesIndicator, + startSharedNotesBlockNote, + unreadNotesIndicatorStaysHidden, +} from './util'; export class BlockNoteSharedNotes extends MultiUsers { async openSharedNotes() { @@ -405,4 +411,42 @@ export class BlockNoteSharedNotes extends MultiUsers { await this.modPage.press('Control+I'); await this.modPage.press('ArrowLeft'); } + + async unreadNotesIndicator() { + const { sharedNotesEnabled } = this.modPage.settings || {}; + + if (!sharedNotesEnabled) { + await this.modPage.hasElement(e.messagesSidebarButton, 'should display the public chat button'); + await this.modPage.wasRemoved(e.sharedNotesSidebarButton, 'should not display the shared notes button'); + return; + } + + // viewer starts without the unread indicator + await this.userPage.hasElement(e.sharedNotesSidebarButton, 'should display the shared notes button'); + await hasNoUnreadNotesIndicator(this.userPage, 'should not display the unread indicator before any edit'); + + // moderator opens the notes and types + await startSharedNotesBlockNote(this.modPage); + const notesEditor = getBlockNoteEditorLocator(this.modPage); + await notesEditor.click(); + await this.modPage.page.keyboard.type('Hello attendees'); + + // viewer (notes panel closed) must see the unread indicator + await this.userPage.hasNotificationIcon(e.sharedNotesSidebarButton, 'should display the unread indicator for the viewer'); + + // opening the notes clears the indicator + await startSharedNotesBlockNote(this.userPage); + await hasNoUnreadNotesIndicator(this.userPage, 'should clear the unread indicator when the notes panel is open'); + + // closing the panel must not bring the indicator back (notes were read) + await this.userPage.waitAndClick(e.hideNotesLabel); + await this.userPage.wasRemoved(e.hideNotesLabel, 'should not display the hide notes label'); + await unreadNotesIndicatorStaysHidden(this.userPage, 'should keep the indicator hidden after reading the notes'); + + // new edits after the viewer read the notes must light the indicator again + const modNotesEditor = getBlockNoteEditorLocator(this.modPage); + await modNotesEditor.click(); + await this.modPage.page.keyboard.type('New content'); + await this.userPage.hasNotificationIcon(e.sharedNotesSidebarButton, 'should display the unread indicator again after new edits'); + } } diff --git a/bigbluebutton-tests/playwright/sharednotes/blocknote/util.ts b/bigbluebutton-tests/playwright/sharednotes/blocknote/util.ts index 022962acd6aa..c1622589ed7d 100644 --- a/bigbluebutton-tests/playwright/sharednotes/blocknote/util.ts +++ b/bigbluebutton-tests/playwright/sharednotes/blocknote/util.ts @@ -1,4 +1,6 @@ -import { ELEMENT_WAIT_LONGER_TIME } from '../../core/constants'; +import { expect } from '@playwright/test'; + +import { ELEMENT_WAIT_LONGER_TIME, ELEMENT_WAIT_TIME } from '../../core/constants'; import { elements as e } from '../../core/elements'; import { Page } from '../../core/page'; @@ -15,3 +17,94 @@ export function getBlockNoteEditorLocator(testPage: Page) { export function getBlockNoteReadOnlyLocator(testPage: Page) { return testPage.page.locator(e.blockNoteReadOnly); } + +function checkUnreadNotesIndicator(testPage: Page) { + return testPage.page.evaluate((selector) => { + const element = document.querySelector(selector); + if (!element) return false; + const afterElement = getComputedStyle(element, 'after'); + return !!afterElement && afterElement.content !== 'none'; + }, e.sharedNotesSidebarButton); +} + +export async function hasNoUnreadNotesIndicator(testPage: Page, description: string, timeout = ELEMENT_WAIT_TIME) { + await expect(async () => { + expect(await checkUnreadNotesIndicator(testPage)).toBeFalsy(); + }, description).toPass({ timeout }); +} + +// The indicator must not (re)appear: poll for the whole duration instead of +// passing on the first falsy check. +export async function unreadNotesIndicatorStaysHidden(testPage: Page, description: string, durationMs = 3000) { + const deadline = Date.now() + durationMs; + while (Date.now() < deadline) { + // eslint-disable-next-line no-await-in-loop + expect(await checkUnreadNotesIndicator(testPage), description).toBeFalsy(); + // eslint-disable-next-line no-await-in-loop + await testPage.page.waitForTimeout(250); + } +} + +// U+2060 word-joiner: y-prosemirror wraps the remote collaboration-cursor name +// in these invisible separators, which is what #25225 leaked into the link. +export const WORD_JOINER = '⁠'; + +// Helpers backported from the 3.0 Markdown / collaboration-cursor specs (#25373, +// #25225). Those specs import startBlockNoteSharedNotes; on 4.0 the shared-notes +// sidebar button is data-test="sharedNotesSidebarButton" (renamed from 3.0's +// "sharedNotes") and the editor root is #bn-notes-scroll-container, so this opens +// the panel with the 4.0 selectors rather than 3.0's e.sharedNotes / [data-test="notes"]. +export async function startBlockNoteSharedNotes(testPage: Page) { + await testPage.waitAndClick(e.sharedNotesSidebarButton); + await testPage.waitForSelector(e.hideNotesLabel, ELEMENT_WAIT_LONGER_TIME); + await testPage.waitForSelector(e.blockNoteEditor, ELEMENT_WAIT_LONGER_TIME); +} + +// The Markdown import/export options default to disabled, so the notes options +// menu hides them unless the settings are turned on. The menu bakes its items +// when it mounts (opening it does not re-render), so tests that exercise those +// items enable both flags on the running client before opening shared notes. +// They are client-only settings with no create-call parameter to seed them. +export async function enableMarkdownNotesOptions(testPage: Page): Promise { + await testPage.page.evaluate(() => { + const { sharedNotes } = ( + globalThis as unknown as { + meetingClientSettings: { public: { sharedNotes: Record } }; + } + ).meetingClientSettings.public; + sharedNotes.importMarkdownEnabled = true; + sharedNotes.exportMarkdownEnabled = true; + }); +} + +export function getBlockNoteLinkLocator(testPage: Page) { + return testPage.page.locator(`${e.blockNoteEditor} a`); +} + +/** + * Reads the rendered state of the first link in another user's BlockNote + * editor, plus whether a remote collaboration cursor is present. Runs via + * `evaluate` so it never focuses/activates the page (which would blur the + * cursor owner's editor and stop their awareness cursor from broadcasting). + */ +export function readLinkAndCursorState(testPage: Page) { + return testPage.page.evaluate( + ({ sel, wordJoiner }: { sel: string; wordJoiner: string }) => { + const anchor = document.querySelector(`${sel} a`) as HTMLAnchorElement | null; + // One `__base` element per remote cursor — count bases only for an accurate count. + const cursorBases = document.querySelectorAll(`${sel} .bn-collaboration-cursor__base`); + return { + linkText: anchor ? (anchor.textContent ?? '') : '', + linkHref: anchor ? (anchor.getAttribute('href') ?? '') : '', + linkTextHasWordJoiner: anchor ? (anchor.textContent ?? '').includes(wordJoiner) : false, + cursorWidgetInsideLink: anchor + ? !!anchor.querySelector( + '.bn-collaboration-cursor__base, .bn-collaboration-cursor__caret, .bn-collaboration-cursor__label', + ) + : false, + remoteCursorCount: cursorBases.length, + }; + }, + { sel: e.blockNoteEditor, wordJoiner: WORD_JOINER }, + ); +} diff --git a/bigbluebutton-tests/playwright/whiteboard/slideChangeWhileEditing.ts b/bigbluebutton-tests/playwright/whiteboard/slideChangeWhileEditing.ts new file mode 100644 index 000000000000..c23a9722db5c --- /dev/null +++ b/bigbluebutton-tests/playwright/whiteboard/slideChangeWhileEditing.ts @@ -0,0 +1,86 @@ +import { expect } from '@playwright/test'; + +import { ELEMENT_WAIT_LONGER_TIME } from '../core/constants'; +import { elements as e } from '../core/elements'; +import { MultiUsers } from '../user/multiusers'; + +export class SlideChangeWhileEditing extends MultiUsers { + // Regression test for issue 25332. + // A viewer editing a whiteboard text shape used to crash the client with + // "Expected an editing shape!" when the presenter changed the slide while the + // viewer was still mid-edit: the slide-change effect mutated the tldraw store + // (removing the shape being edited) without first taking the editor out of the + // select.editing_shape state, so the viewer's next pointer-down hit tldraw's + // editing-shape assertion and tripped the error boundary. + async crashOnSlideChangeWhileEditing() { + // The viewer is the page that crashed, so watch it for uncaught errors. + const viewerPageErrors: string[] = []; + this.userPage.page.on('pageerror', (err) => viewerPageErrors.push(err.message)); + + await this.modPage.hasElement( + e.whiteboard, + 'should display the whiteboard for the presenter', + ELEMENT_WAIT_LONGER_TIME, + ); + await this.userPage.hasElement(e.whiteboard, 'should display the whiteboard for the viewer'); + // Changing slides requires a deck with at least two slides; the default + // presentation provides them (same assumption as the skipSlide test). + await this.modPage.hasElement(e.nextSlide, 'should display the next-slide control (deck has >= 2 slides)'); + + // Presenter grants multi-user whiteboard so the viewer can draw. + await this.modPage.waitAndClick(e.multiUsersWhiteboardOn); + + // Viewer enters select.editing_shape: pick the text tool, click the canvas and + // type some text. Do NOT click away or press Escape/Enter - that would commit + // the edit and leave editing_shape, defeating the precondition. + await this.userPage.waitAndClick(e.wbTextShape); + const wbBox = await this.userPage.page.locator(e.whiteboard).boundingBox(); + if (!wbBox) throw new Error('whiteboard boundingBox is null'); + const textX = wbBox.x + 0.4 * wbBox.width; + const textY = wbBox.y + 0.4 * wbBox.height; + await this.userPage.page.mouse.click(textX, textY); + await this.userPage.page.waitForTimeout(300); + await this.userPage.page.keyboard.type('Hello'); + + // Deterministic precondition: the text shape exists (wbTextTrue) AND the editor + // is actively editing it (tldraw only renders the editing textarea while + // isEditing is true). Asserting both avoids racing the slide change against an + // edit that has not actually entered editing_shape yet. + await this.userPage.hasElement(e.wbTextTrue, 'viewer should have a text shape with content'); + await this.userPage.hasElement(e.wbEditingTextArea, 'viewer should be actively editing the text shape'); + + // The race: presenter advances the slide while the viewer is mid-edit. Give the + // slide-change effect time to run on the viewer (it mutates the tldraw store). + await this.modPage.waitAndClick(e.nextSlide); + await this.userPage.page.waitForTimeout(2500); + + // The viewer's next pointer-down on the canvas is what used to throw + // "Expected an editing shape!". Probe a few spots (incl. where the now-removed + // text shape was) so the pointer-down resolves to the dangling editing shape. + const probePoints = [ + [0.5, 0.5], + [0.6, 0.55], + [0.4, 0.4], + ]; + for (const [fx, fy] of probePoints) { + await this.userPage.page.mouse.move(wbBox.x + fx * wbBox.width, wbBox.y + fy * wbBox.height); + await this.userPage.page.mouse.down(); + await this.userPage.page.waitForTimeout(150); + await this.userPage.page.mouse.up(); + await this.userPage.page.waitForTimeout(800); + } + // Give the client a beat to surface any async error / error boundary. + await this.userPage.page.waitForTimeout(1000); + + const editingShapeErrors = viewerPageErrors.filter((m) => /Expected an editing shape/i.test(m)); + expect( + editingShapeErrors, + `viewer must not throw "Expected an editing shape!" on slide change while editing (got: ${editingShapeErrors.join(' | ')})`, + ).toHaveLength(0); + // The whiteboard must still be mounted (not replaced by the error-boundary fallback). + await this.userPage.hasElement( + e.whiteboard, + 'whiteboard should still be mounted on the viewer after the slide change', + ); + } +} diff --git a/bigbluebutton-tests/playwright/whiteboard/whiteboard.spec.ts b/bigbluebutton-tests/playwright/whiteboard/whiteboard.spec.ts index 25d56edddc98..4d455cbf7be7 100644 --- a/bigbluebutton-tests/playwright/whiteboard/whiteboard.spec.ts +++ b/bigbluebutton-tests/playwright/whiteboard/whiteboard.spec.ts @@ -6,6 +6,7 @@ import { ChangeStyles } from './changeStyles'; import { DrawShape } from './drawShape'; import { ShapeOptions } from './shapeOptions'; import { ShapeTools } from './shapeTools'; +import { SlideChangeWhileEditing } from './slideChangeWhileEditing'; import { TextShape } from './textShape'; import { linkIssue } from '../core/helpers'; import { WhiteboardResize } from './whiteboardResize'; @@ -164,6 +165,13 @@ test.describe.parallel('Whiteboard tools', { tag: '@ci' }, () => { await textShape.realTimeTextTyping(); }); + test('No crash on slide change while a viewer is editing', async ({ browser, context, page }, testInfo) => { + const slideChange = new SlideChangeWhileEditing(browser, context); + await slideChange.initModPage(page, { testInfo }); + await slideChange.initUserPage(context, { testInfo }); + await slideChange.crashOnSlideChangeWhileEditing(); + }); + test.describe.parallel('Shape Options', () => { test('Duplicate', async ({ browser, context, page }, testInfo) => { const shapeOptions = new ShapeOptions(browser, context); diff --git a/bigbluebutton-web/build.gradle b/bigbluebutton-web/build.gradle index c234f24d204d..07fe4061eb76 100755 --- a/bigbluebutton-web/build.gradle +++ b/bigbluebutton-web/build.gradle @@ -22,8 +22,13 @@ buildscript { version "0.10.0" group "org.bigbluebutton.web" -ext['tomcat.version'] = '10.1.55' -ext['netty.version'] = '4.1.132.Final' +ext['tomcat.version'] = '10.1.56' +ext['netty.version'] = '4.1.135.Final' +ext['postgresql.version'] = '42.7.13' +ext['spring-framework.version'] = '6.2.19' +ext['jackson-bom.version'] = '2.22.1' +ext['logback.version'] = '1.5.38' +ext['spring-data-bom.version'] = '2025.0.13' apply plugin: "eclipse" apply plugin: "idea" @@ -63,10 +68,10 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-tomcat:${springVersion}" // Override transitive tomcat-embed from spring-boot-starter-tomcat. - // TODO: remove once springVersion >= 3.5.15 (which will manage a fixed tomcat-embed). - implementation "org.apache.tomcat.embed:tomcat-embed-core:10.1.55" - implementation "org.apache.tomcat.embed:tomcat-embed-websocket:10.1.55" - implementation "org.apache.tomcat.embed:tomcat-embed-el:10.1.55" + // TODO: remove once springVersion >= 3.5.17 (which will manage a fixed tomcat-embed). + implementation "org.apache.tomcat.embed:tomcat-embed-core:10.1.56" + implementation "org.apache.tomcat.embed:tomcat-embed-websocket:10.1.56" + implementation "org.apache.tomcat.embed:tomcat-embed-el:10.1.56" implementation "org.apache.grails:grails-core" implementation "org.apache.grails:grails-logging" diff --git a/bigbluebutton-web/gradle.properties b/bigbluebutton-web/gradle.properties index 95de4287c1f4..43280b548234 100644 --- a/bigbluebutton-web/gradle.properties +++ b/bigbluebutton-web/gradle.properties @@ -1,5 +1,5 @@ grailsVersion=7.0.8 gradleWrapperVersion=8.14.3 groovyVersion=4.0.21 -springVersion=3.5.14 +springVersion=3.5.16 org.gradle.java.enable-classpath-instrumentation=false diff --git a/bigbluebutton-web/grails-app/conf/bigbluebutton.properties b/bigbluebutton-web/grails-app/conf/bigbluebutton.properties index c5b2cd8be63a..303957703bf8 100644 --- a/bigbluebutton-web/grails-app/conf/bigbluebutton.properties +++ b/bigbluebutton-web/grails-app/conf/bigbluebutton.properties @@ -112,6 +112,11 @@ clientSettingsOverrideJsonUrlResponseTimeout=15 #------------------------------------ maxClientSettingsOverrideJsonUrlPayloadSize=1024 +#------------------------------------ +# The response from a shared notes initial-content fetch URL (JSON or markdown) shouldn't have more than this size (in KiB) +#------------------------------------ +maxSharedNotesInitialContentUrlPayloadSize=1024 + #------------------------------------ # Timeout(secs) to wait for pdf to svg conversion (timeout for each tool called during the process) #------------------------------------ diff --git a/bigbluebutton-web/grails-app/conf/spring/resources.xml b/bigbluebutton-web/grails-app/conf/spring/resources.xml index 54527ee6c8d1..14e557f3431d 100755 --- a/bigbluebutton-web/grails-app/conf/spring/resources.xml +++ b/bigbluebutton-web/grails-app/conf/spring/resources.xml @@ -44,6 +44,9 @@ with BigBlueButton; if not, see . + + + . + + diff --git a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ApiController.groovy b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ApiController.groovy index e7ae41c0e9ec..87583f811d51 100755 --- a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ApiController.groovy +++ b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/ApiController.groovy @@ -236,6 +236,10 @@ class ApiController { newMeeting.setSharedNotesInitialContentJsonFromPayload(xmlModules.get("sharedNotesInitialContentJson").text()) } + if(xmlModules.containsKey("sharedNotesInitialContentMarkdown")) { + newMeeting.setSharedNotesInitialContentMarkdownFromPayload(xmlModules.get("sharedNotesInitialContentMarkdown").text()) + } + ApiErrors errors = new ApiErrors() // Strict client-settings override validation (test/staging only, off by default): reject the @@ -1220,6 +1224,19 @@ class ApiController { RESP_CODE_FAILED), contentType: "text/xml") } } + } else { + withFormat { + xml { + render(text: responseBuilder.buildInsertDocumentResponse( + "Request body must contain a presentation module with at least one document.", + RESP_CODE_FAILED), contentType: "text/xml") + } + '*' { + render(text: responseBuilder.buildInsertDocumentResponse( + "Request body must contain a presentation module with at least one document.", + RESP_CODE_FAILED), contentType: "text/xml") + } + } } }else { log.warn("Meeting with externalID ${externalMeetingId} doesn't exist.") @@ -1593,7 +1610,7 @@ class ApiController { if (!xmlModules.containsKey(MODULE_PRESENTATION)) { if (isFromInsertAPI) { log.warn("Insert Document API called without a payload - ignoring") - return; + return false; } if (hasPresentationUrlInParameter) { diff --git a/build/packages-template/bbb-config/build.sh b/build/packages-template/bbb-config/build.sh index 5563c026a49c..bf1664c9a888 100755 --- a/build/packages-template/bbb-config/build.sh +++ b/build/packages-template/bbb-config/build.sh @@ -59,6 +59,12 @@ for unit in freeswitch nginx redis-server postgresql; do cp bigbluebutton.conf "staging/usr/lib/systemd/system/${unit}.service.d/" done +# MALLOC_ARENA_MAX=2 for JVM services - reduces glibc malloc arena overhead +for svc in bbb-web bbb-apps-akka; do + mkdir -p "staging/usr/lib/systemd/system/${svc}.service.d" + cp malloc-arena.conf "staging/usr/lib/systemd/system/${svc}.service.d/" +done + . ./opts-$DISTRO.sh # diff --git a/build/packages-template/bbb-config/malloc-arena.conf b/build/packages-template/bbb-config/malloc-arena.conf new file mode 100644 index 000000000000..cc9392a96fc7 --- /dev/null +++ b/build/packages-template/bbb-config/malloc-arena.conf @@ -0,0 +1,2 @@ +[Service] +Environment="MALLOC_ARENA_MAX=2" diff --git a/build/packages-template/bbb-graphql-server/build.sh b/build/packages-template/bbb-graphql-server/build.sh index 3e26714df2d8..8fc659db23f7 100755 --- a/build/packages-template/bbb-graphql-server/build.sh +++ b/build/packages-template/bbb-graphql-server/build.sh @@ -22,7 +22,7 @@ for dir in $DIRS; do mkdir -p staging$dir done -HASURA_VERSION=v2.48.14 +HASURA_VERSION=v2.49.3 git clone --branch $HASURA_VERSION https://github.com/iMDT/hasura-graphql-engine.git cat hasura-graphql-engine/hasura-graphql.part-a* > hasura-graphql diff --git a/docs/docs/administration/customize.md b/docs/docs/administration/customize.md index e0efced9e0a0..68f67adef3ee 100644 --- a/docs/docs/administration/customize.md +++ b/docs/docs/administration/customize.md @@ -1677,6 +1677,19 @@ You can overwrite the default guest policy in `/etc/bigbluebutton/bbb-web.proper # defaultGuestPolicy=ALWAYS_ACCEPT ``` + +#### Configure guest lobby queue position + +When the guest policy makes users wait for moderator approval, the HTML5 client shows each guest their position in the waiting queue by default. To hide this position, set `public.app.showGuestLobbyWaitingQueuePosition` to `false` in `/etc/bigbluebutton/bbb-html5.yml`. + +```yaml +public: + app: + showGuestLobbyWaitingQueuePosition: false +``` + +Restart BigBlueButton with `sudo bbb-conf --restart` for the change to take effect. + #### Show a custom logo on the client Ensure that the parameter `displayBrandingArea` is set to `true` in bbb-html5's configuration, restart BigBlueButton server with `sudo bbb-conf --restart` and pass `logo=` in Custom parameters when creating the meeting. diff --git a/docs/docs/data/create.tsx b/docs/docs/data/create.tsx index 4944a4a6ff31..c7bf2fc49948 100644 --- a/docs/docs/data/create.tsx +++ b/docs/docs/data/create.tsx @@ -398,7 +398,19 @@ const createEndpointTableData = [ "name": "sharedNotesInitialContentJsonUrl", "required": false, "type": "String", - "description": (<>Url from which the shared-notes will fetch the initial content (Only applicable for when `sharedNotesEditor=blockNote`, ignored otherwise)) + "description": (<>Url from which the shared-notes will fetch the initial content (Only applicable for when `sharedNotesEditor=blockNote`, ignored otherwise). The URL must be `https` (`fetchUrlSupportedProtocols`), is capped by `maxSharedNotesInitialContentUrlPayloadSize` (default 1024 KiB) and has a 6000 ms timeout; a URL that violates these yields empty initial content silently.) + }, + { + "name": "sharedNotesInitialContentMarkdown", + "required": false, + "type": "String", + "description": (<>Raw markdown used as the shared-notes initial content (Only applicable for when `sharedNotesEditor=blockNote`, ignored otherwise). Takes precedence over `sharedNotesInitialContentMarkdownUrl` when both are provided.) + }, + { + "name": "sharedNotesInitialContentMarkdownUrl", + "required": false, + "type": "String", + "description": (<>Url from which the shared-notes will fetch the initial content as markdown (Only applicable for when `sharedNotesEditor=blockNote`, ignored otherwise). The URL must be `https` (`fetchUrlSupportedProtocols`), is capped by `maxSharedNotesInitialContentUrlPayloadSize` (default 1024 KiB) and has a 6000 ms timeout; a URL that violates these yields empty initial content silently.) }, { "name": "disabledFeatures", @@ -615,18 +627,6 @@ const createEndpointTableData = [ "type": "String", "description": (<>If passed it will use this string as the name of the presentation uploaded via preUploadedPresentation (added 2.7.2)) }, - { - "name": "allowOverrideClientSettingsOnCreateCall", - "required": false, - "default": false, - "type": "Boolean", - "description": ( - <> -

Whether to allow clientSettingsOverride to be included in the body of a POST request. Because the body of the post request is not signed by the checksum, this parameter is set to false by default. If you set this to true, you must make sure that the signed parameters of the create API request are not visible to users.

-

Added: 3.0.0-alpha.1

- - ) - }, { "name": "clientSettingsOverride", "required": false, diff --git a/docs/docs/development/api.md b/docs/docs/development/api.md index 224b4ee855a9..97c6ed922a57 100644 --- a/docs/docs/development/api.md +++ b/docs/docs/development/api.md @@ -114,10 +114,10 @@ Updated in 2.7: Updated in 3.0: - **create** - - **Added parameters:** `allowOverrideClientSettingsOnCreateCall`, `loginURL`, `pluginManifests`, `pluginManifestsFetchUrl`, `presentationConversionCacheEnabled`, `maxNumPages`, `multiUserWhiteboardEnabled`, `clientSettingsOverrideJsonUrl`, `sharedNotesEditor`. + - **Added parameters:** `loginURL`, `pluginManifests`, `pluginManifestsFetchUrl`, `presentationConversionCacheEnabled`, `maxNumPages`, `multiUserWhiteboardEnabled`, `clientSettingsOverrideJsonUrl`, `sharedNotesEditor`. - **Added options:** Parameter `meetingLayout` supports a few new options: CAMERAS_ONLY, PARTICIPANTS_AND_CHAT_ONLY, PRESENTATION_ONLY, MEDIA_ONLY; - **Added options:** Parameter `disabledFeatures` supports a few new options: `infiniteWhiteboard`, `deleteChatMessage`, `editChatMessage`, `replyChatMessage`, `chatMessageReactions`, `raiseHand`, `userReactions`, `chatEmojiPicker`, `quizzes`; - - **Added POST module:** `clientSettingsOverride`; + - **Added POST module:** `clientSettingsOverride` (gated by the server-side setting `allowOverrideClientSettingsOnCreateCall` in `bbb-web.properties`); - **Removed:** `breakoutRoomsEnabled`, `learningDashboardEnabled`, `virtualBackgroundsDisabled`. - **join** - **Added:** `bot`, `enforceLayout`, `logoutURL`, `firstName`, `lastName`, `userdata-bbb_default_layout`, `userdata-bbb_skip_echotest_if_previous_device`, `userdata-bbb_prefer_dark_theme`. `userdata-bbb_hide_notifications`, `userdata-bbb_hide_controls`, `userdata-bbb_initial_selected_tool` @@ -427,6 +427,41 @@ If you choose the second option (sending content directly), the POST request pay Pay close attention: the initial content JSON structure must be as described in [BlockNote's documentation](https://www.blocknotejs.org/docs/foundations/document-structure?utm_source=chatgpt.com#block-properties). The same applies to the create parameter `sharedNotesInitialContentJsonUrl` (the content inside the URL must have the same structure). +Alternatively, the initial content can be provided as plain Markdown instead of the BlockNote JSON structure. The Markdown is carried untouched through the pipeline and converted to BlockNote blocks on the shared-notes server before seeding the empty document. There are three ways to provide it: + +| Parameter | Type | Description | +| --- | --- | --- | +| `sharedNotesInitialContentMarkdown` | create parameter | Raw Markdown sent inline as a `create` parameter. Suitable for short content that fits within URL length limits. | +| `sharedNotesInitialContentMarkdownUrl` | create parameter | URL from which the raw Markdown will be fetched by the server. | +| `sharedNotesInitialContentMarkdown` | POST module | Raw Markdown sent in the POST body via an xml module, for content too large for a query string. | + +The expected format is raw Markdown (headings, lists, emphasis, etc.), not the BlockNote JSON structure. + +To send the Markdown in the POST body, use the same `` envelope as the JSON variant: + +```xml + + + + + +``` + +**Precedence:** the BlockNote JSON initial content takes precedence over the Markdown. When both JSON and Markdown are supplied, the JSON is used to seed the document, and the Markdown is only used as a fallback when the JSON is absent, empty, or cannot be converted to a valid document. Within the Markdown options, the create parameter `sharedNotesInitialContentMarkdownUrl` is resolved first, then the inline `sharedNotesInitialContentMarkdown` create parameter, and finally the POST module. + +**URL fetch constraints:** the two URL variants (`sharedNotesInitialContentJsonUrl` and `sharedNotesInitialContentMarkdownUrl`) are fetched by the server through the same DNS-pinned, hardened path used for plugin, presentation and callback URLs. Integrators must be aware of the following, since a URL that violates them yields empty initial content silently (the meeting is still created): + +- **HTTPS only.** By default only `https` URLs are accepted (`fetchUrlSupportedProtocols=https`); an `http://` URL is rejected. Local, loopback, site-local and link-local addresses are always blocked. Use `fetchUrlBlockedExternalHosts` to block additional public hosts, or `fetchUrlAllowedLocalHosts` to allow specific internal hosts to resolve to private addresses. +- **Payload cap.** The fetched response must not exceed `maxSharedNotesInitialContentUrlPayloadSize` (default `1024` KiB). Larger responses are dropped. +- **Timeout.** The connect and socket timeout is 6000 ms; a slower endpoint is treated as a failed fetch. + #### Pre-upload Slides You can upload slides within the create call. If you do this, the BigBlueButton server will immediately download and process the slides. @@ -449,24 +484,26 @@ In the body part, you would append a simple XML like the example below: When you need to provide a document using a URL, and the document URL does not contain an extension, you can use the `filename` parameter, such as `filename=test-results.pdf` to help the BigBlueButton server determine the file type (in this example it would be a PDF file). -**From `2.5.x` and on** there are also 2 parameters one can provide the payload to ensure that the document they are uploading can be downloaded or removed from the meeting, those parameters are: +**From `2.5.x` and on** there are also 3 optional parameters one can provide, those parameters are: -| Parameter | Description | Default Value | -| -------------- | ---------------------------------------------- | ------------- | -| `downloadable` | Dictates if the presentation can be downloaded | `false` | -| `removable` | dictates if one can remove the presentation. | `true` | +| Parameter | Description | Default Value | +| -------------- | -------------------------------------------------------------------- | ------------- | +| `downloadable` | Dictates if the presentation can be downloaded | `false` | +| `removable` | Dictates if one can remove the presentation. | `true` | +| `current` | Dictates if the presentation should become the current presentation. | `false` | In the payload the variables are passed inside each `` tag of the xml, as follows: ```xml -JVBERi0xLjQKJ.... +JVBERi0xLjQKJ.... [clipped here] ....0CiUlRU9GCg== ``` -In the case more than a single document is provided, the first one will be loaded in the client, the processing of the other documents will continue in the background and they will be available for display when the user select one of them from the client. +The first file with current="true" will be loaded in the client. If no file has current="true", the first one will be loaded in the client. +The processing of the other documents will continue in the background and they will be available for display when the user select one of them from the client. For more information about the pre-upload slides check the following [link](http://groups.google.com/group/bigbluebutton-dev/browse_thread/thread/d36ba6ff53e4aa79). @@ -475,7 +512,9 @@ For more information about the pre-upload slides check the following [link](http We support overriding the client settings (the entire set of options can be found in `/usr/share/bigbluebutton/html5-client/private/config/settings.yml`) as part of the CREATE call. Note that these values would have higher precedence over customizations made in `/etc/bigbluebutton/bbb-html5.yml`. -By default this overriding approach on CREATE is disabled. To enable it, please set `allowOverrideClientSettingsOnCreateCall=true` in `/etc/bigbluebutton/bbb-web.properties` or as part of the CREATE call. +By default this overriding approach on CREATE is disabled. To enable it, set `allowOverrideClientSettingsOnCreateCall=true` in `/etc/bigbluebutton/bbb-web.properties` and restart bbb-web. This is a **server-side setting only** — it is not read from the create request, so passing `allowOverrideClientSettingsOnCreateCall` as a `/create` parameter has no effect. + +Because the POST body is not covered by the `/create` [checksum](#api-security-model), only enable this on servers where the signed parameters of the create request are not visible to users. As an alternative that keeps the settings on a checksummed GET parameter, see [`clientSettingsOverrideJsonUrl`](#get-post-create), which does not require `allowOverrideClientSettingsOnCreateCall`. You can construct the HTTPS POST request as follows: @@ -753,7 +792,7 @@ curl -s -X POST "https://{your-host}/bigbluebutton/api/insertDocument?meetingID= ' ``` -There is also the possibility of passing the removable and downloadable variables inside the payload, they go in the `document` tag as already demonstrated. The way it works is exactly the same as in the [(POST) create endpoint](#pre-upload-slides) +There is also the possibility of passing the removable, downloadable and current variables inside the payload, they go in the `document` tag as already demonstrated. The way it works is exactly the same as in the [(POST) create endpoint](#pre-upload-slides) ### `GET` `POST` isMeetingRunning diff --git a/docs/docs/development/dev-guide.md b/docs/docs/development/dev-guide.md index 3e558d9bc916..db28be3d4e95 100644 --- a/docs/docs/development/dev-guide.md +++ b/docs/docs/development/dev-guide.md @@ -338,13 +338,14 @@ cd ~/dev/bigbluebutton/bigbluebutton-web To rebuild and deploy your changes, replacing the existing `bbb-web` in `/usr/share/bbb-web`: ```bash -./deploy_to_usr_share.sh +./deploy_to_usr_share.sh --build ``` +The --build option builds bbb-common-web, which is required when building bbb-web for the first time. After that, if you have not changed the bbb-common-web source code, you can omit the --build option. Alternatively, to run bbb-web in development mode on port 8090 without replacing the deployed files: ```bash -./run-dev.sh +./run-dev.sh --build ``` ## Developing Akka-Apps diff --git a/record-and-playback/core/Gemfile b/record-and-playback/core/Gemfile index 38f5cab75d4c..bda34f4ca784 100644 --- a/record-and-playback/core/Gemfile +++ b/record-and-playback/core/Gemfile @@ -23,7 +23,7 @@ gem 'builder', '~> 3.2' gem 'fastimage', '~> 2.1' gem 'java_properties' gem 'journald-logger', '~> 3.0' -gem 'jwt', '~> 2.2' +gem 'jwt', '~> 2.10' gem 'locale', '~> 2.1' gem 'loofah', '~> 2.19.1' gem 'nokogiri', '~> 1.16.5' diff --git a/record-and-playback/core/Gemfile.lock b/record-and-playback/core/Gemfile.lock index e8962a781ac6..92618239cff3 100644 --- a/record-and-playback/core/Gemfile.lock +++ b/record-and-playback/core/Gemfile.lock @@ -8,12 +8,13 @@ GEM minitest (>= 5.1) tzinfo (~> 2.0) ast (2.4.2) + base64 (0.3.0) bbbevents (2.0.3) activesupport (>= 5.0.0.1, < 8) csv rexml builder (3.2.4) - concurrent-ruby (1.3.4) + concurrent-ruby (1.3.7) crass (1.0.6) csv (3.3.5) fastimage (2.2.6) @@ -25,7 +26,8 @@ GEM journald-native (~> 1.0) journald-native (1.0.12) json (2.7.5) - jwt (2.5.0) + jwt (2.10.3) + base64 language_server-protocol (3.17.0.3) locale (2.1.3) loofah (2.19.1) @@ -99,7 +101,7 @@ DEPENDENCIES fastimage (~> 2.1) java_properties journald-logger (~> 3.0) - jwt (~> 2.2) + jwt (~> 2.10) locale (~> 2.1) loofah (~> 2.19.1) minitest (~> 5.14.1) diff --git a/record-and-playback/core/scripts/sanity/sanity.rb b/record-and-playback/core/scripts/sanity/sanity.rb index d00344d54e30..29265e5c1d60 100755 --- a/record-and-playback/core/scripts/sanity/sanity.rb +++ b/record-and-playback/core/scripts/sanity/sanity.rb @@ -50,7 +50,8 @@ def check_events_xml(raw_dir,meeting_id) filepath = "#{raw_dir}/#{meeting_id}/events.xml" - raise Exception, "Events file doesn't exists." if not File.exist?(filepath) + raise Exception, "Events file doesn't exist." if not File.exist?(filepath) + bad_doc = Nokogiri::XML(File.open(filepath)) { |config| config.options = Nokogiri::XML::ParseOptions::STRICT } end