diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/presentationpod/MakePresentationDownloadReqMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/presentationpod/MakePresentationDownloadReqMsgHdlr.scala
index 08e9f051a28b..1c8dea01d988 100644
--- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/presentationpod/MakePresentationDownloadReqMsgHdlr.scala
+++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/presentationpod/MakePresentationDownloadReqMsgHdlr.scala
@@ -12,7 +12,6 @@ import org.bigbluebutton.core.running.LiveMeeting
import org.bigbluebutton.core.util.RandomStringGenerator
import java.io.File
-import java.net.URI
trait MakePresentationDownloadReqMsgHdlr extends RightsManagementTrait {
this: PresentationPodHdlrs =>
@@ -173,16 +172,18 @@ trait MakePresentationDownloadReqMsgHdlr extends RightsManagementTrait {
bus.outGW.send(buildStoreAnnotationsInRedisSysMsg(annotations, liveMeeting))
} else {
// Return existing uploaded file directly
- val convertedFileName = new URI(null, null, currentPres.get.filenameConverted, null).getRawPath
- val originalFilename = new URI(null, null, currentPres.get.name, null).getRawPath
+ val convertedFileName = currentPres.get.filenameConverted
+ val originalFilename = currentPres.get.name
val originalFileExt = originalFilename.split("\\.").last
val convertedFileExt = if (convertedFileName != "") convertedFileName.split("\\.").last else ""
- val convertedFileURI = if (convertedFileName != "") List("presentation", "download", meetingId,
- s"${presId}?presFilename=${presId}.${convertedFileExt}&filename=$convertedFileName").mkString("", File.separator, "")
+ val convertedFileURI = if (convertedFileName != "") PresentationDownloadUrlBuilder.buildFileUri(
+ meetingId, presId, convertedFileExt, convertedFileName
+ )
else ""
- val originalFileURI = List("presentation", "download", meetingId,
- s"${presId}?presFilename=${presId}.${originalFileExt}&filename=$originalFilename").mkString("", File.separator, "")
+ val originalFileURI = PresentationDownloadUrlBuilder.buildFileUri(
+ meetingId, presId, originalFileExt, originalFilename
+ )
val event = buildNewPresFileAvailable("", originalFileURI, convertedFileURI, presId,
m.body.fileStateType)
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 154b4ab20a46..4437d6212591 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,8 +8,6 @@ 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 {
@@ -61,9 +59,9 @@ trait PresentationConversionCompletedSysPubMsgHdlr {
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, "")
+ val originalFileURI = PresentationDownloadUrlBuilder.buildFileUri(
+ meetingId, pres.id, originalDownloadableExtension, pres.name
+ )
PresPresentationDAO.updateDownloadUri(pres.id, originalFileURI)
}
if(pres.current) {
diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/presentationpod/PresentationDownloadUrlBuilder.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/presentationpod/PresentationDownloadUrlBuilder.scala
new file mode 100644
index 000000000000..f29d5e3edeb6
--- /dev/null
+++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/presentationpod/PresentationDownloadUrlBuilder.scala
@@ -0,0 +1,23 @@
+package org.bigbluebutton.core.apps.presentationpod
+
+import java.net.URLEncoder
+import java.nio.charset.StandardCharsets
+
+object PresentationDownloadUrlBuilder {
+ private def encodeQueryValue(value: String): String = {
+ URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20")
+ }
+
+ def buildFileUri(
+ meetingId: String,
+ presentationId: String,
+ fileExtension: String,
+ downloadFilename: String
+ ): String = {
+ val storedFilename = encodeQueryValue(s"${presentationId}.${fileExtension}")
+ val encodedDownloadFilename = encodeQueryValue(downloadFilename)
+
+ s"presentation/download/${meetingId}/${presentationId}" +
+ s"?presFilename=${storedFilename}&filename=${encodedDownloadFilename}"
+ }
+}
diff --git a/bbb-export-annotations/package-lock.json b/bbb-export-annotations/package-lock.json
index 8a9f58577803..c59eb4196220 100644
--- a/bbb-export-annotations/package-lock.json
+++ b/bbb-export-annotations/package-lock.json
@@ -9,7 +9,7 @@
"version": "2.0",
"dependencies": {
"@svgdotjs/svg.js": "^3.2.4",
- "axios": "^1.16.0",
+ "axios": "^1.18.0",
"form-data": "^4.0.4",
"opentype.js": "^1.3.4",
"perfect-freehand": "^1.2.2",
@@ -166,6 +166,18 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@@ -218,13 +230,14 @@
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
},
"node_modules/axios": {
- "version": "1.16.0",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
- "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
+ "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
+ "https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
@@ -381,7 +394,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -941,6 +953,19 @@
"node": ">= 0.4"
}
},
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/ignore": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
@@ -1141,7 +1166,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
"license": "MIT"
},
"node_modules/natural-compare": {
@@ -1653,6 +1677,14 @@
"dev": true,
"requires": {}
},
+ "agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "requires": {
+ "debug": "4"
+ }
+ },
"ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@@ -1691,12 +1723,13 @@
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
},
"axios": {
- "version": "1.16.0",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
- "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
+ "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
"requires": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
+ "https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
@@ -1808,7 +1841,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
"requires": {
"ms": "^2.1.3"
}
@@ -2194,6 +2226,15 @@
"function-bind": "^1.1.2"
}
},
+ "https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "requires": {
+ "agent-base": "6",
+ "debug": "4"
+ }
+ },
"ignore": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
@@ -2335,8 +2376,7 @@
"ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"natural-compare": {
"version": "1.4.0",
diff --git a/bbb-export-annotations/package.json b/bbb-export-annotations/package.json
index 7bba016706c1..56bda2f30157 100644
--- a/bbb-export-annotations/package.json
+++ b/bbb-export-annotations/package.json
@@ -9,7 +9,7 @@
},
"dependencies": {
"@svgdotjs/svg.js": "^3.2.4",
- "axios": "^1.16.0",
+ "axios": "^1.18.0",
"form-data": "^4.0.4",
"opentype.js": "^1.3.4",
"perfect-freehand": "^1.2.2",
diff --git a/bbb-learning-dashboard/src/services/UserService.js b/bbb-learning-dashboard/src/services/UserService.js
index 236b6c0a01d8..0b8c091547ce 100644
--- a/bbb-learning-dashboard/src/services/UserService.js
+++ b/bbb-learning-dashboard/src/services/UserService.js
@@ -217,7 +217,9 @@ export function makeUserCSVData(users, polls, intl) {
// Add the anonymous answers
anonymousRecord += `,"${pollValues[i].anonymousAnswers.join('\r\n')}"`;
}
- userRecords.Anonymous = anonymousRecord;
+ if (pollValues.some((poll) => poll.anonymous)) {
+ userRecords.Anonymous = anonymousRecord;
+ }
return [
header,
diff --git a/bbb-recording-imex/pom.xml b/bbb-recording-imex/pom.xml
index 297408fbc042..698b53466c7d 100644
--- a/bbb-recording-imex/pom.xml
+++ b/bbb-recording-imex/pom.xml
@@ -75,7 +75,7 @@
ch.qos.logback
logback-core
- 1.5.33
+ 1.5.34
org.slf4j
diff --git a/bbb-shared-notes-server/package-lock.json b/bbb-shared-notes-server/package-lock.json
index fa0f177b9b80..314da983c63f 100644
--- a/bbb-shared-notes-server/package-lock.json
+++ b/bbb-shared-notes-server/package-lock.json
@@ -15,7 +15,7 @@
"@hocuspocus/server": "^4.0.0",
"@types/express": "^5.0.6",
"@types/node": "^22.19.15",
- "axios": "^1.15.2",
+ "axios": "^1.18.0",
"claude": "^0.1.2",
"express": "^5.2.1",
"express-rate-limit": "^8.5.1",
@@ -2227,9 +2227,9 @@
"license": "MIT"
},
"node_modules/axios": {
- "version": "1.16.1",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
- "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
+ "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
@@ -2311,9 +2311,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "5.0.6",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
- "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+ "version": "5.0.7",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
+ "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/bbb-shared-notes-server/package.json b/bbb-shared-notes-server/package.json
index 399e220edea5..b9efd5d3b468 100644
--- a/bbb-shared-notes-server/package.json
+++ b/bbb-shared-notes-server/package.json
@@ -39,7 +39,7 @@
"@hocuspocus/server": "^4.0.0",
"@types/express": "^5.0.6",
"@types/node": "^22.19.15",
- "axios": "^1.15.2",
+ "axios": "^1.18.0",
"claude": "^0.1.2",
"express": "^5.2.1",
"express-rate-limit": "^8.5.1",
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 d334ab42c463..d45ea9385b52 100644
--- a/bigbluebutton-html5/imports/ui/components/bn-shared-notes/component.tsx
+++ b/bigbluebutton-html5/imports/ui/components/bn-shared-notes/component.tsx
@@ -502,6 +502,7 @@ function BlockNoteApp(props: BlockNoteAppProps): React.ReactElement {
ref={toolbarRef}
role="toolbar"
className="bn-toolbar-row"
+ data-test="blockNoteToolbar"
onKeyDown={(e) => { if (e.key === 'Escape') editor.focus(); }}
>
diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/presentation-download-dropdown-wrapper/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/presentation-download-dropdown-wrapper/component.jsx
index bb0b35ad6bae..c7cdb502fc8d 100644
--- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/presentation-download-dropdown-wrapper/component.jsx
+++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-uploader/presentation-download-dropdown/presentation-download-dropdown-wrapper/component.jsx
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import Styled from './styles';
const propTypes = {
- children: PropTypes.shape({}).isRequired,
+ children: PropTypes.node.isRequired,
};
function PresentationDownloadDropdownWrapper({ children }) {
return (
diff --git a/bigbluebutton-html5/imports/ui/components/webcam/component.tsx b/bigbluebutton-html5/imports/ui/components/webcam/component.tsx
index 9843c6dc7d5d..6562f910693e 100644
--- a/bigbluebutton-html5/imports/ui/components/webcam/component.tsx
+++ b/bigbluebutton-html5/imports/ui/components/webcam/component.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Resizable } from 're-resizable';
import Draggable, { DraggableEvent } from 'react-draggable';
@@ -31,6 +31,22 @@ const intlMessages = defineMessages({
},
});
+const CAMERA_DOCK_GRID_SNAP_TOLERANCE = 12;
+const CAMERA_DOCK_GRID_SETTLE_DELAY = 100;
+
+const snapCameraDockDimensionToGrid = (
+ dockSize: number,
+ gridSize: number | undefined,
+ minSize: number,
+ maxSize: number,
+) => {
+ if (!gridSize || dockSize - gridSize <= CAMERA_DOCK_GRID_SNAP_TOLERANCE) {
+ return dockSize;
+ }
+
+ return Math.min(Math.max(gridSize, minSize), maxSize);
+};
+
interface WebcamComponentProps {
cameraDock: Output['cameraDock'];
swapLayout: boolean;
@@ -40,6 +56,7 @@ interface WebcamComponentProps {
isPresenter: boolean;
displayPresentation: boolean;
cameraOptimalGridSize: Input['cameraDock']['cameraOptimalGridSize'];
+ snapToCameraGrid: boolean;
isRTL: boolean;
}
@@ -52,6 +69,7 @@ const WebcamComponent: React.FC = ({
isPresenter,
displayPresentation,
cameraOptimalGridSize: cameraSize,
+ snapToCameraGrid,
isRTL,
}) => {
const [isResizing, setIsResizing] = useState(false);
@@ -60,8 +78,14 @@ const WebcamComponent: React.FC = ({
const [resizeStart, setResizeStart] = useState({ width: 0, height: 0 });
const [cameraMaxWidth, setCameraMaxWidth] = useState(0);
const [draggedAtLeastOneTime, setDraggedAtLeastOneTime] = useState(false);
+ const cameraDockRef = useRef(cameraDock);
+ const cameraSizeRef = useRef(cameraSize);
+ const cameraDockGridSettleTimeoutRef = useRef | null>(null);
const intl = useIntl();
+ cameraDockRef.current = cameraDock;
+ cameraSizeRef.current = cameraSize;
+
const lastSize = Storage.getItem('webcamSize') || { width: 0, height: 0 };
const { height: lastHeight } = lastSize as { width: number, height: number };
@@ -85,6 +109,12 @@ const WebcamComponent: React.FC = ({
};
}, []);
+ useEffect(() => () => {
+ if (cameraDockGridSettleTimeoutRef.current !== null) {
+ clearTimeout(cameraDockGridSettleTimeoutRef.current);
+ }
+ }, []);
+
useEffect(() => {
setIsFullScreen(fullscreen.group === 'webcams');
}, [fullscreen]);
@@ -150,6 +180,36 @@ const WebcamComponent: React.FC = ({
}
};
+ const snapCameraDockToGrid = () => {
+ if (!snapToCameraGrid) return;
+
+ const currentCameraDock = cameraDockRef.current;
+ const currentCameraSize = cameraSizeRef.current;
+ const isCurrentCameraTopOrBottom = currentCameraDock.position === CAMERADOCK_POSITION.CONTENT_TOP
+ || currentCameraDock.position === CAMERADOCK_POSITION.CONTENT_BOTTOM;
+
+ const height = isCurrentCameraTopOrBottom
+ ? snapCameraDockDimensionToGrid(
+ currentCameraDock.height,
+ currentCameraSize?.height,
+ currentCameraDock.minHeight,
+ currentCameraDock.maxHeight,
+ )
+ : currentCameraDock.height;
+
+ if (height === currentCameraDock.height) return;
+
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAMERA_DOCK_SIZE,
+ value: {
+ width: currentCameraDock.width,
+ height,
+ browserWidth: window.innerWidth,
+ browserHeight: window.innerHeight,
+ },
+ });
+ };
+
const handleWebcamDragStart = () => {
setIsDragging(true);
document.body.style.overflow = 'hidden';
@@ -241,6 +301,10 @@ const WebcamComponent: React.FC = ({
height: isDragging ? cameraSize?.height : cameraDock.height,
}}
onResizeStart={() => {
+ if (cameraDockGridSettleTimeoutRef.current !== null) {
+ clearTimeout(cameraDockGridSettleTimeoutRef.current);
+ cameraDockGridSettleTimeoutRef.current = null;
+ }
setIsResizing(true);
setResizeStart({ width: cameraDock.width, height: cameraDock.height });
onResizeHandle(cameraDock.width, cameraDock.height);
@@ -255,10 +319,27 @@ const WebcamComponent: React.FC = ({
onResizeStop={() => {
setResizeStart({ width: 0, height: 0 });
setTimeout(() => setIsResizing(false), 500);
- layoutContextDispatch({
- type: ACTIONS.SET_CAMERA_DOCK_IS_RESIZING,
- value: false,
- });
+ const stopCameraDockResize = () => {
+ layoutContextDispatch({
+ type: ACTIONS.SET_CAMERA_DOCK_IS_RESIZING,
+ value: false,
+ });
+ };
+
+ if (snapToCameraGrid && isCameraTopOrBottom) {
+ // Let the throttled grid calculation observe the final pointer size before
+ // compacting it. This keeps larger row/column transitions reachable.
+ if (cameraDockGridSettleTimeoutRef.current !== null) {
+ clearTimeout(cameraDockGridSettleTimeoutRef.current);
+ }
+ cameraDockGridSettleTimeoutRef.current = setTimeout(() => {
+ cameraDockGridSettleTimeoutRef.current = null;
+ snapCameraDockToGrid();
+ stopCameraDockResize();
+ }, CAMERA_DOCK_GRID_SETTLE_DELAY);
+ } else {
+ stopCameraDockResize();
+ }
}}
enable={{
top: !isFullScreen && !isDragging && !swapLayout && cameraDock?.resizableEdge?.top,
@@ -343,6 +424,8 @@ const WebcamContainer: React.FC = () => {
const { selectedLayout } = useSettings(SETTINGS.APPLICATION) as { selectedLayout: string };
const isVideoFocus = selectedLayout === LAYOUT_TYPE.VIDEO_FOCUS;
const isUnifiedLayout = selectedLayout === LAYOUT_TYPE.UNIFIED_LAYOUT;
+ const snapToCameraGrid = selectedLayout === LAYOUT_TYPE.CUSTOM_LAYOUT
+ || selectedLayout === LAYOUT_TYPE.UNIFIED_LAYOUT;
const isGridEnabled = isVideoFocus || (isUnifiedLayout && !presentationIsOpen);
@@ -369,6 +452,7 @@ const WebcamContainer: React.FC = () => {
focusedId: cameraDock.focusedId,
cameraDock,
cameraOptimalGridSize,
+ snapToCameraGrid,
layoutContextDispatch,
fullscreen,
isPresenter: currentUserData?.presenter ?? false,
diff --git a/bigbluebutton-html5/package-lock.json b/bigbluebutton-html5/package-lock.json
index c541f890e2b1..8c1bb3a2fc1c 100644
--- a/bigbluebutton-html5/package-lock.json
+++ b/bigbluebutton-html5/package-lock.json
@@ -9585,9 +9585,9 @@
"dev": true
},
"node_modules/fast-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
- "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
+ "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"dev": true,
"funding": [
{
@@ -11715,9 +11715,9 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
},
"node_modules/linkify-it": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz",
- "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==",
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
+ "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
"funding": [
{
"type": "github",
diff --git a/bigbluebutton-tests/playwright/core/elements.ts b/bigbluebutton-tests/playwright/core/elements.ts
index 77b27a79cc72..e8f724f0cdea 100644
--- a/bigbluebutton-tests/playwright/core/elements.ts
+++ b/bigbluebutton-tests/playwright/core/elements.ts
@@ -255,6 +255,17 @@ export const elements = {
unpinNotes: 'button[data-test="unpinNotes"]',
exportetherpad: 'span[id="exportetherpad"]',
exporthtml: 'span[id="exporthtml"]',
+
+ // BlockNote specific
+ blockNoteContainer: '#bn-notes-scroll-container',
+ blockNoteEditor: '#bn-notes-scroll-container .bn-editor',
+ blockNoteEditable: '#bn-notes-scroll-container .bn-editor[contenteditable="true"]',
+ blockNoteReadOnly: '#bn-notes-scroll-container .bn-editor[contenteditable="false"]',
+ blockNoteToolbar: 'div[data-test="blockNoteToolbar"]',
+ blockNoteUnderlineButton: 'div[data-test="blockNoteToolbar"] button[aria-label="Underline"]',
+ notesConnectionError: '[data-test="notesError"]',
+ notesRetryButton: 'button[data-test="notesRetryButton"]',
+
// Notifications
smallToastMsg: 'div[data-test="toastSmallMsg"]',
closeToastBtn: 'i[data-test="closeToastBtn"]',
diff --git a/bigbluebutton-tests/playwright/core/setup/fixtures.ts b/bigbluebutton-tests/playwright/core/setup/fixtures.ts
index 075826f57d98..f99d0149b0e1 100644
--- a/bigbluebutton-tests/playwright/core/setup/fixtures.ts
+++ b/bigbluebutton-tests/playwright/core/setup/fixtures.ts
@@ -1,4 +1,4 @@
-import { test as base } from '@playwright/test';
+import { test as base, type Video } from '@playwright/test';
interface TestFixtures {
sharedBeforeEachTestHook: void;
@@ -6,12 +6,30 @@ interface TestFixtures {
const testWithValidation = base.extend({
sharedBeforeEachTestHook: [
- async ({ browser }, use) => {
+ async ({ browser }, use, testInfo) => {
// Before test
await use();
- // After test
+ // After test — collect video refs before closing (videos finalize on context close)
const contexts = browser.contexts();
+ const videos: Video[] = [];
+ for (const ctx of contexts) {
+ for (const pg of ctx.pages()) {
+ const v = pg.video();
+ if (v) videos.push(v);
+ }
+ }
await Promise.all(contexts.map((context) => context.close()));
+ // Only attach videos for failed/timed-out tests to avoid bloating CI artifacts on passing runs
+ if (testInfo.status !== 'passed') {
+ for (let i = 0; i < videos.length; i++) {
+ try {
+ const videoPath = await videos[i].path();
+ await testInfo.attach(`video-${i + 1}`, { path: videoPath, contentType: 'video/webm' });
+ } catch {
+ // skip if video file unavailable
+ }
+ }
+ }
},
{ scope: 'test', auto: true },
],
diff --git a/bigbluebutton-tests/playwright/learningdashboard/learningdashboard.ts b/bigbluebutton-tests/playwright/learningdashboard/learningdashboard.ts
index cb10284b8182..e8c94b1a6391 100644
--- a/bigbluebutton-tests/playwright/learningdashboard/learningdashboard.ts
+++ b/bigbluebutton-tests/playwright/learningdashboard/learningdashboard.ts
@@ -193,5 +193,8 @@ export class LearningDashboard extends MultiUsers {
];
await checkTextContent(dataCSV.content, dataToCheck);
+ expect(dataCSV.content, 'should not include an anonymous row when no anonymous polls were created').not.toMatch(
+ /^"Anonymous"(?:,|$)/m,
+ );
}
}
diff --git a/bigbluebutton-tests/playwright/package-lock.json b/bigbluebutton-tests/playwright/package-lock.json
index f5d9ce52a497..0d1378c3d91d 100644
--- a/bigbluebutton-tests/playwright/package-lock.json
+++ b/bigbluebutton-tests/playwright/package-lock.json
@@ -11,7 +11,7 @@
"dependencies": {
"@playwright/test": "^1.56.0",
"@swc/core": "^1.13.5",
- "axios": "^1.16.0",
+ "axios": "^1.18.0",
"chalk": "^4.1.2",
"deep-equal": "^2.2.3",
"dotenv": "^16.4.5",
@@ -791,6 +791,18 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@@ -993,13 +1005,14 @@
}
},
"node_modules/axios": {
- "version": "1.16.0",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
- "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
+ "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
+ "https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
@@ -1330,7 +1343,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -2720,6 +2732,19 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/husky": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/husky/-/husky-1.3.1.tgz",
@@ -3532,7 +3557,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
"license": "MIT"
},
"node_modules/natural-compare": {
diff --git a/bigbluebutton-tests/playwright/package.json b/bigbluebutton-tests/playwright/package.json
index 7366200302fb..10732ba5abd0 100644
--- a/bigbluebutton-tests/playwright/package.json
+++ b/bigbluebutton-tests/playwright/package.json
@@ -28,7 +28,7 @@
},
"dependencies": {
"@playwright/test": "^1.56.0",
- "axios": "^1.16.0",
+ "axios": "^1.18.0",
"chalk": "^4.1.2",
"deep-equal": "^2.2.3",
"dotenv": "^16.4.5",
diff --git a/bigbluebutton-tests/playwright/sharednotes/blocknote/sharednotes.spec.ts b/bigbluebutton-tests/playwright/sharednotes/blocknote/sharednotes.spec.ts
index f843a6475cba..6cbe490c8bac 100644
--- a/bigbluebutton-tests/playwright/sharednotes/blocknote/sharednotes.spec.ts
+++ b/bigbluebutton-tests/playwright/sharednotes/blocknote/sharednotes.spec.ts
@@ -2,19 +2,72 @@ import { initializePages, linkIssue } from '../../core/helpers';
import { test } from '../../core/setup/fixtures';
import { BlockNoteSharedNotes } from './sharednotes';
-test.describe('Shared Notes - BlockNote', { tag: '@ci' }, () => {
- let sharedNotes: BlockNoteSharedNotes;
+const CREATE_PARAMETER = 'sharedNotesEditor=blockNote';
- test.beforeEach(async ({ browser, context }, testInfo) => {
- sharedNotes = new BlockNoteSharedNotes(browser, context);
- await initializePages(sharedNotes, browser, {
- createParameter: 'sharedNotesEditor=blockNote',
- testInfo,
- });
+test.describe.parallel('Shared Notes - BlockNote', { tag: '@ci' }, () => {
+ test('Open shared notes', async ({ browser, context }, testInfo) => {
+ const sharedNotes = new BlockNoteSharedNotes(browser, context);
+ await initializePages(sharedNotes, browser, { isMultiUser: true, createParameter: CREATE_PARAMETER, testInfo });
+ await sharedNotes.openSharedNotes();
});
- test('Export empty shared notes as PDF returns a PDF, not an error', async () => {
+ test('Type in shared notes', async ({ browser, context }, testInfo) => {
+ const sharedNotes = new BlockNoteSharedNotes(browser, context);
+ await initializePages(sharedNotes, browser, { isMultiUser: true, createParameter: CREATE_PARAMETER, testInfo });
+ await sharedNotes.typeInSharedNotes();
+ });
+
+ test('Format text in shared notes', async ({ browser, context }, testInfo) => {
+ const sharedNotes = new BlockNoteSharedNotes(browser, context);
+ await initializePages(sharedNotes, browser, { isMultiUser: true, createParameter: CREATE_PARAMETER, testInfo });
+ await sharedNotes.formatTextInSharedNotes();
+ });
+
+ test('Export shared notes as PDF', async ({ browser, context, browserName }, testInfo) => {
+ test.skip(browserName === 'firefox', 'window.open popup handling differs on Firefox');
+ const sharedNotes = new BlockNoteSharedNotes(browser, context);
+ await initializePages(sharedNotes, browser, { isMultiUser: true, createParameter: CREATE_PARAMETER, testInfo });
+ await sharedNotes.exportSharedNotesAsPDF();
+ });
+
+ test('Export empty shared notes as PDF returns a PDF, not an error', async ({ browser, context }, testInfo) => {
linkIssue(25122);
+ const sharedNotes = new BlockNoteSharedNotes(browser, context);
+ await initializePages(sharedNotes, browser, { createParameter: CREATE_PARAMETER, testInfo });
await sharedNotes.exportEmptyNotesAsPDF();
});
+
+ test('Convert notes to presentation', async ({ browser, context }, testInfo) => {
+ const sharedNotes = new BlockNoteSharedNotes(browser, context);
+ await initializePages(sharedNotes, browser, { isMultiUser: true, createParameter: CREATE_PARAMETER, testInfo });
+ await sharedNotes.convertNotesToWhiteboard();
+ });
+
+ test('Multiusers edit', async ({ browser, context }, testInfo) => {
+ const sharedNotes = new BlockNoteSharedNotes(browser, context);
+ await initializePages(sharedNotes, browser, { isMultiUser: true, createParameter: CREATE_PARAMETER, testInfo });
+ await sharedNotes.editSharedNotesWithMoreThanOneUser();
+ });
+
+ test('See notes without edit permission', async ({ browser, context }, testInfo) => {
+ const sharedNotes = new BlockNoteSharedNotes(browser, context);
+ await initializePages(sharedNotes, browser, { isMultiUser: true, createParameter: CREATE_PARAMETER, testInfo });
+ await sharedNotes.seeNotesWithoutEditPermission();
+ });
+
+ // different failures in CI and local
+ // local: not able to click on "unpin" button
+ // CI: not restoring presentation for viewer after unpinning notes
+ test('Pin and unpin notes onto whiteboard', async ({ browser, context, browserName }, testInfo) => {
+ test.skip(browserName === 'firefox', 'Webcams does not work properly, due to heavy firefox for testing');
+ // On BBB 3.0 the viewer whiteboard is not restored to the presenter presentation state after the
+ // presenter unpins the shared notes (observed consistently across 3 runs; the 4.0 suite passes the
+ // same scenario). Whether this is a genuine 3.0 sync gap or a test adaptation issue is not yet
+ // determined, so the scenario is kept as fixme in the 3.0 backport of #25165 rather than reported
+ // as a passing case. The make-presenter / second-unpin path below is therefore not exercised here.
+ test.fixme(true, 'BBB 3.0 viewer presentation is not restored after the presenter unpins shared notes (observed, root cause undetermined)');
+ const sharedNotes = new BlockNoteSharedNotes(browser, context);
+ await initializePages(sharedNotes, browser, { isMultiUser: true, createParameter: CREATE_PARAMETER, testInfo });
+ await sharedNotes.pinAndUnpinNotesOntoWhiteboard();
+ });
});
diff --git a/bigbluebutton-tests/playwright/sharednotes/blocknote/sharednotes.ts b/bigbluebutton-tests/playwright/sharednotes/blocknote/sharednotes.ts
index b89db663a018..38f129976917 100644
--- a/bigbluebutton-tests/playwright/sharednotes/blocknote/sharednotes.ts
+++ b/bigbluebutton-tests/playwright/sharednotes/blocknote/sharednotes.ts
@@ -1,10 +1,122 @@
import { expect, Response } from '@playwright/test';
-import { ELEMENT_WAIT_LONGER_TIME, ELEMENT_WAIT_TIME } from '../../core/constants';
+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';
export class BlockNoteSharedNotes extends MultiUsers {
+ async openSharedNotes() {
+ 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 startSharedNotesBlockNote(this.modPage);
+ const editorLocator = getBlockNoteEditorLocator(this.modPage);
+ await expect(editorLocator, 'should display the BlockNote editor in editable mode').toBeVisible({
+ timeout: ELEMENT_WAIT_TIME,
+ });
+
+ await this.modPage.waitAndClick(e.hideNotesLabel);
+ await this.modPage.wasRemoved(e.hideNotesLabel, 'should not display the hide notes label');
+ }
+
+ async typeInSharedNotes() {
+ 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 startSharedNotesBlockNote(this.modPage);
+ const editorLocator = getBlockNoteEditorLocator(this.modPage);
+ await editorLocator.click();
+ await editorLocator.pressSequentially(e.message);
+ await expect(editorLocator, 'should contain the typed text on shared notes').toContainText(e.message, {
+ timeout: ELEMENT_WAIT_TIME,
+ });
+
+ await editorLocator.press('Control+Z');
+ await editorLocator.press('Control+Z');
+ await editorLocator.press('Control+Z');
+
+ await this.modPage.waitAndClick(e.hideNotesLabel);
+ await this.modPage.wasRemoved(e.hideNotesLabel, 'should not display the hide notes label');
+ }
+
+ async formatTextInSharedNotes() {
+ 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 startSharedNotesBlockNote(this.modPage);
+ const editorLocator = getBlockNoteEditorLocator(this.modPage);
+ await editorLocator.click();
+ await editorLocator.pressSequentially(e.message);
+
+ await editorLocator.press('Control+Z');
+ await expect(editorLocator, 'should not contain any text after undoing').not.toContainText(e.message, {
+ timeout: ELEMENT_WAIT_TIME,
+ });
+ // Re-type so we have content to format (Y.js collaborative redo is not reliable in tests)
+ await editorLocator.pressSequentially(e.message);
+ await expect(editorLocator, 'should contain the message again after re-typing').toContainText(e.message, {
+ timeout: ELEMENT_WAIT_TIME,
+ });
+
+ await this.formatBlockNoteMessage();
+ const html = await editorLocator.innerHTML();
+
+ await expect(html.includes(''), 'should include underline formatting').toBeTruthy();
+ await expect(html.includes(''), 'should include bold formatting').toBeTruthy();
+ await expect(html.includes(''), 'should include italic formatting').toBeTruthy();
+
+ await editorLocator.press('Control+Z');
+ await editorLocator.press('Control+Z');
+ await editorLocator.press('Control+Z');
+
+ await this.modPage.waitAndClick(e.hideNotesLabel);
+ await this.modPage.wasRemoved(e.hideNotesLabel, 'should not display the hide notes label');
+ }
+
+ async exportSharedNotesAsPDF() {
+ 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 startSharedNotesBlockNote(this.modPage);
+ const editorLocator = getBlockNoteEditorLocator(this.modPage);
+ await editorLocator.click();
+ await editorLocator.pressSequentially(e.message);
+
+ await this.modPage.waitAndClick(e.notesOptions);
+ await this.modPage.hasElement(e.exportNotesAsPDF, 'should display the export as PDF option');
+
+ // Intercept the outgoing request — the server responds with Content-Disposition: attachment
+ // so the popup tab never navigates; checking the request URL is the reliable approach.
+ const [request] = await Promise.all([
+ this.modPage.page.context().waitForEvent('request', {
+ predicate: (req) => req.url().includes('/hocuspocus/api/documents/') && req.url().includes('/export/pdf'),
+ timeout: ELEMENT_WAIT_EXTRA_LONG_TIME,
+ }),
+ this.modPage.waitAndClick(e.exportNotesAsPDF),
+ ]);
+ await expect(request.url(), 'should request the PDF export endpoint').toContain('/export/pdf');
+
+ await this.modPage.waitAndClick(e.hideNotesLabel);
+ await this.modPage.wasRemoved(e.hideNotesLabel, 'should not display the hide notes label');
+ }
+
// Regression for https://github.com/bigbluebutton/bigbluebutton/issues/25122:
// exporting an empty BlockNote shared note must return a file, not an error
// page ("Export failed: Document is empty...").
@@ -76,4 +188,219 @@ export class BlockNoteSharedNotes extends MultiUsers {
if (body) body(await response.text());
}
}
+
+ async convertNotesToWhiteboard() {
+ 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 startSharedNotesBlockNote(this.modPage);
+ const editorLocator = getBlockNoteEditorLocator(this.modPage);
+ await editorLocator.click();
+ await editorLocator.pressSequentially('test');
+ await expect(editorLocator, 'should register the typed text before converting to whiteboard').toContainText(
+ 'test',
+ { timeout: ELEMENT_WAIT_TIME },
+ );
+
+ await this.modPage.waitAndClick(e.notesOptions);
+ await this.modPage.waitAndClick(e.sendNotesToWhiteboard);
+
+ await this.modPage.hasText(
+ e.currentSlideText,
+ /test/,
+ 'should the slide contain the text "test" for the moderator',
+ 30000,
+ );
+ await this.userPage.hasText(
+ e.currentSlideText,
+ /test/,
+ 'should the slide contain the text "test" for the attendee',
+ 20000,
+ );
+
+ await editorLocator.press('Control+Z');
+
+ await this.modPage.waitAndClick(e.hideNotesLabel);
+ await this.modPage.wasRemoved(e.hideNotesLabel, 'should not display the hide notes label button');
+ }
+
+ async editSharedNotesWithMoreThanOneUser() {
+ 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;
+ }
+ // Open notes for both users before any typing so Hocuspocus registers both sessions
+ await startSharedNotesBlockNote(this.userPage);
+ const userEditorLocator = getBlockNoteEditorLocator(this.userPage);
+
+ await startSharedNotesBlockNote(this.modPage);
+ const modEditorLocator = getBlockNoteEditorLocator(this.modPage);
+ await modEditorLocator.click();
+ await modEditorLocator.pressSequentially('Hello');
+
+ // user waits for mod's text to sync, then selects all and replaces
+ await expect(userEditorLocator, 'should sync mod content to user before editing').toContainText('Hello', {
+ timeout: ELEMENT_WAIT_TIME,
+ });
+ await userEditorLocator.click();
+ await userEditorLocator.press('Control+A');
+ await userEditorLocator.pressSequentially('Jello');
+
+ await expect(modEditorLocator, 'should the shared notes contain the text "Jello" for the moderator').toContainText(
+ /Jello/,
+ { timeout: ELEMENT_WAIT_TIME },
+ );
+ await expect(userEditorLocator, 'should the shared notes contain the text "Jello" for the attendee').toContainText(
+ /Jello/,
+ { timeout: ELEMENT_WAIT_TIME },
+ );
+
+ await this.modPage.waitAndClick(e.hideNotesLabel);
+ await this.modPage.wasRemoved(e.hideNotesLabel, 'should not display the hide notes button for the moderator');
+ await this.userPage.waitAndClick(e.hideNotesLabel);
+ await this.userPage.wasRemoved(e.hideNotesLabel, 'should not display the hide notes button for the attendee');
+ }
+
+ async seeNotesWithoutEditPermission() {
+ 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;
+ }
+ // type on shared notes as moderator
+ await startSharedNotesBlockNote(this.modPage);
+ const modEditorLocator = getBlockNoteEditorLocator(this.modPage);
+ await modEditorLocator.click();
+ await modEditorLocator.pressSequentially('Hello');
+
+ // open user notes to join the Hocuspocus session
+ await startSharedNotesBlockNote(this.userPage);
+
+ // lock shared notes editing for viewers
+ await this.modPage.waitAndClick(e.manageUsers);
+ await this.modPage.waitAndClick(e.lockViewersButton);
+ await this.modPage.waitAndClickElement(e.lockEditSharedNotes);
+ await this.modPage.waitAndClick(e.applyLockSettings);
+
+ // attendee's editor should become read-only and still show content
+ const userReadOnlyLocator = getBlockNoteReadOnlyLocator(this.userPage);
+ await expect(
+ userReadOnlyLocator,
+ 'should display the text "Hello" in read-only mode for the attendee',
+ ).toContainText(/Hello/, { timeout: 20000 });
+ await this.userPage.wasRemoved(
+ e.blockNoteToolbar,
+ 'should not display the BlockNote toolbar when shared notes are locked for editing',
+ );
+ }
+
+ async pinAndUnpinNotesOntoWhiteboard() {
+ 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 this.modPage.waitForSelector(e.whiteboard);
+ await this.userPage.waitForSelector(e.whiteboard);
+ // user minimize presentation
+ await this.userPage.waitAndClick(e.minimizePresentation);
+ await this.userPage.hasElement(
+ e.restorePresentation,
+ 'should display the restore presentation button for the attendee',
+ );
+ // type on shared notes as moderator
+ await startSharedNotesBlockNote(this.modPage);
+ const editorLocator = getBlockNoteEditorLocator(this.modPage);
+ await editorLocator.click();
+ await editorLocator.pressSequentially('Hello');
+ await expect(editorLocator, 'should register the typed text before pinning').toContainText(/Hello/, {
+ timeout: ELEMENT_WAIT_TIME,
+ });
+ // pin notes
+ await this.modPage.waitAndClick(e.notesOptions);
+ await this.modPage.waitAndClick(e.pinNotes);
+ await this.modPage.hasElement(e.unpinNotes, 'should display the unpin notes button');
+ await this.userPage.hasElement(
+ e.minimizePresentation,
+ 'should display the minimize presentation button for the attendee',
+ );
+ // check text content on pinned shared notes
+ const userEditorLocator = getBlockNoteEditorLocator(this.userPage);
+ await expect(editorLocator, 'should display the text "Hello" on the shared notes for the moderator').toContainText(
+ /Hello/,
+ { timeout: 20000 },
+ );
+ await expect(
+ userEditorLocator,
+ 'should display the text "Hello" on the shared notes for the attendee',
+ ).toContainText(/Hello/);
+ // unpin notes
+ await this.modPage.closeAllToastNotifications();
+ await this.modPage.waitAndClick(e.unpinNotes);
+ await this.modPage.hasElement(e.whiteboard, 'should restore the presentation for the moderator (previous state)');
+ await this.userPage.hasElement(
+ e.whiteboard,
+ 'should restore the presentation for the attendee as it syncs to presenter state',
+ );
+ // pin notes again as moderator
+ await startSharedNotesBlockNote(this.modPage);
+ await this.modPage.waitAndClick(e.notesOptions);
+ await this.modPage.waitAndClick(e.pinNotes);
+ await this.modPage.hasElement(
+ e.unpinNotes,
+ 'should display the unpin notes button for the moderator after pinning the notes again',
+ );
+ // make viewer as presenter and unpin notes
+ await this.modPage.waitAndClick(e.userListItem);
+ await this.modPage.waitAndClick(e.makePresenter);
+ await this.userPage.closeAllToastNotifications();
+ await this.userPage.waitAndClick(e.unpinNotes);
+ await this.userPage.hasElement(e.whiteboard, 'should restore the presentation for the attendee (previous state)');
+ await this.modPage.hasElement(e.whiteboard, 'should restore the presentation for the moderator (previous state)');
+ }
+
+ async formatBlockNoteMessage() {
+ // U for '!' — BlockNote has no Ctrl+U shortcut; click the toolbar button instead.
+ // The static toolbar uses e.preventDefault() on mousedown to preserve selection.
+ await this.modPage.down('Shift');
+ await this.modPage.press('ArrowLeft');
+ await this.modPage.up('Shift');
+ await this.modPage.page.locator(e.blockNoteUnderlineButton).click();
+ await this.modPage.press('ArrowLeft');
+
+ // B for 'World'
+ await this.modPage.down('Shift');
+ let i = 5;
+ while (i > 0) {
+ await this.modPage.press('ArrowLeft');
+ i--;
+ }
+ await this.modPage.up('Shift');
+ await this.modPage.press('Control+B');
+ await this.modPage.press('ArrowLeft');
+
+ await this.modPage.press('ArrowLeft');
+
+ // I for 'Hello'
+ await this.modPage.down('Shift');
+ i = 5;
+ while (i > 0) {
+ await this.modPage.press('ArrowLeft');
+ i--;
+ }
+ await this.modPage.up('Shift');
+ await this.modPage.press('Control+I');
+ await this.modPage.press('ArrowLeft');
+ }
}
diff --git a/bigbluebutton-tests/playwright/sharednotes/blocknote/util.ts b/bigbluebutton-tests/playwright/sharednotes/blocknote/util.ts
index 2890674d6bf8..4679c1ee818f 100644
--- a/bigbluebutton-tests/playwright/sharednotes/blocknote/util.ts
+++ b/bigbluebutton-tests/playwright/sharednotes/blocknote/util.ts
@@ -71,3 +71,16 @@ export function readLinkAndCursorState(testPage: Page) {
{ sel: BLOCKNOTE_EDITOR, wordJoiner: WORD_JOINER },
);
}
+
+// Helpers backported from #25165. On 3.0 the shared-notes sidebar button carries
+// data-test="sharedNotes" (renamed to sharedNotesSidebarButton on 4.0), so this
+// helper opens the panel via e.sharedNotes to match the 3.0 client.
+export async function startSharedNotesBlockNote(testPage: Page) {
+ await testPage.waitAndClick(e.sharedNotes);
+ await testPage.waitForSelector(e.hideNotesLabel, ELEMENT_WAIT_LONGER_TIME);
+ await testPage.hasElement(e.blockNoteEditable, 'should display the BlockNote editor', ELEMENT_WAIT_LONGER_TIME);
+}
+
+export function getBlockNoteReadOnlyLocator(testPage: Page) {
+ return testPage.page.locator(e.blockNoteReadOnly);
+}
diff --git a/bigbluebutton-tests/playwright/sharednotes/etherpad/sharednotes.spec.ts b/bigbluebutton-tests/playwright/sharednotes/etherpad/sharednotes.spec.ts
index 79bf5d423ac3..326816b91661 100644
--- a/bigbluebutton-tests/playwright/sharednotes/etherpad/sharednotes.spec.ts
+++ b/bigbluebutton-tests/playwright/sharednotes/etherpad/sharednotes.spec.ts
@@ -2,52 +2,59 @@ import { initializePages } from '../../core/helpers';
import { test } from '../../core/setup/fixtures';
import { SharedNotes } from './sharednotes';
-test.describe.parallel('Shared Notes - Etherpad', { tag: '@ci' }, () => {
- let sharedNotes: SharedNotes;
-
- test.beforeEach(async ({ browser, context }, testInfo) => {
- sharedNotes = new SharedNotes(browser, context);
- await initializePages(sharedNotes, browser, {
- isMultiUser: true,
- createParameter: 'sharedNotesEditor=etherpad',
- testInfo,
- });
- });
+const CREATE_PARAMETER = 'sharedNotesEditor=etherpad';
- test('Open shared notes', async () => {
+test.describe.parallel('Shared Notes - Etherpad', { tag: '@ci' }, () => {
+ test('Open shared notes', async ({ browser, context }, testInfo) => {
+ const sharedNotes = new SharedNotes(browser, context);
+ await initializePages(sharedNotes, browser, { isMultiUser: true, createParameter: CREATE_PARAMETER, testInfo });
await sharedNotes.openSharedNotes();
});
- test('Type in shared notes', async ({ browserName }) => {
+ test('Type in shared notes', async ({ browser, context, browserName }, testInfo) => {
test.skip(browserName === 'firefox', 'Firefox has different fonts on local and ci');
+ const sharedNotes = new SharedNotes(browser, context);
+ await initializePages(sharedNotes, browser, { isMultiUser: true, createParameter: CREATE_PARAMETER, testInfo });
await sharedNotes.typeInSharedNotes();
});
- test('Formate text in shared notes', async () => {
+ test('Format text in shared notes', async ({ browser, context }, testInfo) => {
+ const sharedNotes = new SharedNotes(browser, context);
+ await initializePages(sharedNotes, browser, { isMultiUser: true, createParameter: CREATE_PARAMETER, testInfo });
await sharedNotes.formatTextInSharedNotes();
});
- test('Export shared notes', async () => {
+ test('Export shared notes', async ({ browser, context }, testInfo) => {
+ const sharedNotes = new SharedNotes(browser, context);
+ await initializePages(sharedNotes, browser, { isMultiUser: true, createParameter: CREATE_PARAMETER, testInfo });
await sharedNotes.exportSharedNotes();
});
- test('Convert notes to presentation', async () => {
+ test('Convert notes to presentation', async ({ browser, context }, testInfo) => {
+ const sharedNotes = new SharedNotes(browser, context);
+ await initializePages(sharedNotes, browser, { isMultiUser: true, createParameter: CREATE_PARAMETER, testInfo });
await sharedNotes.convertNotesToWhiteboard();
});
- test('Multiusers edit', async () => {
+ test('Multiusers edit', async ({ browser, context }, testInfo) => {
+ const sharedNotes = new SharedNotes(browser, context);
+ await initializePages(sharedNotes, browser, { isMultiUser: true, createParameter: CREATE_PARAMETER, testInfo });
await sharedNotes.editSharedNotesWithMoreThanOneUSer();
});
- test('See notes without edit permission', async () => {
+ test('See notes without edit permission', async ({ browser, context }, testInfo) => {
+ const sharedNotes = new SharedNotes(browser, context);
+ await initializePages(sharedNotes, browser, { isMultiUser: true, createParameter: CREATE_PARAMETER, testInfo });
await sharedNotes.seeNotesWithoutEditPermission();
});
// different failures in CI and local
// local: not able to click on "unpin" button
// CI: not restoring presentation for viewer after unpinning notes
- test('Pin and unpin notes onto whiteboard', async ({ browserName }) => {
+ test('Pin and unpin notes onto whiteboard', async ({ browser, context, browserName }, testInfo) => {
test.skip(browserName === 'firefox', 'Webcams does not work properly, due to heavy firefox for testing');
+ const sharedNotes = new SharedNotes(browser, context);
+ await initializePages(sharedNotes, browser, { isMultiUser: true, createParameter: CREATE_PARAMETER, testInfo });
await sharedNotes.pinAndUnpinNotesOntoWhiteboard();
});
});
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 5630fb611dff..d9859305de37 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
@@ -379,7 +379,7 @@ class ApiController {
}
if (createTime != meeting.getCreateTime()) {
// BEGIN - backward compatibility
- invalid("mismatchCreateTimeParam", "The createTime parameter submitted mismatches with the current meeting.", redirectClient, errorRedirectUrl);
+ invalid("mismatchCreateTimeParam", "The createTime parameter submitted mismatches with the current meeting.", redirectClient, errorRedirectUrl, true, meeting.getLogoutUrl());
return
// END - backward compatibility
@@ -578,7 +578,7 @@ class ApiController {
if (hasReachedMaxParticipants(meeting, us)) {
// BEGIN - backward compatibility
- invalid("maxParticipantsReached", "The number of participants allowed for this meeting has been reached.", redirectClient, errorRedirectUrl)
+ invalid("maxParticipantsReached", "The number of participants allowed for this meeting has been reached.", redirectClient, errorRedirectUrl, true, us.logoutUrl)
return
// END - backward compatibility
@@ -635,7 +635,7 @@ class ApiController {
// have it wait for approval.
String destUrl = us.clientUrl
if (guestStatusVal == GuestPolicy.DENY) {
- invalid("guestDeniedAccess", "You have been denied access to this meeting based on the meeting's guest policy", redirectClient, errorRedirectUrl)
+ invalid("guestDeniedAccess", "You have been denied access to this meeting based on the meeting's guest policy", redirectClient, errorRedirectUrl, true, us.logoutUrl)
return
}
@@ -2101,7 +2101,7 @@ class ApiController {
}
//TODO: method added for backward compatibility, it will be removed in next versions after 0.8
- private void invalid(key, msg, redirectResponse = false, errorRedirectUrl = "", useLogoutUrl = true) {
+ private void invalid(key, msg, redirectResponse = false, errorRedirectUrl = "", useLogoutUrl = true, meetingLogoutUrl = "") {
// Note: This xml scheme will be DEPRECATED.
log.debug CONTROLLER_NAME + "#invalid " + msg
if (redirectResponse) {
@@ -2114,7 +2114,7 @@ class ApiController {
JSONArray errorsJSONArray = new JSONArray(errors)
log.debug "JSON Errors {}", errorsJSONArray.toString()
- respondWithRedirect(errorsJSONArray, errorRedirectUrl, useLogoutUrl)
+ respondWithRedirect(errorsJSONArray, errorRedirectUrl, useLogoutUrl, meetingLogoutUrl)
} else {
response.addHeader("Cache-Control", "no-cache")
withFormat {
@@ -2149,9 +2149,16 @@ class ApiController {
return newURL;
}
- private void respondWithRedirect(errorsJSONArray, redirectUrl = "", useLogoutUrl = true) {
+ private void respondWithRedirect(errorsJSONArray, redirectUrl = "", useLogoutUrl = true, meetingLogoutUrl = "") {
String uriString = paramsProcessorUtil.getDefaultLogoutUrl();
+ // The logoutURL stored on the meeting at create is not URL-validated, so
+ // only fall back to it when it can actually serve as a redirect target
+ if (useLogoutUrl && !StringUtils.isEmpty(meetingLogoutUrl)
+ && ServiceUtils.getValidationService().isValidURL(meetingLogoutUrl)) {
+ uriString = meetingLogoutUrl;
+ }
+
if (useLogoutUrl && !StringUtils.isEmpty(params.logoutURL)) {
try {
uriString = params.logoutURL;
diff --git a/docs/docs/administration/customize.md b/docs/docs/administration/customize.md
index 0878fda3252d..4222ee4d954a 100644
--- a/docs/docs/administration/customize.md
+++ b/docs/docs/administration/customize.md
@@ -1569,6 +1569,7 @@ These configs can be set in `/etc/bigbluebutton/bbb-web.properties`. The table i
| `learningDashboardCleanupDelayInMinutes` | Minutes the Learning Dashboard remains available after the meeting ends | Integer (0=keep permanently) | 2 _`overwritable`_ |
| `disabledFeatures` | Comma-separated list of features to disable (see [`/create` docs](/development/api/#create) for the full list of feature names) | csv | _(empty)_ _`overwritable`_ |
| `sharedNotesEditor` | Type of shared notes editor to use | etherpad, blockNote | etherpad _`overwritable`_ |
+| `maxSharedNotesInitialContentUrlPayloadSize` | Maximum size (in KiB) of the response fetched when seeding shared-notes initial content from `sharedNotesInitialContentJsonUrl` / `sharedNotesInitialContentMarkdownUrl` | Integer (KiB) | 1024 |
| `allowOverrideClientSettingsOnCreateCall` | Allow `clientSettingsOverride` / `clientSettingsOverrideJsonUrl` to be passed on `/create` | true/false | false |
| `clientSettingsOverrideStrictValidation` | When true, reject the `/create` call (`bbb-web`) and refuse `bbb-apps-akka` boot if a client settings override has unknown or malformed keys. Intended for test/staging (see [Validating client settings overrides](#validating-client-settings-overrides)) | true/false | false |
| `clientSettingsFilePath` | Path to the `settings.yml` catalog used as the schema for the strict client-settings override validation above | path | `/usr/share/bigbluebutton/html5-client/private/config/settings.yml` |
diff --git a/docs/docs/data/create.tsx b/docs/docs/data/create.tsx
index fbb9b05fa920..f5c36ee356ee 100644
--- a/docs/docs/data/create.tsx
+++ b/docs/docs/data/create.tsx
@@ -56,7 +56,7 @@ const createEndpointTableData = [
"name": "voiceBridge",
"required": false,
"type": "String",
- "description": (<>Voice conference number for the FreeSWITCH voice conference associated with this meeting. This must be a 5-digit number in the range 10000 to 99999. If you add a phone number to your BigBlueButton server, This parameter sets the personal identification number (PIN) that FreeSWITCH will prompt for a phone-only user to enter. If you want to change this range, edit FreeSWITCH dialplan and defaultNumDigitsForTelVoice of bigbluebutton.properties.
The voiceBridge number must be different for every meeting.
This parameter is optional. If you do not specify a voiceBridge number, then BigBlueButton will assign a random unused number for the meeting.
If do you pass a voiceBridge number, then you must ensure that each meeting has a unique voiceBridge number; otherwise, reusing same voiceBridge number for two different meetings will cause users from one meeting to appear as phone users in the other, which will be very confusing to users in both meetings.>)
+ "description": (<>Voice conference number for the FreeSWITCH voice conference associated with this meeting. This must be a 5-digit numeric string in the range 00000 to 99999. If you add a phone number to your BigBlueButton server, This parameter sets the personal identification number (PIN) that FreeSWITCH will prompt for a phone-only user to enter. If you want to change this range, edit FreeSWITCH dialplan and defaultNumDigitsForTelVoice of bigbluebutton.properties.
The voiceBridge number must be different for every meeting.
This parameter is optional. If you do not specify a voiceBridge number, then BigBlueButton will assign a random unused number for the meeting.
If do you pass a voiceBridge number, then you must ensure that each meeting has a unique voiceBridge number; otherwise, reusing same voiceBridge number for two different meetings will cause users from one meeting to appear as phone users in the other, which will be very confusing to users in both meetings.>)
},
{
"name": "maxParticipants",
@@ -76,6 +76,12 @@ const createEndpointTableData = [
"type": "String",
"description": (<>The URL that the BigBlueButton client will go to after users click the OK button on the ‘You have been logged out message’. This overrides the value for bigbluebutton.web.logoutURL in bigbluebutton.properties.>)
},
+ {
+ "name": "meetingEndedURL",
+ "required": false,
+ "type": "String",
+ "description": (<>Server-to-server callback URL that BigBlueButton will invoke when the meeting ends. Useful for third-party integrations that need to react to meeting termination. (added 2.2)>)
+ },
{
"name": "record",
"required": false,
@@ -126,6 +132,30 @@ const createEndpointTableData = [
"default": false,
"description": (<>If set to false, breakout rooms will not be recorded.>)
},
+ {
+ "name": "breakoutRoomsCaptureSlides",
+ "required": false,
+ "type": "Boolean",
+ "description": (<>If set to true, the current slide (with annotations) from each breakout room is exported back to the parent meeting's presentation when the breakout ends. The server-side default is taken from defaultBreakoutRoomsCaptureSlides. (added 2.6)>)
+ },
+ {
+ "name": "breakoutRoomsCaptureSlidesFilename",
+ "required": false,
+ "type": "String",
+ "description": (<>Filename template for slides captured from breakout rooms when breakoutRoomsCaptureSlides=true. (added 2.6)>)
+ },
+ {
+ "name": "breakoutRoomsCaptureNotes",
+ "required": false,
+ "type": "Boolean",
+ "description": (<>If set to true, the shared notes from each breakout room are exported back to the parent meeting's presentation when the breakout ends. The server-side default is taken from defaultBreakoutRoomsCaptureNotes. (added 2.6)>)
+ },
+ {
+ "name": "breakoutRoomsCaptureNotesFilename",
+ "required": false,
+ "type": "String",
+ "description": (<>Filename template for shared notes captured from breakout rooms when breakoutRoomsCaptureNotes=true. (added 2.6)>)
+ },
{
"name": "meta",
"required": false,
@@ -240,7 +270,7 @@ const createEndpointTableData = [
"required": false,
"type": "Boolean",
"default": false,
- "description": (<>Setting to true will disable notes in the meeting. (added 2.2)>)
+ "description": (<>Setting to true will disable notes in the meeting. (added 2.2)
Note: lockSettingsDisableNote (singular) is accepted as a deprecated alias and logs a deprecation warning on the server.>)
},
{
"name": "lockSettingsHideUserList",
@@ -347,6 +377,30 @@ const createEndpointTableData = [
"default": 0,
"description": (<>Setting to 0 will disable this threshold. Defines the max number of webcams a meeting can have simultaneously. (added 2.5.0)>)
},
+ {
+ "name": "maxPinnedCameras",
+ "required": false,
+ "type": "Number",
+ "description": (<>Per-meeting override of the maxPinnedCameras property in bigbluebutton.properties. Caps how many cameras can be pinned simultaneously in this meeting. Only positive values are applied. (added 2.6)>)
+ },
+ {
+ "name": "cameraBridge",
+ "required": false,
+ "type": "String",
+ "description": (<>Per-meeting override of the cameraBridge property. Selects the media bridge used for camera streams. Valid values: bbb-webrtc-sfu, livekit. (added 3.0)>)
+ },
+ {
+ "name": "screenShareBridge",
+ "required": false,
+ "type": "String",
+ "description": (<>Per-meeting override of the screenShareBridge property. Selects the media bridge used for screen share streams. Valid values: bbb-webrtc-sfu, livekit. (added 3.0)>)
+ },
+ {
+ "name": "audioBridge",
+ "required": false,
+ "type": "String",
+ "description": (<>Per-meeting override of the audioBridge property. Selects the media bridge used for audio streams. Valid values: bbb-webrtc-sfu, livekit, freeswitch. (added 3.0)>)
+ },
{
"name": "meetingExpireIfNoUserJoinedInMinutes",
"required": false,
@@ -373,6 +427,12 @@ const createEndpointTableData = [
"type": "String",
"description": (<>Pass a URL to an image which will then be visible in the area above the participants list if displayBrandingArea is set to true in bbb-html5's configuration>)
},
+ {
+ "name": "darklogo",
+ "required": false,
+ "type": "String",
+ "description": (<>Like logo, but used when the client is in dark mode. If only logo is provided, it is used in both light and dark modes. (added 3.0)>)
+ },
{
"name": "sharedNotesEditor",
"required": false,
@@ -390,13 +450,13 @@ const createEndpointTableData = [
"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.>)
+ "description": (<>Raw markdown used as the shared-notes initial content (Only applicable for when `sharedNotesEditor=blockNote`, ignored otherwise). When `sharedNotesInitialContentMarkdownUrl` is also provided, the URL takes precedence over this inline value; this inline parameter in turn takes precedence over the `sharedNotesInitialContentMarkdown` POST module.>)
},
{
"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.>)
+ "description": (<>Url from which the shared-notes will fetch the initial content as markdown (Only applicable for when `sharedNotesEditor=blockNote`, ignored otherwise). When provided, it takes precedence over the inline `sharedNotesInitialContentMarkdown` create parameter and POST module. 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",
diff --git a/docs/docs/development/api.md b/docs/docs/development/api.md
index 5856112d5437..74fb5aad834b 100644
--- a/docs/docs/development/api.md
+++ b/docs/docs/development/api.md
@@ -71,7 +71,7 @@ Updated in 2.0:
Updated in 2.2:
-- **create** - Added `endWhenNoModerator`.
+- **create** - Added `endWhenNoModerator`, `meetingEndedURL`.
- **getRecordingTextTracks** - Get a list of the caption/subtitle files currently available for a recording.
- **putRecordingTextTrack** - Upload a caption or subtitle file to add it to the recording. If there is any existing track with the same values for kind and lang, it will be replaced.
@@ -99,7 +99,7 @@ Updated in 2.5:
Updated in 2.6:
-- **create** - **Added:** `notifyRecordingIsOn`, `presentationUploadExternalUrl`, `presentationUploadExternalDescription`, `recordFullDurationMedia` (v2.6.9); `disabledFeaturesExclude`(2.6.9); Added `liveTranscription` and `presentation` as options for `disabledFeatures`.
+- **create** - **Added:** `notifyRecordingIsOn`, `presentationUploadExternalUrl`, `presentationUploadExternalDescription`, `recordFullDurationMedia` (v2.6.9); `disabledFeaturesExclude`(2.6.9); `maxPinnedCameras`, `breakoutRoomsCaptureSlides`, `breakoutRoomsCaptureSlidesFilename`, `breakoutRoomsCaptureNotes`, `breakoutRoomsCaptureNotesFilename`; Added `liveTranscription` and `presentation` as options for `disabledFeatures`.
- **getRecordings** - **Added:** Added support for pagination using `offset`, `limit`
@@ -114,7 +114,7 @@ Updated in 2.7:
Updated in 3.0:
- **create**
- - **Added parameters:** `loginURL`, `pluginManifests`, `pluginManifestsFetchUrl`, `presentationConversionCacheEnabled`, `maxNumPages`, `multiUserWhiteboardEnabled`, `clientSettingsOverrideJsonUrl`, `sharedNotesEditor`.
+ - **Added parameters:** `loginURL`, `pluginManifests`, `pluginManifestsFetchUrl`, `presentationConversionCacheEnabled`, `maxNumPages`, `multiUserWhiteboardEnabled`, `clientSettingsOverrideJsonUrl`, `sharedNotesEditor`, `cameraBridge`, `screenShareBridge`, `audioBridge`, `darklogo`.
- **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` (gated by the server-side setting `allowOverrideClientSettingsOnCreateCall` in `bbb-web.properties`);
diff --git a/docs/docs/new-features.md b/docs/docs/new-features.md
index b3cea238a304..6c9472319ea5 100644
--- a/docs/docs/new-features.md
+++ b/docs/docs/new-features.md
@@ -143,6 +143,25 @@ To enable it, you would first need to install the optional package via
At this point you can use it in a specific session by passing `sharedNotesEditor=blockNote` on the `/create` call.
If you have made up your mind and would like to use it for all sessions, add the same line (`sharedNotesEditor=blockNote`) to `/etc/bigbluebutton/bbb-web.properties` and restart BigBlueButton via `$ sudo bbb-conf --restart`
+#### Import and export BlockNote shared notes as Markdown
+
+The BlockNote shared notes editor can now exchange content as Markdown (available in BigBlueButton 3.0.33). From the shared notes options menu, the presenter can choose **Import from Markdown**, which opens a dialog to either upload a Markdown file (drag-and-drop or file picker) or paste Markdown directly. The imported content can be **appended** to the existing notes (the default, so importing never destroys what is already there) or **replace** the whole document. Separately, an **Export notes as Markdown** option downloads the current notes as a `.md` file.
+
+Both options are **disabled by default** in BigBlueButton 3.0 so that a minor upgrade does not add new menu buttons unexpectedly. Enable either or both in `/etc/bigbluebutton/bbb-html5.yml` and restart with `sudo bbb-conf --restart`:
+
+```yaml
+public:
+ sharedNotes:
+ importMarkdownEnabled: true
+ exportMarkdownEnabled: true
+```
+
+These toggles only affect the BlockNote editor; they are ignored when Etherpad is used.
+
+Integrations can also seed a session's shared notes with Markdown at creation time using the `sharedNotesInitialContentMarkdown` / `sharedNotesInitialContentMarkdownUrl` create parameters (or a `sharedNotesInitialContentMarkdown` POST module). See the [Create API parameters](/development/api/#get-post-create) for details.
+
+
+
### Engagement
@@ -307,6 +326,8 @@ For full details on what is new in BigBlueButton 3.0, see the release notes.
Recent releases:
+- [3.0.33](https://github.com/bigbluebutton/bigbluebutton/releases/tag/v3.0.33)
+- [3.0.32](https://github.com/bigbluebutton/bigbluebutton/releases/tag/v3.0.32)
- [3.0.31](https://github.com/bigbluebutton/bigbluebutton/releases/tag/v3.0.31)
- [3.0.30](https://github.com/bigbluebutton/bigbluebutton/releases/tag/v3.0.30)
- [3.0.29](https://github.com/bigbluebutton/bigbluebutton/releases/tag/v3.0.29)
@@ -399,6 +420,11 @@ In BigBlueButton 3.0.0-alpha.5 we replaced the JOIN parameter `defaultLayout` wi
- Client settings.yml: `showGuestLobbyWaitingQueuePosition`. Defaults to `true`
+#### Added new settings to enable Markdown import/export in shared notes
+
+- Client settings.yml: `public.sharedNotes.importMarkdownEnabled`. Defaults to `false`. When `true`, presenters see an **Import from Markdown** option in the BlockNote shared notes menu.
+- Client settings.yml: `public.sharedNotes.exportMarkdownEnabled`. Defaults to `false`. When `true`, an **Export notes as Markdown** option is shown in the BlockNote shared notes menu.
+
#### Added new setting and userdata to allow skipping echo test if session has valid input/output devices stored
- Client settings.yml: `skipEchoTestIfPreviousDevice`. Defaults to `false`
@@ -446,6 +472,7 @@ Modified/added events
- `muteOnStart` default value changed to `true` - which helps now that `transparentListenOnly` is enabled by default too. See [PR 20848](https://github.com/bigbluebutton/bigbluebutton/issues/20848) for more info.
- `insertDocumentSupportedProtocols` renamed to `fetchUrlSupportedProtocols`
- `insertDocumentBlockedHosts` renamed to `fetchUrlBlockedExternalHosts`
+- `html5PluginSdkVersion` bumped to `0.0.103`
#### Added
- `pluginManifestFetchTimeout` added
@@ -498,6 +525,7 @@ Modified/added events
- `pluginManifestCacheRefreshIntervalMinutes` added in BBB 3.0.27
- `clientSettingsOverrideStrictValidation` added in BBB 3.0.30
- `clientSettingsFilePath` added in BBB 3.0.30
+- `maxSharedNotesInitialContentUrlPayloadSize` added — caps the size (in KiB, default `1024`) of the response fetched by `sharedNotesInitialContentJsonUrl` / `sharedNotesInitialContentMarkdownUrl`
### Removed support for POST requests on `join` endpoint and Content-Type headers are now required
diff --git a/docs/docs/plugins.md b/docs/docs/plugins.md
index 9061558d8cbe..a5d705371dce 100644
--- a/docs/docs/plugins.md
+++ b/docs/docs/plugins.md
@@ -829,6 +829,16 @@ That being said, here are the extensible areas we have so far:
Mind that no plugin will interfere into another's extensible area. So feel free to set whatever you need into a certain plugin with no worries.
+#### Configurable button styles
+
+Plugin-provided **nav bar**, **actions bar**, and **presentation toolbar** buttons accept a few optional style fields that control the rendered button's shape. They are read when present and fall back to the previous defaults when omitted, so older plugin objects keep rendering exactly as before (requires plugin SDK `0.0.100` or later):
+
+- `color` — button color (e.g. `primary`, `default`). Default: `primary` in the nav bar and actions bar, `default` in the presentation toolbar.
+- `circle` — render as a circular icon button. Default: `true` in the actions bar, `false` in the presentation toolbar and nav-bar.
+- `hideLabel` — hide the text label and show only the icon. Default: `true` in the actions bar, `false` in the presentation toolbar and nav-bar.
+- `size` — button size (`sm`, `md`, `lg`). Default: `lg` in the actions bar, `md` in the presentation toolbar and nav-bar.
+- `style` — an inline CSS style object applied to the button.
+
### Auxiliaries:
- `getSessionToken`: returns the user session token located on the user's URL.
diff --git a/docs/package-lock.json b/docs/package-lock.json
index 1c35f943915d..02303db71c5a 100644
--- a/docs/package-lock.json
+++ b/docs/package-lock.json
@@ -6658,9 +6658,9 @@
}
},
"node_modules/body-parser": {
- "version": "1.20.5",
- "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
- "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+ "version": "1.20.6",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
+ "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
@@ -9556,9 +9556,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
- "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
+ "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"funding": [
{
"type": "github",
@@ -17708,9 +17708,9 @@
}
},
"node_modules/shell-quote": {
- "version": "1.8.4",
- "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
- "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz",
+ "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -18187,9 +18187,9 @@
"license": "MIT"
},
"node_modules/svgo": {
- "version": "3.3.3",
- "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz",
- "integrity": "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==",
+ "version": "3.3.4",
+ "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.4.tgz",
+ "integrity": "sha512-GsNRis4e8jxn2Y9ENz/8lbJ93CstG8svtMnuRaHbiF2LTJ5tK0/q3t/URPq9Zc7zVWBJnNnJMIp6bevK7bSmNg==",
"license": "MIT",
"dependencies": {
"commander": "^7.2.0",
diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css
index d9b6910e890e..f06eb823fc44 100644
--- a/docs/src/css/custom.css
+++ b/docs/src/css/custom.css
@@ -71,14 +71,14 @@ html {
@font-face {
font-family: 'Open Sans';
src: url('/static/fonts/OpenSans-SemiBold.ttf');
- font-weight: bolder;
+ font-weight: 600;
font-style: normal;
}
@font-face {
font-family: 'Open Sans';
src: url('/static/fonts/OpenSans-SemiBoldItalic.ttf');
- font-weight: bolder;
+ font-weight: 600;
font-style: italic;
}