Merge branch 'v2.6.x-release' of github.com:bigbluebutton/bigbluebutton into mar-30-dev

This commit is contained in:
Anton Georgiev 2023-03-30 09:10:16 -04:00
commit c830582202
89 changed files with 2365 additions and 446 deletions

View File

@ -5,6 +5,9 @@ on:
- 'develop'
- 'v2.[5-9].x-release'
- 'v[3-9].*.x-release'
paths-ignore:
- 'docs/**'
- '**/*.md'
pull_request:
types: [opened, synchronize, reopened]
paths-ignore:

View File

@ -283,7 +283,8 @@ class RedisRecorderActor(
}
private def handleSendWhiteboardAnnotationsEvtMsg(msg: SendWhiteboardAnnotationsEvtMsg) {
msg.body.annotations.foreach(annotation => {
// filter poll annotations that are still not tldraw ready
msg.body.annotations.filter(!_.annotationInfo.contains("answers")).foreach(annotation => {
val ev = new AddTldrawShapeWhiteboardRecordEvent()
ev.setMeetingId(msg.header.meetingId)
ev.setPresentation(getPresentationId(annotation.wbId))

View File

@ -136,7 +136,8 @@ object RecMeta {
def getRecMeta(metaXml: Elem): Option[RecMeta] = {
val id = getText(metaXml, "id", "unknown")
val state = getText(metaXml, "state", "unknown")
val stateVal = getText(metaXml, "state", "unknown")
val state = if (stateVal.equalsIgnoreCase("available")) "published" else stateVal
val published = getText(metaXml, "published", "true").toString.toBoolean
val startTime = getValLong(metaXml, "start_time", 0)
val endTime = getValLong(metaXml, "end_time", 0)
@ -151,7 +152,11 @@ object RecMeta {
val meetingId = meeting match {
case Some(m) => m.externalId
case None => id
case None =>
meta match {
case Some(m) => m.getOrElse("meetingId", id)
case None => id
}
}
val meetingName = meeting match {
@ -165,7 +170,7 @@ object RecMeta {
val internalMeetingId = meeting match {
case Some(m) => Some(m.id)
case None => None
case None => Some(id)
}
val isBreakout = meeting match {

View File

@ -16,9 +16,9 @@
},
"process": {
"whiteboardTextEncoding": "utf-8",
"maxImageWidth": 2048,
"maxImageHeight": 1536,
"textScaleFactor": 4,
"maxImageWidth": 1440,
"maxImageHeight": 1080,
"textScaleFactor": 2,
"pointsPerInch": 72,
"pixelsPerInch": 96
},

View File

@ -105,7 +105,7 @@ function determine_font_from_family(family) {
}
function rad_to_degree(angle) {
return angle * (180 / Math.PI);
return angle * (180 / Math.PI) || 0;
}
// Convert pixels to points
@ -608,24 +608,33 @@ function overlay_shape_label(svg, annotation) {
const label_center_y = shape_y + shape_height * y_offset;
render_textbox(fontColor, font, fontSize, textAlign, text, id);
const shape_label = path.join(dropbox, `text${id}.png`);
if (fs.existsSync(shape_label)) {
const dimensions = probe.sync(fs.readFileSync(shape_label));
const labelWidth = dimensions.width / config.process.textScaleFactor;
const labelHeight = dimensions.height / config.process.textScaleFactor;
// Poll results must fit inside shape, unlike other rectangle labels.
// Linewrapping handled by client.
const ref = `file://${dropbox}/text${id}.png`;
const transform = `rotate(${rotation} ${label_center_x} ${label_center_y})`
const fitLabelToShape = annotation?.name?.startsWith('poll-result');
let labelWidth = shape_width;
let labelHeight = shape_height;
if (!fitLabelToShape) {
const dimensions = probe.sync(fs.readFileSync(shape_label));
labelWidth = dimensions.width / config.process.textScaleFactor;
labelHeight = dimensions.height / config.process.textScaleFactor;
}
svg.ele('g', {
transform: `rotate(${rotation} ${label_center_x} ${label_center_y})`,
transform: transform,
}).ele('image', {
'x': label_center_x - (labelWidth * x_offset),
'y': label_center_y - (labelHeight * y_offset),
'width': labelWidth,
'height': labelHeight,
'xlink:href': `file://${dropbox}/text${id}.png`,
}).up();
}
'xlink:href': ref,
}).up();
}
}
function overlay_sticky(svg, annotation) {

View File

@ -582,6 +582,7 @@ class App extends React.Component {
</p>
</div>
<button
data-test="downloadSessionDataDashboard"
type="button"
className="border-2 text-gray-700 border-gray-200 rounded-md px-4 py-2 bg-white focus:outline-none focus:ring ring-offset-2 focus:ring-gray-500 focus:ring-opacity-50"
onClick={this.handleSaveSessionData.bind(this)}

View File

@ -1 +1 @@
git clone --branch v5.0.0-rc.2 --depth 1 https://github.com/bigbluebutton/bbb-playback bbb-playback
git clone --branch v5.0.0 --depth 1 https://github.com/bigbluebutton/bbb-playback bbb-playback

View File

@ -1,6 +1,6 @@
#!/bin/sh
set -ex
RELEASE=4.0.0-rc.2
RELEASE=4.0.1
cat <<MSG
This tool downloads prebuilt packages built on Github Actions
The corresponding source can be browsed at https://github.com/bigbluebutton/bbb-presentation-video/tree/${RELEASE}

View File

@ -39,10 +39,10 @@
errorContainer.classList.add("error-div");
welcomeMessage.before(errorContainer);
for (i in jsonString) {
for (let i in jsonString) {
const newError = document.createElement('p')
newError.classList.add("error-message");
newError.innerHTML = "<b>Error</b>: " + jsonString[i].message;
newError.innerText = "Error: " + jsonString[i].message;
errorContainer.appendChild(newError);
}

View File

@ -5,8 +5,8 @@ const calculateSlideData = (slideData) => {
} = slideData;
// calculating viewBox and offsets for the current presentation
const maxImageWidth = 2048;
const maxImageHeight = 1536;
const maxImageWidth = 1440;
const maxImageHeight = 1080;
const ratio = Math.min(maxImageWidth / width, maxImageHeight / height);
const scaledWidth = width * ratio;

View File

@ -9,7 +9,7 @@ import Modal from '/imports/ui/components/common/modal/fullscreen/component';
import { withModalMounter } from '/imports/ui/components/common/modal/service';
import SortList from './sort-user-list/component';
import Styled from './styles';
import Icon from '/imports/ui/components/common/icon/component.jsx';
import Icon from '/imports/ui/components/common/icon/component';
import { isImportSharedNotesFromBreakoutRoomsEnabled, isImportPresentationWithAnnotationsFromBreakoutRoomsEnabled } from '/imports/ui/services/features';
import { addNewAlert } from '/imports/ui/components/screenreader-alert/service';
import PresentationUploaderService from '/imports/ui/components/presentation/presentation-uploader/service';
@ -195,6 +195,13 @@ const propTypes = {
isBreakoutRecordable: PropTypes.bool,
};
const setPresentationVisibility = (state) => {
const presentationInnerWrapper = document.getElementById('presentationInnerWrapper');
if (presentationInnerWrapper) {
presentationInnerWrapper.style.display = state;
}
}
class BreakoutRoom extends PureComponent {
constructor(props) {
super(props);
@ -263,6 +270,7 @@ class BreakoutRoom extends PureComponent {
allowUserChooseRoomByDefault, captureSharedNotesByDefault,
captureWhiteboardByDefault,
} = this.props;
setPresentationVisibility('none');
this.setRoomUsers();
if (isUpdate) {
const usersToMerge = []
@ -403,7 +411,7 @@ class BreakoutRoom extends PureComponent {
handleDismiss() {
const { mountModal } = this.props;
setPresentationVisibility('block');
return new Promise((resolve) => {
mountModal(null);
@ -414,6 +422,7 @@ class BreakoutRoom extends PureComponent {
}
onCreateBreakouts() {
setPresentationVisibility('block');
const {
createBreakoutRoom,
} = this.props;
@ -505,6 +514,7 @@ class BreakoutRoom extends PureComponent {
}
onUpdateBreakouts() {
setPresentationVisibility('block');
const { users } = this.state;
const leastOneUserIsValid = users.some((user) => user.from !== user.room);

View File

@ -241,6 +241,11 @@ const RoomUserItem = styled.p`
font-size: ${fontSizeSmaller};
}
&:focus {
background-color: ${colorPrimary};
color: ${colorWhite};
}
${({ selected }) => selected && `
background-color: ${colorPrimary};
color: ${colorWhite};

View File

@ -40,7 +40,6 @@ import SidebarNavigationContainer from '../sidebar-navigation/container';
import SidebarContentContainer from '../sidebar-content/container';
import { makeCall } from '/imports/ui/services/api';
import ConnectionStatusService from '/imports/ui/components/connection-status/service';
import DarkReader from 'darkreader';
import Settings from '/imports/ui/services/settings';
import { registerTitleView } from '/imports/utils/dom-utils';
import Notifications from '../notifications/container';
@ -50,6 +49,7 @@ import PushLayoutEngine from '../layout/push-layout/pushLayoutEngine';
import AudioService from '/imports/ui/components/audio/service';
import NotesContainer from '/imports/ui/components/notes/container';
import DEFAULT_VALUES from '../layout/defaultValues';
import AppService from '/imports/ui/components/app/service';
const MOBILE_MEDIA = 'only screen and (max-width: 40em)';
const APP_CONFIG = Meteor.settings.public.app;
@ -446,22 +446,8 @@ class App extends Component {
renderDarkMode() {
const { darkTheme } = this.props;
if (darkTheme && !DarkReader.isEnabled()) {
DarkReader.enable(
{ brightness: 100, contrast: 90 },
{ invert: [Styled.DtfInvert], ignoreInlineStyle: [Styled.DtfCss], ignoreImageAnalysis: [Styled.DtfImages] },
)
logger.info({
logCode: 'dark_mode',
}, 'Dark mode is on.');
}
if (!darkTheme && DarkReader.isEnabled()){
DarkReader.disable();
logger.info({
logCode: 'dark_mode',
}, 'Dark mode is off.');
}
AppService.setDarkTheme(darkTheme);
}
mountPushLayoutEngine() {

View File

@ -3,6 +3,9 @@ import Meetings from '/imports/api/meetings';
import Settings from '/imports/ui/services/settings';
import Auth from '/imports/ui/services/auth/index';
import deviceInfo from '/imports/utils/deviceInfo';
import Styled from './styles';
import DarkReader from 'darkreader';
import logger from '/imports/startup/client/logger';
const getFontSize = () => {
const applicationSettings = Settings.application;
@ -26,9 +29,34 @@ const validIOSVersion = () => {
return true;
};
const setDarkTheme = (value) => {
if (value && !DarkReader.isEnabled()) {
DarkReader.enable(
{ brightness: 100, contrast: 90 },
{ invert: [Styled.DtfInvert], ignoreInlineStyle: [Styled.DtfCss], ignoreImageAnalysis: [Styled.DtfImages] },
)
logger.info({
logCode: 'dark_mode',
}, 'Dark mode is on.');
}
if (!value && DarkReader.isEnabled()){
DarkReader.disable();
logger.info({
logCode: 'dark_mode',
}, 'Dark mode is off.');
}
}
const isDarkThemeEnabled = () => {
return DarkReader.isEnabled()
}
export {
getFontSize,
meetingIsBreakout,
getBreakoutRooms,
validIOSVersion,
setDarkTheme,
isDarkThemeEnabled,
};

View File

@ -43,9 +43,9 @@ export const getLoginTime = () => (Users.findOne({ userId: Auth.userID }) || {})
const generateTimeWindow = (timestamp) => {
const groupingTime = getGroupingTime();
dateInMilliseconds = Math.floor(timestamp);
groupIndex = Math.floor(dateInMilliseconds / groupingTime)
date = groupIndex * 30000;
const dateInMilliseconds = Math.floor(timestamp);
const groupIndex = Math.floor(dateInMilliseconds / groupingTime)
const date = groupIndex * 30000;
return date;
}

View File

@ -97,7 +97,7 @@ const LayoutModalComponent = (props) => {
{ ...application, selectedLayout, pushLayout: isKeepPushingLayout },
};
updateSettings(obj, intl.formatMessage(intlMessages.layoutToastLabel));
updateSettings(obj, intlMessages.layoutToastLabel);
closeModal();
};

View File

@ -168,11 +168,12 @@ class NavBar extends Component {
amIModerator,
style,
main,
isPinned,
sidebarNavigation,
currentUserId,
} = this.props;
const hasNotification = hasUnreadMessages || hasUnreadNotes;
const hasNotification = hasUnreadMessages || (hasUnreadNotes && !isPinned);
let ariaLabel = intl.formatMessage(intlMessages.toggleUserListAria);
ariaLabel += hasNotification ? (` ${intl.formatMessage(intlMessages.newMessages)}`) : '';

View File

@ -115,6 +115,7 @@ export default withTracker(() => {
}
return {
isPinned: NotesService.isSharedNotesPinned(),
currentUserId: Auth.userID,
meetingId,
presentationTitle: meetingTitle,

View File

@ -713,6 +713,7 @@ class Presentation extends PureComponent {
textAlign: 'center',
display: !presentationIsOpen ? 'none' : 'block',
}}
id={"presentationInnerWrapper"}
>
<Styled.VisuallyHidden id="currentSlideText">{slideContent}</Styled.VisuallyHidden>
{!tldrawIsMounting && currentSlide && this.renderPresentationMenu()}

View File

@ -9,6 +9,7 @@ import BBBMenu from '/imports/ui/components/common/menu/component';
import TooltipContainer from '/imports/ui/components/common/tooltip/container';
import { ACTIONS } from '/imports/ui/components/layout/enums';
import browserInfo from '/imports/utils/browserInfo';
import AppService from '/imports/ui/components/app/service';
const intlMessages = defineMessages({
downloading: {
@ -206,6 +207,13 @@ const PresentationMenu = (props) => {
},
});
// This is a workaround to a conflict of the
// dark mode's styles and the html-to-image lib.
// Issue:
// https://github.com/bubkoo/html-to-image/issues/370
const darkThemeState = AppService.isDarkThemeEnabled();
AppService.setDarkTheme(false);
try {
const { copySvg, getShapes, currentPageId } = tldrawAPI;
const svgString = await copySvg(getShapes(currentPageId).map((shape) => shape.id));
@ -239,6 +247,9 @@ const PresentationMenu = (props) => {
logCode: 'presentation_snapshot_error',
extraInfo: e,
});
} finally {
// Workaround
AppService.setDarkTheme(darkThemeState);
}
},
},

View File

@ -161,17 +161,21 @@ class PresentationToolbar extends PureComponent {
nextSlideHandler(event) {
const {
nextSlide, currentSlideNum, numberOfSlides, podId,
nextSlide, currentSlideNum, numberOfSlides, podId, endCurrentPoll,
} = this.props;
if (event) event.currentTarget.blur();
endCurrentPoll();
nextSlide(currentSlideNum, numberOfSlides, podId);
}
previousSlideHandler(event) {
const { previousSlide, currentSlideNum, podId } = this.props;
const {
previousSlide, currentSlideNum, podId, endCurrentPoll,
} = this.props;
if (event) event.currentTarget.blur();
endCurrentPoll();
previousSlide(currentSlideNum, podId);
}

View File

@ -10,6 +10,7 @@ import { UsersContext } from '/imports/ui/components/components-data/users-conte
import Auth from '/imports/ui/services/auth';
import FullscreenService from '/imports/ui/components/common/fullscreen-button/service';
import { isPollingEnabled } from '/imports/ui/services/features';
import { CurrentPoll } from '/imports/api/polls';
const PresentationToolbarContainer = (props) => {
const usingUsersContext = useContext(UsersContext);
@ -21,6 +22,10 @@ const PresentationToolbarContainer = (props) => {
const handleToggleFullScreen = (ref) => FullscreenService.toggleFullScreen(ref);
const endCurrentPoll = () => {
if (CurrentPoll.findOne({ meetingId: Auth.meetingID })) makeCall('stopPoll');
};
if (userIsPresenter && !layoutSwapped) {
// Only show controls if user is presenter and layout isn't swapped
@ -28,6 +33,7 @@ const PresentationToolbarContainer = (props) => {
<PresentationToolbar
{...props}
amIPresenter={userIsPresenter}
endCurrentPoll={endCurrentPoll}
{...{
handleToggleFullScreen,
}}
@ -78,6 +84,7 @@ PresentationToolbarContainer.propTypes = {
previousSlide: PropTypes.func.isRequired,
skipToSlide: PropTypes.func.isRequired,
layoutSwapped: PropTypes.bool,
endCurrentPoll: PropTypes.func.isRequired,
};
PresentationToolbarContainer.defaultProps = {

View File

@ -179,6 +179,7 @@ class ZoomTool extends PureComponent {
aria-describedby="zoomOutDescription"
aria-label={zoomOutAriaLabel}
label={intl.formatMessage(intlMessages.zoomOutLabel)}
data-test="zoomOutBtn"
icon="substract"
onClick={() => { }}
disabled={(zoomValue <= minBound) || !isMeteorConnected}
@ -198,6 +199,7 @@ class ZoomTool extends PureComponent {
size="md"
onClick={() => this.resetZoom()}
label={intl.formatMessage(intlMessages.resetZoomLabel)}
data-test="resetZoomButton"
hideLabel
/>
<div id="resetZoomDescription" hidden>

View File

@ -497,10 +497,22 @@ class PresentationUploader extends Component {
...filteredPresentations,
...filteredPropPresentations,
];
let hasUploading
merged.forEach(d => {
if (!d.upload?.done || !d.conversion?.done) {
hasUploading = true;
}})
this.hasError = false;
return this.setState({
presentations: merged,
});
if (hasUploading) {
return this.setState({
presentations: merged,
});
} else {
return this.setState({
presentations: merged,
disableActions: false,
});
}
}
const { presentations } = this.state;

View File

@ -86,6 +86,12 @@ const parseCurrentSlideContent = (yesValue, noValue, abstentionValue, trueValue,
const questionRegex = /.*?\?/gm;
const question = safeMatch(questionRegex, content, '');
if (question?.length > 0) {
const urlRegex = /\bhttps?:\/\/\S+\b/g;
const hasUrl = safeMatch(urlRegex, question[0], '');
if (hasUrl.length > 0) question.pop();
}
const doubleQuestionRegex = /\?{2}/gm;
const doubleQuestion = safeMatch(doubleQuestionRegex, content, false);

View File

@ -228,6 +228,13 @@ const VirtualBgSelector = ({
lastActivityDate: Date.now(),
},
});
const { filename, data, uniqueId } = background;
_virtualBgSelected(
EFFECT_TYPES.IMAGE_TYPE,
filename,
0,
{ file: data, uniqueId },
);
};
const onError = (error) => {
@ -354,6 +361,7 @@ const VirtualBgSelector = ({
type: 'delete',
uniqueId,
});
_virtualBgSelected(EFFECT_TYPES.NONE_TYPE);
}}
/>
</Styled.ButtonWrapper>

View File

@ -119,36 +119,36 @@ const renderGuestUserItem = (
</Styled.UserContentContainer>
<Styled.ButtonContainer key={`userlist-btns-${userId}`}>
{ isGuestLobbyMessageEnabled ? (
<Styled.WaitingUsersButton
key={`userbtn-message-${userId}`}
color="primary"
size="lg"
key={`userbtn-accept-${userId}`}
size="md"
aria-label={intl.formatMessage(intlMessages.accept)}
ghost
label={intl.formatMessage(intlMessages.privateMessageLabel)}
hideLabel
icon="add"
onClick={handleAccept}
data-test="acceptGuest"
/>
{ isGuestLobbyMessageEnabled ? (
<Styled.WaitingUsersButtonMsg
key={`userbtn-message-${userId}`}
size="lg"
aria-label={intl.formatMessage(intlMessages.privateMessageLabel)}
ghost
hideLabel
onClick={privateMessageVisible}
data-test="privateMessageGuest"
/>
) : null}
|
<Styled.WaitingUsersButton
key={`userbtn-accept-${userId}`}
color="primary"
size="lg"
ghost
label={intl.formatMessage(intlMessages.accept)}
onClick={handleAccept}
data-test="acceptGuest"
/>
|
<Styled.WaitingUsersButton
<Styled.WaitingUsersButtonDeny
key={`userbtn-deny-${userId}`}
color="danger"
size="lg"
aria-label={intl.formatMessage(intlMessages.deny)}
ghost
label={intl.formatMessage(intlMessages.deny)}
hideLabel
onClick={handleDeny}
data-test="denyGuest"
size="sm"
icon="close"
/>
</Styled.ButtonContainer>
</Styled.ListItem>

View File

@ -24,7 +24,6 @@ const ListItem = styled.div`
flex-direction: row;
align-items: center;
border-radius: 5px;
cursor: pointer;
${({ animations }) => animations && `
transition: all .3s;
@ -40,9 +39,6 @@ const ListItem = styled.div`
outline: none;
}
&:hover {
background-color: ${listItemBgHover};
}
flex-shrink: 0;
`;
@ -65,6 +61,7 @@ const UserName = styled.p`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: initial;
`;
const ButtonContainer = styled.div`
@ -73,12 +70,15 @@ const ButtonContainer = styled.div`
align-items: center;
color: ${colorPrimary};
& > button {
padding: 0 .25rem 0 .25rem;
padding: ${mdPaddingY};
font-size: ${fontSizeBase};
border-radius: 50%;
}
`;
const WaitingUsersButton = styled(Button)`
font-weight: 400;
color: ${colorPrimary};
&:focus {
background-color: ${listItemBgHover} !important;
@ -87,6 +87,42 @@ const WaitingUsersButton = styled(Button)`
}
&:hover {
color: ${colorPrimary};
background-color: ${listItemBgHover} !important;
}
`;
const WaitingUsersButtonMsg = styled(Button)`
font-weight: 400;
color: ${colorPrimary};
&:after {
font-family: 'bbb-icons';
content: "\\E910";
}
&:focus {
background-color: ${listItemBgHover} !important;
box-shadow: inset 0 0 0 ${borderSize} ${itemFocusBorder}, inset 1px 0 0 1px ${itemFocusBorder} ;
outline: none;
}
&:hover {
color: ${colorPrimary};
background-color: ${listItemBgHover} !important;
}
`;
const WaitingUsersButtonDeny = styled(Button)`
font-weight: 400;
color: #ff0e0e;
&:focus {
background-color: ${listItemBgHover} !important;
box-shadow: inset 0 0 0 ${borderSize} ${itemFocusBorder}, inset 1px 0 0 1px ${itemFocusBorder} ;
outline: none;
}
&:hover {
color: #ff0e0e;
background-color: ${listItemBgHover} !important;
}
`;
@ -205,6 +241,8 @@ export default {
UserName,
ButtonContainer,
WaitingUsersButton,
WaitingUsersButtonDeny,
WaitingUsersButtonMsg,
PendingUsers,
NoPendingUsers,
MainTitle,

View File

@ -9,7 +9,11 @@ import Cursors from './cursors/container';
import Settings from '/imports/ui/services/settings';
import logger from '/imports/startup/client/logger';
import KEY_CODES from '/imports/utils/keyCodes';
import { presentationMenuHeight } from '/imports/ui/stylesheets/styled-components/general';
import {
presentationMenuHeight,
styleMenuOffset,
styleMenuOffsetSmall
} from '/imports/ui/stylesheets/styled-components/general';
import Styled from './styles';
import PanToolInjector from './pan-tool-injector/component';
import {
@ -64,6 +68,7 @@ export default function Whiteboard(props) {
hasMultiUserAccess,
tldrawAPI,
setTldrawAPI,
isIphone,
} = props;
const { pages, pageStates } = initDefaultPages(curPres?.pages.length || 1);
const rDocument = React.useRef({
@ -234,8 +239,10 @@ export default function Whiteboard(props) {
// update document if the number of pages has changed
if (currentDoc.id !== whiteboardId && currentDoc?.pages.length !== curPres?.pages.length) {
const currentPageShapes = currentDoc?.pages[curPageId]?.shapes;
currentDoc.id = whiteboardId;
currentDoc.pages = pages;
currentDoc.pages[curPageId].shapes = currentPageShapes;
currentDoc.pageStates = pageStates;
}
@ -508,6 +515,8 @@ export default function Whiteboard(props) {
React.useEffect(() => {
if (isPresenter && slidePosition && tldrawAPI) {
tldrawAPI.zoomTo(0);
setHistory(null);
tldrawAPI.resetHistory();
}
}, [curPres?.id]);
@ -628,14 +637,21 @@ export default function Whiteboard(props) {
newApp.setHoveredId = () => { };
}
if (curPageId) {
app.changePage(curPageId);
setIsMounting(true);
}
if (history) {
app.replaceHistory(history);
}
if (curPageId) {
app.patchState(
{
appState: {
currentPageId: curPageId,
},
},
);
setIsMounting(true);
}
};
const onPatch = (e, t, reason) => {
@ -860,7 +876,10 @@ export default function Whiteboard(props) {
};
const onCommand = (app, command) => {
setHistory(app.history);
const isFirstCommand = command.id === "change_page" && command.before?.appState.currentPageId === "0";
if (!isFirstCommand){
setHistory(app.history);
}
const changedShapes = command.after?.document?.pages[app.currentPageId]?.shapes;
if (!isMounting && app.currentPageId !== curPageId) {
// can happen then the "move to page action" is called, or using undo after changing a page
@ -897,7 +916,7 @@ export default function Whiteboard(props) {
const webcams = document.getElementById('cameraDock');
const dockPos = webcams?.getAttribute('data-position');
if (currentTool && !isPanning) tldrawAPI?.selectTool(currentTool);
if (currentTool && !isPanning && !tldrawAPI?.isForcePanning) tldrawAPI?.selectTool(currentTool);
const editableWB = (
<Styled.EditableWBWrapper onKeyDown={handleOnKeyDown}>
@ -961,6 +980,19 @@ export default function Whiteboard(props) {
}
}
const menuOffsetValues = {
true: {
true: `${styleMenuOffsetSmall}`,
false: `${styleMenuOffset}`,
},
false: {
true: `-${styleMenuOffsetSmall}`,
false: `-${styleMenuOffset}`,
},
};
const menuOffset = menuOffsetValues[isRTL][isIphone];
return (
<>
<Cursors
@ -982,7 +1014,7 @@ export default function Whiteboard(props) {
isPresenter,
size,
darkTheme,
isRTL,
menuOffset,
}}
/>
</Cursors>
@ -1007,6 +1039,7 @@ export default function Whiteboard(props) {
Whiteboard.propTypes = {
isPresenter: PropTypes.bool.isRequired,
isIphone: PropTypes.bool.isRequired,
removeShapes: PropTypes.func.isRequired,
initDefaultPages: PropTypes.func.isRequired,
persistShape: PropTypes.func.isRequired,

View File

@ -25,6 +25,7 @@ import Auth from '/imports/ui/services/auth';
import PresentationToolbarService from '../presentation/presentation-toolbar/service';
import { layoutSelect } from '../layout/context';
import FullscreenService from '/imports/ui/components/common/fullscreen-button/service';
import deviceInfo from '/imports/utils/deviceInfo';
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
const WHITEBOARD_CONFIG = Meteor.settings.public.whiteboard;
@ -91,6 +92,7 @@ export default withTracker(({
}) => {
const shapes = getShapes(whiteboardId, curPageId, intl);
const curPres = getCurrentPres();
const { isIphone } = deviceInfo;
shapes['slide-background-shape'] = {
assetId: `slide-background-asset-${curPageId}`,
@ -135,6 +137,7 @@ export default withTracker(({
notifyNotAllowedChange,
notifyShapeNumberExceeded,
darkTheme,
isIphone,
};
})(WhiteboardContainer);

View File

@ -12,10 +12,10 @@ const TldrawGlobalStyle = createGlobalStyle`
display: none;
}
`}
${({ isRTL }) => `
${({ menuOffset }) => `
#TD-StylesMenu {
position: relative;
right: ${isRTL ? '7rem' : '-7rem'};
right: ${menuOffset};
}
`}
#TD-PrimaryTools-Image {
@ -23,6 +23,7 @@ const TldrawGlobalStyle = createGlobalStyle`
}
#slide-background-shape div {
pointer-events: none;
user-select: none;
}
div[dir*="ltr"]:has(button[aria-expanded*="false"][aria-controls*="radix-"]) {
pointer-events: none;
@ -80,6 +81,11 @@ const TldrawGlobalStyle = createGlobalStyle`
}
}
`}
${({ isPresenter }) => (!isPresenter) && `
#presentationInnerWrapper div{
cursor: default !important;
}
`}
`;
const EditableWBWrapper = styled.div`

View File

@ -86,6 +86,9 @@ const toastIconSm = '1.2rem';
const presentationMenuHeight = '45px';
const styleMenuOffset = '6.25rem';
const styleMenuOffsetSmall = '5rem';
export {
borderSizeSmall,
borderSize,
@ -165,4 +168,6 @@ export {
toastIconMd,
toastIconSm,
presentationMenuHeight,
styleMenuOffset,
styleMenuOffsetSmall,
};

View File

@ -17,6 +17,10 @@ import {
const GlobalStyle = createGlobalStyle`
// BBBMenu
@media ${smallOnly} {
.MuiPopover-root {
top: 0 !important;
}
.MuiPaper-root.MuiMenu-paper.MuiPopover-paper {
top: 0 !important;
left: 0 !important;

View File

@ -5,8 +5,8 @@
"app.chat.errorMaxMessageLength": "پیام خیلی طولانی است، از حداکثر {0} کاراکتر بیشتر است",
"app.chat.disconnected": "ارتباط شما قطع شده است، امکان ارسال پیام وجود ندارد",
"app.chat.locked": "گفنگو قفل شده است، امکان ارسال هیچ پیامی وجود ندارد",
"app.chat.inputLabel": "ورودی پیام برای گفتگو {0}",
"app.chat.emojiButtonLabel": "انتخاب کننده شکلک",
"app.chat.inputLabel": "ورودی پیام برای گفتگوی {0}",
"app.chat.emojiButtonLabel": "انتخابکننده شکلک",
"app.chat.inputPlaceholder": "پیام {0}",
"app.chat.titlePublic": "گفتگوی عمومی",
"app.chat.titlePrivate": "گفتگوی خصوصی با {0}",
@ -15,13 +15,13 @@
"app.chat.hideChatLabel": "پنهان‌سازی {0}",
"app.chat.moreMessages": "ادامه پیام‌ها در پایین",
"app.chat.dropdown.options": "گزینه‌های گفتگو",
"app.chat.dropdown.clear": "پاکسازی",
"app.chat.dropdown.clear": "پاکسازی",
"app.chat.dropdown.copy": "کپی",
"app.chat.dropdown.save": "ذخیره",
"app.chat.label": "گفتگو",
"app.chat.offline": "آفلاین",
"app.chat.pollResult": "نتایج نظرسنجی",
"app.chat.breakoutDurationUpdated": "زمان جلسه زیرمجموعه اکنون {0} دقیقه است",
"app.chat.breakoutDurationUpdated": "زمان جلسه جانبی اکنون {0} دقیقه است",
"app.chat.breakoutDurationUpdatedModerator": "زمان اتاق‌های جانبی اکنون {0} دقیقه است و آگاه‌سازی ارسال شده است.",
"app.chat.emptyLogLabel": "سابقه گفتگو خالی است",
"app.chat.clearPublicChatMessage": "سابقه گفتگوی عمومی توسط مدیر حذف گردید",
@ -29,11 +29,11 @@
"app.chat.one.typing": "{0} در حال نوشتن است",
"app.chat.two.typing": "{0} و {1} در حال نوشتن هستند",
"app.chat.copySuccess": "رونوشت گفتگو کپی شد",
"app.chat.copyErr": "کپی رونوشت گفتگو با خطا مواجه شد!",
"app.chat.copyErr": "کپی رونوشت گفتگو با خطا مواجه شد",
"app.emojiPicker.search": "جستجو",
"app.emojiPicker.notFound": "هیچ شکلکی پیدا نشد",
"app.emojiPicker.skintext": "رنگ پوست پیش‌فرض خود را انتخاب کنید",
"app.emojiPicker.clear": "پاکسازی",
"app.emojiPicker.clear": "پاکسازی",
"app.emojiPicker.categories.label": "دسته‌بندی شکلک‌ها",
"app.emojiPicker.categories.people": "مردم و بدن",
"app.emojiPicker.categories.nature": "حیوانات و طبیعت",
@ -43,7 +43,7 @@
"app.emojiPicker.categories.objects": "اشیاء",
"app.emojiPicker.categories.symbols": "نمادها",
"app.emojiPicker.categories.flags": "پرچم‌ها",
"app.emojiPicker.categories.recent": "اغلب استفاده می‌شود",
"app.emojiPicker.categories.recent": "پراستفاده",
"app.emojiPicker.categories.search": "نتایج جستجو",
"app.emojiPicker.skintones.1": "رنگ پوست پیش‌فرض",
"app.emojiPicker.skintones.2": "رنگ پوست روشن",
@ -54,7 +54,7 @@
"app.captions.label": "زیرنویس‌ها",
"app.captions.menu.close": "بستن",
"app.captions.menu.start": "شروع",
"app.captions.menu.ariaStart": "آغاز نوشتن زیرنویس‌ها",
"app.captions.menu.ariaStart": "شروع نوشتن زیرنویس‌ها",
"app.captions.menu.ariaStartDesc": "ویرایشگر زیرنویس‌ها را باز کرده و پنجره را می‌بندد",
"app.captions.menu.select": "انتخاب زبان موجود",
"app.captions.menu.ariaSelect": "زبان زیرنویس‌ها",
@ -69,7 +69,7 @@
"app.captions.hide": "پنهان‌سازی زیرنویس‌ها",
"app.captions.ownership": "برعهده گرفتن",
"app.captions.ownershipTooltip": "شما به عنوان صاحب زیرنویس‌های {0} منسوب خواهید شد",
"app.captions.dictationStart": "آغاز نوشتن کلمات",
"app.captions.dictationStart": "شروع نوشتن کلمات",
"app.captions.dictationStop": "توقف نوشتن کلمات",
"app.captions.dictationOnDesc": "تشخیص گفتار را روشن می‌کند",
"app.captions.dictationOffDesc": "تشخیص گفتار را خاموش می‌کند",
@ -78,7 +78,7 @@
"app.captions.speech.error": "به دلیل ناسازگاری مرورگر یا مدتی سکوت، تشخیص گفتار متوقف شد",
"app.confirmation.skipConfirm": "دوباره نپرس",
"app.confirmation.virtualBackground.title": "شروع پس‌زمینه مجازی جدید",
"app.confirmation.virtualBackground.description": "{0} به عنوان پس‌زمینه مجازی اضافه خواهد شد. ادامه؟",
"app.confirmation.virtualBackground.description": "{0} به عنوان پس‌زمینه مجازی اضافه خواهد شد. ادامه می‌دهید؟",
"app.confirmationModal.yesLabel": "بله",
"app.textInput.sendLabel": "ارسال",
"app.title.defaultViewLabel": "نمای پیش‌فرض ارائه",
@ -137,7 +137,7 @@
"app.userList.menu.makePresenter.label": "تغییر نقش به ارائه‌دهنده",
"app.userList.userOptions.manageUsersLabel": "مدیریت کاربران",
"app.userList.userOptions.muteAllLabel": "بستن صدای همه کاربران",
"app.userList.userOptions.muteAllDesc": "بستن صدای همه کاربران حاضر در جلسه",
"app.userList.userOptions.muteAllDesc": "صدای همه کاربران حاضر در جلسه را می‌بندد",
"app.userList.userOptions.clearAllLabel": "پاک‌کردن نماد همه وضعیت‌ها",
"app.userList.userOptions.clearAllDesc": "همه نمادهای وضعیت را از کاربران پاک می‌کند",
"app.userList.userOptions.muteAllExceptPresenterLabel": "بستن صدای همه کاربران به جز ارائه‌دهنده",
@ -194,8 +194,8 @@
"app.meeting.endedMessage": "شما در حال انتقال به صفحه اصلی هستید",
"app.meeting.alertMeetingEndsUnderMinutesSingular": "جلسه تا یک دقیقه دیگر به پایان می‌رسد.",
"app.meeting.alertMeetingEndsUnderMinutesPlural": "جلسه تا {0} دقیقه دیگر به پایان می‌رسد.",
"app.meeting.alertBreakoutEndsUnderMinutesPlural": "جلسه زیرمجموعه تا {0} دقیقه دیگر به پایان می‌رسد.",
"app.meeting.alertBreakoutEndsUnderMinutesSingular": "جلسه زیرمجموعه تا یک دقیقه دیگر به پایان می‌رسد.",
"app.meeting.alertBreakoutEndsUnderMinutesPlural": "اتاق‌های جانبی تا {0} دقیقه دیگر به پایان می‌رسد.",
"app.meeting.alertBreakoutEndsUnderMinutesSingular": "جلسه جانبی تا یک دقیقه دیگر به پایان می‌رسد.",
"app.presentation.hide": "پنهان‌سازی ارائه",
"app.presentation.notificationLabel": "ارائه کنونی",
"app.presentation.downloadLabel": "بارگیری",
@ -299,7 +299,7 @@
"app.presentationUploder.item" : "مورد",
"app.presentationUploder.itemPlural" : "موارد",
"app.presentationUploder.clearErrors": "پاک‌کردن خطاها",
"app.presentationUploder.clearErrorsDesc": "پاک‌کردن بارگذاری‌های ناموفق ارائه",
"app.presentationUploder.clearErrorsDesc": "بارگذاری‌های ناموفق ارائه را پاک می‌کند",
"app.presentationUploder.uploadViewTitle": "بارگذاری ارائه ",
"app.poll.questionAndoptions.label" : "متن سوال نمایش داده شود.\nالف. گزینه نظرسنجی *\nب. گزینه نظرسنجی (اختیاری)\nپ. گزینه نظرسنجی (اختیاری)\nت. گزینه نظرسنجی (اختیاری)\nث. گزینه نظرسنجی (اختیاری)",
"app.poll.customInput.label": "ورودی سفارشی",
@ -315,7 +315,7 @@
"app.poll.customPollTextArea": "پرکردن مقادیر نظرسنجی",
"app.poll.publishLabel": "انتشار نتایج نظرسنجی",
"app.poll.cancelPollLabel": "لغو",
"app.poll.backLabel": "آغاز یک نظرسنجی",
"app.poll.backLabel": "شروع یک نظرسنجی",
"app.poll.closeLabel": "بستن",
"app.poll.waitingLabel": "در انتظار پاسخ‌ها ({0}/{1})",
"app.poll.ariaInputCount": "گزینه نظرسنجی سفارشی {0} از {1}",
@ -330,7 +330,7 @@
"app.poll.responseChoices.label" : "انتخاب‌های پاسخ",
"app.poll.typedResponse.desc" : "یک جعبه متن به کاربران برای پرکردن پاسخشان نمایش داده می شود.",
"app.poll.addItem.label" : "افزودن مورد",
"app.poll.start.label" : "آغاز نظرسنجی",
"app.poll.start.label" : "شروع نظرسنجی",
"app.poll.secretPoll.label" : "نظرسنجی ناشناس",
"app.poll.secretPoll.isSecretLabel": "این نظرسنجی ناشناس است - شما قادر به دیدن پاسخ‌های فردی نخواهید بود.",
"app.poll.questionErr": "ارائه یک سوال الزامی است.",
@ -574,11 +574,11 @@
"app.actionsBar.actionsDropdown.initPollDesc": "آغاز یک نظرسنجی",
"app.actionsBar.actionsDropdown.desktopShareDesc": "صفحه خود را با دیگران به اشتراک بگذارید",
"app.actionsBar.actionsDropdown.stopDesktopShareDesc": "متوقف‌کردن اشتراک‌گذاری صفحه خود با",
"app.actionsBar.actionsDropdown.pollBtnLabel": "آغاز یک نظرسنجی",
"app.actionsBar.actionsDropdown.pollBtnLabel": "شروع یک نظرسنجی",
"app.actionsBar.actionsDropdown.pollBtnDesc": "وضعیت پنجره نظرسنجی را تغییر می‌دهد ",
"app.actionsBar.actionsDropdown.saveUserNames": "ذخیره نام‌های کاربری",
"app.actionsBar.actionsDropdown.createBreakoutRoom": "ایجاد اتاق‌های زیرمجموعه",
"app.actionsBar.actionsDropdown.createBreakoutRoomDesc": "ایجاد اتاق‌های زیرمجموعه برای چندتکه‌کردن جلسه کنونی",
"app.actionsBar.actionsDropdown.createBreakoutRoom": "ایجاد اتاق‌های جانبی",
"app.actionsBar.actionsDropdown.createBreakoutRoomDesc": "ایجاد اتاق‌های جانبی برای چندتکه‌کردن جلسه کنونی",
"app.actionsBar.actionsDropdown.captionsLabel": "نوشتن زیرنویس",
"app.actionsBar.actionsDropdown.captionsDesc": "وضعیت پنجره زیرنویس‌ها را تغییر می‌دهد",
"app.actionsBar.actionsDropdown.takePresenter": "گرفتن نقش ارائه‌دهنده",
@ -609,7 +609,7 @@
"app.actionsBar.emojiMenu.thumbsDownLabel": "نه چندان خوب",
"app.actionsBar.emojiMenu.thumbsDownDesc": "تغییر وضعیت خود به نه چندان خوب",
"app.actionsBar.currentStatusDesc": "وضعیت کنونی {0}",
"app.actionsBar.captions.start": "آغار مشاهده زیرنویس‌ها",
"app.actionsBar.captions.start": "شروع مشاهده زیرنویس‌ها",
"app.actionsBar.captions.stop": "توقف مشاهده زیرنویس‌ها",
"app.audioNotification.audioFailedError1001": "ارتباط وب‌سوکت قطع شد (خطای ۱۰۰۱)",
"app.audioNotification.audioFailedError1002": "امکان برقراری وب‌سوکت وجود ندارد (خطای ۱۰۰۲)",
@ -628,17 +628,17 @@
"app.audioNotification.deviceChangeFailed": "تغییر دستگاه صوتی انجام نشد. بررسی کنید که آیا دستگاه انتخاب‌شده به درستی تنظیم شده و در دسترس باشد",
"app.audioNotification.closeLabel": "بستن",
"app.audioNotificaion.reconnectingAsListenOnly": "میکروفن برای کاربران قفل شده است، شما به عنوان شنونده به جلسه متصل خواهید شد ",
"app.breakoutJoinConfirmation.title": "پیوستن به اتاق زیرمجموعه",
"app.breakoutJoinConfirmation.title": "پیوستن به اتاق جانبی",
"app.breakoutJoinConfirmation.message": "آیا مایل به پیوستن هستید",
"app.breakoutJoinConfirmation.confirmDesc": "شما را به اتاق زیرمجموعه منتقل می‌کند",
"app.breakoutJoinConfirmation.confirmDesc": "شما را به اتاق جانبی منتقل می‌کند",
"app.breakoutJoinConfirmation.dismissLabel": "لغو",
"app.breakoutJoinConfirmation.dismissDesc": "امکان پیوستن به اتاق زیرمجموعه را بسته و رد می‌کند",
"app.breakoutJoinConfirmation.freeJoinMessage": "یک اتاق زیرمجموعه را برای پیوستن انتخاب کنید",
"app.breakoutTimeRemainingMessage": "زمان باقی‌مانده از اتاق زیرمجموعه: {0}",
"app.breakoutWillCloseMessage": "زمان به پایان رسید. اتاق زیرمجموعه به زودی بسته خواهد شد",
"app.breakoutJoinConfirmation.dismissDesc": "امکان پیوستن به اتاق جانبی را بسته و رد می‌کند",
"app.breakoutJoinConfirmation.freeJoinMessage": "یک اتاق جانبی را برای پیوستن انتخاب کنید",
"app.breakoutTimeRemainingMessage": "زمان باقی‌مانده از اتاق جانبی: {0}",
"app.breakoutWillCloseMessage": "زمان به پایان رسید. اتاق جانبی به زودی بسته خواهد شد",
"app.breakout.dropdown.manageDuration": "تغییر مدت زمان",
"app.breakout.dropdown.destroyAll": "اتمام اتاق‌های زیرمجموعه",
"app.breakout.dropdown.options": "گزینه‌های اتاق‌های زیرمجموعه",
"app.breakout.dropdown.destroyAll": "اتمام اتاق‌های جانبی",
"app.breakout.dropdown.options": "گزینه‌های اتاق‌های جانبی",
"app.breakout.dropdown.manageUsers": "مدیریت کاربران",
"app.calculatingBreakoutTimeRemaining": "در حال محاسبه زمان باقی مانده ...",
"app.audioModal.ariaTitle": "پنجره پیوستن به صدا",
@ -804,7 +804,7 @@
"app.userList.guest.denyLabel": "ردکردن",
"app.userList.guest.feedbackMessage": "اقدام اعمال‌شده:",
"app.user-info.title": "جستجوی دایرکتوری",
"app.toast.breakoutRoomEnded": "اتاق زیرمجموعه بسته شد، لطفا دوباره به صدا بپیوندید.",
"app.toast.breakoutRoomEnded": "اتاق جانبی بسته شد، لطفا دوباره به صدا بپیوندید.",
"app.toast.chat.public": "پیام جدید در گفتگوی عمومی",
"app.toast.chat.private": "پیام جدید در گفتگوی خصوصی",
"app.toast.chat.system": "سیستم",
@ -955,7 +955,7 @@
"app.videoPreview.cancelLabel": "لغو",
"app.videoPreview.closeLabel": "بستن",
"app.videoPreview.findingWebcamsLabel": "در جستجوی دوربین‌ها",
"app.videoPreview.startSharingLabel": "آغاز اشتراک‌گذاری",
"app.videoPreview.startSharingLabel": "شروع اشتراک‌گذاری",
"app.videoPreview.stopSharingLabel": "توقف اشتراک‌گذاری",
"app.videoPreview.stopSharingAllLabel": "متوقف‌کردن همه",
"app.videoPreview.sharedCameraLabel": "این دوربین از قبل به اشتراک گذاشته شده است",
@ -1018,13 +1018,13 @@
"app.video.virtualBackground.custom": "از رایانه خود بارگذاری کنید",
"app.video.virtualBackground.remove": "حذف تصویر اضافه شده",
"app.video.virtualBackground.genericError": "جلوه دوربین اعمال نشد. دوباره تلاش کنید.",
"app.video.virtualBackground.camBgAriaDesc": "تنظیم پس‌زمینه مجازی دوربین به {0}",
"app.video.virtualBackground.camBgAriaDesc": "پس‌زمینه مجازی دوربین را روی {0} تنظیم می‌کند",
"app.video.virtualBackground.maximumFileSizeExceeded": "از حداکثر اندازه پرونده بیشتر شده است. ({0} مگابایت)",
"app.video.virtualBackground.typeNotAllowed": "نوع پرونده مجاز نیست.",
"app.video.virtualBackground.errorOnRead": "هنگام خواندن پرونده مشکلی پیش آمد.",
"app.video.virtualBackground.uploaded": "بارگذاری شد",
"app.video.virtualBackground.uploading": "در حال بارگذاری...",
"app.video.virtualBackground.button.customDesc": "افزودن یک تصویر پس‌زمینه مجازی",
"app.video.virtualBackground.button.customDesc": "یک تصویر پس‌زمینه مجازی را اضافه می‌کند",
"app.video.camCapReached": "نمی‌توانید دوربین‌های بیشتری را به اشتراک بگذارید",
"app.video.meetingCamCapReached": "جلسه به حد مجاز دوربین‌های همزمان خود رسیده است",
"app.video.dropZoneLabel": "اینجا رها کنید",
@ -1100,12 +1100,12 @@
"app.videoDock.webcamUnpinDesc": "سنجاق دوربین انتخاب‌شده را بردارید",
"app.videoDock.autoplayBlockedDesc": "ما برای نمایش دوربین کاربران دیگر به شما، به اجازهٔ شما نیازمندیم.",
"app.videoDock.autoplayAllowLabel": "مشاهده دوربین‌ها",
"app.createBreakoutRoom.title": "اتاق‌های زیرمجموعه",
"app.createBreakoutRoom.ariaTitle": "پنهان‌کردن اتاق‌های زیرمجموعه",
"app.createBreakoutRoom.breakoutRoomLabel": "اتاق‌های زیرمجموعه {0}",
"app.createBreakoutRoom.title": "اتاق‌های جانبی",
"app.createBreakoutRoom.ariaTitle": "پنهان‌کردن اتاق‌های جانبی",
"app.createBreakoutRoom.breakoutRoomLabel": "اتاق‌های جانبی {0}",
"app.createBreakoutRoom.askToJoin": "درخواست برای پیوستن",
"app.createBreakoutRoom.generatingURL": "در حال تولید نشانی وب",
"app.createBreakoutRoom.generatingURLMessage": "ما در حال ایجاد یک نشانی وب برای پیوستن به اتاق زیرمجموعه انتخاب‌شده هستیم. شاید چند ثانیه طول بکشد …",
"app.createBreakoutRoom.generatingURLMessage": "ما در حال ایجاد یک نشانی وب برای پیوستن به اتاق جانبی انتخاب‌شده هستیم. شاید چند ثانیه طول بکشد …",
"app.createBreakoutRoom.duration": "مدت زمان {0}",
"app.createBreakoutRoom.room": "اتاق {0}",
"app.createBreakoutRoom.notAssigned": "اختصاص داده نشده ({0})",
@ -1118,23 +1118,23 @@
"app.createBreakoutRoom.numberOfRooms": "تعداد اتاق‌ها",
"app.createBreakoutRoom.durationInMinutes": "مدت زمان (دقیقه)",
"app.createBreakoutRoom.randomlyAssign": "به صورت تصادفی اختصاص بده",
"app.createBreakoutRoom.randomlyAssignDesc": "کاربران را به صورت تصادفی به اتاق‌های زیرمجموعه اختصاص می‌دهد",
"app.createBreakoutRoom.randomlyAssignDesc": "کاربران را به صورت تصادفی به اتاق‌های جانبی اختصاص می‌دهد",
"app.createBreakoutRoom.resetAssignments": "بازنشانی اختصاص‌شده‌ها",
"app.createBreakoutRoom.resetAssignmentsDesc": "همه کاربران اختصاص‌شده به اتاق را بازنشانی کنید",
"app.createBreakoutRoom.endAllBreakouts": "پایان تمام اتاق‌های زیرمجموعه",
"app.createBreakoutRoom.endAllBreakouts": "پایان تمام اتاق‌های جانبی",
"app.createBreakoutRoom.chatTitleMsgAllRooms": "همه اتاق‌ها",
"app.createBreakoutRoom.msgToBreakoutsSent": "پیام به {0} اتاق زیرمجموعه ارسال شد",
"app.createBreakoutRoom.msgToBreakoutsSent": "پیام به {0} اتاق جانبی ارسال شد",
"app.createBreakoutRoom.roomName": "{0} (اتاق - {1})",
"app.createBreakoutRoom.doneLabel": "انجام شد",
"app.createBreakoutRoom.nextLabel": "بعدی",
"app.createBreakoutRoom.minusRoomTime": "کاهش زمان اتاق زیرمجموعه به",
"app.createBreakoutRoom.addRoomTime": "افزایش زمان اتاق زیرمجموعه به ",
"app.createBreakoutRoom.minusRoomTime": "کاهش زمان اتاق جانبی به",
"app.createBreakoutRoom.addRoomTime": "افزایش زمان اتاق جانبی به ",
"app.createBreakoutRoom.addParticipantLabel": "+ افزودن شرکت کننده",
"app.createBreakoutRoom.freeJoin": "اجازه انتخاب اتاق زیرمجموعه به کاربران برای پیوستن",
"app.createBreakoutRoom.captureNotes": "ثبت یادداشت‌های اشتراکی پس از پایان اتاق‌های زیرمجموعه",
"app.createBreakoutRoom.captureSlides": "ثبت تخته‌سفید پس از پایان اتاق‌های زیرمجموعه",
"app.createBreakoutRoom.leastOneWarnBreakout": "شما باید حداقل یک کاربر را در هر اتاق زیرمجموعه قرار دهید.",
"app.createBreakoutRoom.minimumDurationWarnBreakout": "حداقل مدت زمان اتاق زیرمجموعه {0} دقیقه است.",
"app.createBreakoutRoom.freeJoin": "اجازه انتخاب اتاق جانبی به کاربران برای پیوستن",
"app.createBreakoutRoom.captureNotes": "ثبت یادداشت‌های اشتراکی پس از پایان اتاق‌های جانبی",
"app.createBreakoutRoom.captureSlides": "ثبت تخته‌سفید پس از پایان اتاق‌های جانبی",
"app.createBreakoutRoom.leastOneWarnBreakout": "شما باید حداقل یک کاربر را در هر اتاق جانبی قرار دهید.",
"app.createBreakoutRoom.minimumDurationWarnBreakout": "حداقل مدت زمان اتاق جانبی {0} دقیقه است.",
"app.createBreakoutRoom.modalDesc": "نکته: می‌توانید نام یک کاربر را بکشید و رها کنید تا به آن‌ها اتاقی را اختصاص دهید. ",
"app.createBreakoutRoom.roomTime": "{0} دقیقه",
"app.createBreakoutRoom.numberOfRoomsError": "تعداد اتاق‌ها نامعتبر است.",
@ -1143,12 +1143,12 @@
"app.createBreakoutRoom.setTimeInMinutes": "تنظیم مدت زمان به (دقیقه)",
"app.createBreakoutRoom.setTimeLabel": "اعمال",
"app.createBreakoutRoom.setTimeCancel": "لغو",
"app.createBreakoutRoom.setTimeHigherThanMeetingTimeError": "مدت زمان اتاق‌های زیرمجموعه نمی‌تواند از زمان جلسه بیشتر باشد.",
"app.createBreakoutRoom.roomNameInputDesc": "به‌روزرسانی نام اتاق‌های زیر مجموعه",
"app.createBreakoutRoom.setTimeHigherThanMeetingTimeError": "مدت زمان اتاق‌های جانبی نمی‌تواند از زمان جلسه بیشتر باشد.",
"app.createBreakoutRoom.roomNameInputDesc": "نام اتاق‌های جانبی را به‌روزرسانی می‌کند",
"app.createBreakoutRoom.movedUserLabel": "{0} به اتاق {1} منتقل شد",
"app.updateBreakoutRoom.modalDesc": "برای به‌روزرسانی یا دعوت از یک کاربر، به سادگی آنها را به اتاق مورد نظر بکشید.",
"app.updateBreakoutRoom.cancelLabel": "لغو",
"app.updateBreakoutRoom.title": روزرسانی اتاق‌های زیرمجموعه",
"app.updateBreakoutRoom.title": ه‌روزرسانی اتاق‌های جانبی",
"app.updateBreakoutRoom.confirm": "اعمال",
"app.updateBreakoutRoom.userChangeRoomNotification": "شما به اتاق {0} منتقل شدید.",
"app.smartMediaShare.externalVideo": "ویدیو(های) خارجی",

View File

@ -28,8 +28,8 @@
"app.chat.multi.typing": "Plusieurs utilisateurs sont en train d'écrire",
"app.chat.one.typing": "{0} est en train d'écrire",
"app.chat.two.typing": "{0} et {1} sont en train d'écrire",
"app.chat.copySuccess": "Historique de discussion copié",
"app.chat.copyErr": "La copie de l'historique de discussion a échouée",
"app.chat.copySuccess": "La transcription de la discussion est copiée",
"app.chat.copyErr": "La copie de la transcription de la discussion a échouée",
"app.emojiPicker.search": "Rechercher",
"app.emojiPicker.notFound": "Aucun émoticône trouvé",
"app.emojiPicker.skintext": "Choisissez une teinte par défaut",
@ -51,22 +51,22 @@
"app.emojiPicker.skintones.4": "Ton mat",
"app.emojiPicker.skintones.5": "Ton mat-foncé",
"app.emojiPicker.skintones.6": "Ton foncé",
"app.captions.label": "Sous-titre",
"app.captions.label": "Sous-titres",
"app.captions.menu.close": "Fermer",
"app.captions.menu.start": "Démarrer",
"app.captions.menu.ariaStart": "Démarrer l'écriture des sous-titres",
"app.captions.menu.ariaStart": "Démarrer le sous-titrage",
"app.captions.menu.ariaStartDesc": "Ouvre l'éditeur de sous-titres et ferme la fenêtre de dialogue",
"app.captions.menu.select": "Sélectionnez une langue disponible",
"app.captions.menu.ariaSelect": "Langue des sous-titres",
"app.captions.menu.subtitle": "Veuillez sélectionner une langue et les styles pour les sous-titres de votre réunion.",
"app.captions.menu.title": "Sous-titres codés",
"app.captions.menu.title": "Sous-titres SEM",
"app.captions.menu.fontSize": "Taille",
"app.captions.menu.fontColor": "Couleur du texte",
"app.captions.menu.fontFamily": "Police d'écriture",
"app.captions.menu.backgroundColor": "Couleur du fond",
"app.captions.menu.backgroundColor": "Couleur de l'arrière-plan",
"app.captions.menu.previewLabel": "Prévisualiser",
"app.captions.menu.cancelLabel": "Annuler",
"app.captions.hide": "Cacher les sous-titres",
"app.captions.hide": "Occulter les sous-titres",
"app.captions.ownership": "Prendre le contrôle",
"app.captions.ownershipTooltip": "Vous serez désigné comme propriétaire de {0} sous-titres.",
"app.captions.dictationStart": "Démarrer la dictée",
@ -81,7 +81,7 @@
"app.confirmation.virtualBackground.description": "{0} sera ajouté comme arrière-plan virtuel. Poursuivre?",
"app.confirmationModal.yesLabel": "Oui",
"app.textInput.sendLabel": "Envoyer",
"app.title.defaultViewLabel": "Vue par défaut de la présentation",
"app.title.defaultViewLabel": "Visualiser la présentation par défaut ",
"app.notes.title": "Notes partagées",
"app.notes.titlePinned": "Notes partagées (épinglées)",
"app.notes.pinnedNotification": "Les notes partagées sont maintenant épinglées sur le tableau blanc.",
@ -102,7 +102,7 @@
"app.userList.messagesTitle": "Messages",
"app.userList.notesTitle": "Notes",
"app.userList.notesListItem.unreadContent": "Nouveau contenu disponible dans la section des notes partagées",
"app.userList.captionsTitle": "Sous-titre",
"app.userList.captionsTitle": "Sous-titres",
"app.userList.presenter": "Présentateur",
"app.userList.you": "Vous",
"app.userList.locked": "Verrouillé",
@ -120,19 +120,19 @@
"app.userList.menu.clearStatus.label": "Effacer le statut",
"app.userList.menu.removeUser.label": "Retirer l'utilisateur",
"app.userList.menu.removeConfirmation.label": "Retirer l'utilisateur ({0})",
"app.userlist.menu.removeConfirmation.desc": "Empêcher cet utilisateur de rejoindre de nouveau la réunion.",
"app.userlist.menu.removeConfirmation.desc": "Empêcher cet utilisateur de rejoindre la réunion à nouveau.",
"app.userList.menu.muteUserAudio.label": "Rendre l'utilisateur silencieux",
"app.userList.menu.unmuteUserAudio.label": "Autoriser l'utilisateur à parler",
"app.userList.menu.webcamPin.label": "Épingler la caméra de ce participant",
"app.userList.menu.webcamUnpin.label": "Ne plus épingler la caméra de ce participant",
"app.userList.menu.webcamUnpin.label": "Désépingler la caméra de ce participant",
"app.userList.menu.giveWhiteboardAccess.label" : "Donner l'accès au tableau blanc",
"app.userList.menu.removeWhiteboardAccess.label": "Supprimer l'accès au tableau blanc",
"app.userList.menu.ejectUserCameras.label": "Eteindre les caméras",
"app.userList.userAriaLabel": "{0} {1} {2} État {3}",
"app.userList.userAriaLabel": "{0} {1} {2} Statut {3}",
"app.userList.menu.promoteUser.label": "Promouvoir comme modérateur",
"app.userList.menu.demoteUser.label": "Rétrograder en participant normal",
"app.userList.menu.unlockUser.label": "Débloquer {0}",
"app.userList.menu.lockUser.label": "Bloquer {0}",
"app.userList.menu.demoteUser.label": "Rétrograder comme spectateur",
"app.userList.menu.unlockUser.label": "Déverrouiller {0}",
"app.userList.menu.lockUser.label": "Verrouiller {0}",
"app.userList.menu.directoryLookup.label": "Recherche dans l'annuaire",
"app.userList.menu.makePresenter.label": "Définir comme présentateur",
"app.userList.userOptions.manageUsersLabel": "Gérer les utilisateurs",
@ -142,8 +142,8 @@
"app.userList.userOptions.clearAllDesc": "Effacer toutes les icônes de statut des utilisateurs",
"app.userList.userOptions.muteAllExceptPresenterLabel": "Rendre silencieux tous les utilisateurs sauf le présentateur",
"app.userList.userOptions.muteAllExceptPresenterDesc": "Rend silencieux tous les utilisateurs de la réunion sauf le présentateur",
"app.userList.userOptions.unmuteAllLabel": "Désactiver l'interdiction de parler pour la réunion",
"app.userList.userOptions.unmuteAllDesc": "Désactive l'interdiction de parler pour la réunion",
"app.userList.userOptions.unmuteAllLabel": "Désactiver le mode silencieux de la réunion",
"app.userList.userOptions.unmuteAllDesc": "Désactive le mode silencieux de la réunion",
"app.userList.userOptions.lockViewersLabel": "Gestion des interactions",
"app.userList.userOptions.lockViewersDesc": "Verrouiller certaines fonctionnalités pour les participants à la réunion",
"app.userList.userOptions.guestPolicyLabel": "Gestion des accès",
@ -152,7 +152,7 @@
"app.userList.userOptions.disableMic": "Les microphones des participants sont désactivés",
"app.userList.userOptions.disablePrivChat": "La discussion privée est désactivée",
"app.userList.userOptions.disablePubChat": "La discussion publique est désactivée",
"app.userList.userOptions.disableNotes": "Les notes partagées sont maintenant verrouillées",
"app.userList.userOptions.disableNotes": "Les notes partagées sont désormais verrouillées",
"app.userList.userOptions.hideUserList": "La liste des utilisateurs est maintenant masquée pour les participants",
"app.userList.userOptions.webcamsOnlyForModerator": "Seuls les modérateurs peuvent voir les webcams des participants (selon les paramètres de verrouillage)",
"app.userList.content.participants.options.clearedStatus": "Tous les statuts d'utilisateurs sont effacés",
@ -162,34 +162,34 @@
"app.userList.userOptions.enablePubChat": "La discussion publique est activée",
"app.userList.userOptions.enableNotes": "Les notes partagées sont maintenant activées",
"app.userList.userOptions.showUserList": "La liste des utilisateurs est maintenant affichée aux participants",
"app.userList.userOptions.enableOnlyModeratorWebcam": "Vous pouvez activer votre webcam maintenant, tout le monde vous verra",
"app.userList.userOptions.savedNames.title": "Liste des utilisateurs de la conférence {0} à {1}",
"app.userList.userOptions.sortedFirstName.heading": "Trié par prénom :",
"app.userList.userOptions.sortedLastName.heading": "Trié par nom :",
"app.userList.userOptions.enableOnlyModeratorWebcam": "Vous pouvez désormais activer votre webcam, tout le monde vous verra",
"app.userList.userOptions.savedNames.title": "Liste des utilisateurs en réunion {0} à {1}",
"app.userList.userOptions.sortedFirstName.heading": "Tri par prénom :",
"app.userList.userOptions.sortedLastName.heading": "Tri par nom :",
"app.userList.userOptions.hideViewersCursor": "Les pointeurs des participants ont été verrouillés ",
"app.userList.userOptions.showViewersCursor": "Les pointeurs des participants sont déverrouillés",
"app.media.label": "Média",
"app.media.autoplayAlertDesc": "Autoriser l'accès",
"app.media.screenshare.start": "Partage d'écran commencé",
"app.media.screenshare.start": "Partage d'écran démarré",
"app.media.screenshare.end": "Partage d'écran terminé",
"app.media.screenshare.endDueToDataSaving": "Le partage d'écran est arrêté pour favoriser l'économie de données.",
"app.media.screenshare.endDueToDataSaving": "Le partage d'écran est arrêté pour économiser le flux de données.",
"app.media.screenshare.unavailable": "Partage d'écran indisponible",
"app.media.screenshare.notSupported": "Le partage d'écran n'est pas pris en charge par ce navigateur.",
"app.media.screenshare.notSupported": "Le partage d'écran n'est pas disponible pour ce navigateur.",
"app.media.screenshare.autoplayBlockedDesc": "Nous avons besoin de votre permission pour vous montrer l'écran du présentateur.",
"app.media.screenshare.autoplayAllowLabel": "Voir l'écran partagé",
"app.screenshare.presenterLoadingLabel": " Votre partage d'écran est en cours de chargement",
"app.screenshare.presenterLoadingLabel": "Votre partage d'écran est en cours de chargement",
"app.screenshare.viewerLoadingLabel": "L'écran du présentateur est en cours de chargement",
"app.screenshare.presenterSharingLabel": "Vous partagez maintenant votre écran",
"app.screenshare.screenshareFinalError": "Code {0}. Ne peut pas partager l'écran.",
"app.screenshare.presenterSharingLabel": "Vous partagez désormais votre écran",
"app.screenshare.screenshareFinalError": "Code {0}. Impossible de partager l'écran.",
"app.screenshare.screenshareRetryError": "Code {0}. Essayez à nouveau de partager l'écran.",
"app.screenshare.screenshareRetryOtherEnvError": "Code {0}. Ne peut pas partager l'écran. Essayez à nouveau en utilisant un navigateur ou appareil différent.",
"app.screenshare.screenshareUnsupportedEnv": "Code {0}. Le navigateur n'est pas pris en charge. Essayez à nouveau en utilisant un navigateur ou appareil différent.",
"app.screenshare.screenshareRetryOtherEnvError": "Code {0}. Impossible de partager l'écran. Essayez à nouveau en utilisant un autre navigateur ou un appareil différent.",
"app.screenshare.screenshareUnsupportedEnv": "Code {0}. Le navigateur n'est pas pris en charge. Essayez à nouveau en utilisant un autre navigateur ou un appareil différent.",
"app.screenshare.screensharePermissionError": "Code {0}. L'autorisation de capturer d'écran doit être accordée.",
"app.meeting.ended": "Cette réunion est terminée",
"app.meeting.meetingTimeRemaining": "Temps de réunion restant : {0}",
"app.meeting.meetingTimeHasEnded": "Le temps s'est écoulé. La réunion sera bientôt close",
"app.meeting.endedByUserMessage": "Cette réunion a été close par {0}",
"app.meeting.endedByNoModeratorMessageSingular": "La réunion est terminée car aucun modérateur n'est présent depuis plus d'une minute.",
"app.meeting.endedByNoModeratorMessageSingular": "La réunion est terminée car aucun modérateur n'est présent depuis plus d'une minute.",
"app.meeting.endedByNoModeratorMessagePlural": "La réunion est terminée car aucun modérateur n'est présent depuis plus de {0} minutes.",
"app.meeting.endedMessage": "Vous serez redirigé vers l'écran d'accueil",
"app.meeting.alertMeetingEndsUnderMinutesSingular": "La réunion se terminera dans une minute.",
@ -199,15 +199,15 @@
"app.presentation.hide": "Masquer la présentation",
"app.presentation.notificationLabel": "Présentation courante",
"app.presentation.downloadLabel": "Télécharger",
"app.presentation.slideContent": "Contenu de la diapositive",
"app.presentation.startSlideContent": "Début du contenu de la diapositive",
"app.presentation.endSlideContent": "Fin du contenu de la diapositive",
"app.presentation.changedSlideContent": "La présentation est désormais à la diapositive : {0}",
"app.presentation.emptySlideContent": "Pas de contenu pour la diapositive actuelle",
"app.presentation.slideContent": "Diaporama",
"app.presentation.startSlideContent": "Le diaporama démarre",
"app.presentation.endSlideContent": "Fin du diaporama",
"app.presentation.changedSlideContent": "La présentation est désormais un diaporama : {0}",
"app.presentation.emptySlideContent": "La diapositive courante n'a pas de contenu",
"app.presentation.options.fullscreen": "Présentation en plein écran",
"app.presentation.options.exitFullscreen": "Sortir du plein écran",
"app.presentation.options.minimize": "Réduire",
"app.presentation.options.snapshot": "Capture de la présentation courante",
"app.presentation.options.snapshot": "Capture de la diapositive courante",
"app.presentation.options.downloading": "Téléchargement en cours...",
"app.presentation.options.downloaded": "La présentation courante a été téléchargée",
"app.presentation.options.downloadFailed": "Impossible de télécharger la présentation courante",
@ -215,24 +215,24 @@
"app.presentation.presentationToolbar.noPrevSlideDesc": "Début de présentation",
"app.presentation.presentationToolbar.selectLabel": "Sélectionnez la diapositive",
"app.presentation.presentationToolbar.prevSlideLabel": "Diapositive précédente",
"app.presentation.presentationToolbar.prevSlideDesc": "Changer la présentation à la diapositive précédente",
"app.presentation.presentationToolbar.prevSlideDesc": "Revenir à la diapositive précédente",
"app.presentation.presentationToolbar.nextSlideLabel": "Diapositive suivante",
"app.presentation.presentationToolbar.nextSlideDesc": "Changer la présentation à la diapositive suivante",
"app.presentation.presentationToolbar.skipSlideLabel": "Passer la diapositive",
"app.presentation.presentationToolbar.skipSlideDesc": "Changer la présentation à une diapositive spécifique",
"app.presentation.presentationToolbar.fitWidthLabel": "Adapté à la largeur",
"app.presentation.presentationToolbar.fitWidthDesc": "Afficher toute la largeur de la diapositive",
"app.presentation.presentationToolbar.nextSlideDesc": "Passer à la diapositive suivante",
"app.presentation.presentationToolbar.skipSlideLabel": "Sauter la diapositive",
"app.presentation.presentationToolbar.skipSlideDesc": "Passer à une diapositive spécifique",
"app.presentation.presentationToolbar.fitWidthLabel": "Ajuster à la largeur",
"app.presentation.presentationToolbar.fitWidthDesc": "Afficher la diapositive sur toute sa largeur",
"app.presentation.presentationToolbar.fitScreenLabel": "Ajuster à l'écran",
"app.presentation.presentationToolbar.fitScreenDesc": "Afficher toute la diapositive",
"app.presentation.presentationToolbar.zoomLabel": "Zoom",
"app.presentation.presentationToolbar.zoomDesc": "Changer le niveau de zoom de la présentation",
"app.presentation.presentationToolbar.zoomDesc": "Changer le niveau de zoom dans la présentation",
"app.presentation.presentationToolbar.zoomInLabel": "Zoomer",
"app.presentation.presentationToolbar.zoomInDesc": "Zoomer dans la présentation",
"app.presentation.presentationToolbar.zoomOutLabel": "Dézoomer",
"app.presentation.presentationToolbar.zoomOutDesc": "Dézoomer de la présentation",
"app.presentation.presentationToolbar.zoomReset": "Réinitialiser le zoom",
"app.presentation.presentationToolbar.zoomIndicator": "Pourcentage de zoom actuel",
"app.presentation.presentationToolbar.fitToWidth": "Adapter à la largeur",
"app.presentation.presentationToolbar.fitToWidth": "Ajuster à la largeur",
"app.presentation.presentationToolbar.fitToPage": "Ajuster à la page",
"app.presentation.presentationToolbar.goToSlide": "Diapositive {0}",
"app.presentation.placeholder": "Il n'y a pas de présentation active actuellement",
@ -252,7 +252,7 @@
"app.presentationUploader.export.notAccessibleWarning": "Probablement non conforme aux règles d'accessibilité",
"app.presentationUploader.currentPresentationLabel": "Présentation courante",
"app.presentationUploder.extraHint": "IMPORTANT: Le volume d'un fichier ne doit pas excéder {0} MB et {1} pages.",
"app.presentationUploder.uploadLabel": "Télécharger",
"app.presentationUploder.uploadLabel": "Téléverser",
"app.presentationUploder.confirmLabel": "Confirmer",
"app.presentationUploder.confirmDesc": "Enregistrez vos modifications et lancez la présentation",
"app.presentationUploder.dismissLabel": "Annuler",
@ -272,19 +272,19 @@
"app.presentationUploder.upload.413": "Le fichier est trop volumineux, il dépasse le maximum de {0} Mo",
"app.presentationUploder.genericError": "Oups, quelque chose s'est mal passé...",
"app.presentationUploder.upload.408": "Le jeton de demande de téléversement a expiré.",
"app.presentationUploder.upload.404": "404 : jeton de téléversement invalide",
"app.presentationUploder.upload.404": "404 : jeton de téléversement non valide",
"app.presentationUploder.upload.401": "La demande d'un jeton de téléversement de présentation a échoué.",
"app.presentationUploder.conversion.conversionProcessingSlides": "Traitement de la page {0} sur {1}",
"app.presentationUploder.conversion.genericConversionStatus": "Conversion du fichier en cours...",
"app.presentationUploder.conversion.generatingThumbnail": "Création des vignettes en cours...",
"app.presentationUploder.conversion.generatedSlides": "Diapositives générées...",
"app.presentationUploder.conversion.generatingSvg": "Création des images SVG en cours...",
"app.presentationUploder.conversion.generatingSvg": "Création d'images SVG en cours...",
"app.presentationUploder.conversion.pageCountExceeded": "Le nombre de pages dépasse le maximum de {0}",
"app.presentationUploder.conversion.invalidMimeType": "Mauvais format du fichier détecté (extension={0}, type de contenu={1})",
"app.presentationUploder.conversion.conversionTimeout": "La diapositive {0} n'a pas pu être traitée au cours des {1} essais",
"app.presentationUploder.conversion.officeDocConversionInvalid": "Échec du traitement du document Office. Veuillez télécharger un PDF à la place.",
"app.presentationUploder.conversion.officeDocConversionInvalid": "Échec du traitement du document Office. Veuillez téléverser un PDF à la place.",
"app.presentationUploder.conversion.officeDocConversionFailed": "Échec du traitement du document office. Veuillez télécharger un PDF à la place.",
"app.presentationUploder.conversion.pdfHasBigPage": "Nous n'avons pas pu convertir le fichier PDF, veuillez essayer de l'optimiser. Taille de page maximum {0}",
"app.presentationUploder.conversion.pdfHasBigPage": "Nous n'avons pas pu convertir le fichier PDF, veuillez essayer de l'optimiser. Taille maximum {0}",
"app.presentationUploder.conversion.timeout": "Oups, la conversion a pris trop de temps",
"app.presentationUploder.conversion.pageCountFailed": "Impossible de déterminer le nombre de pages.",
"app.presentationUploder.conversion.unsupportedDocument": "Extension de fichier non prise en charge",
@ -293,9 +293,9 @@
"app.presentationUploder.tableHeading.filename": "Nom de fichier",
"app.presentationUploder.tableHeading.options": "Options",
"app.presentationUploder.tableHeading.status": "Statut",
"app.presentationUploder.uploading": "Charger {0} {1}",
"app.presentationUploder.uploadStatus": "{0} sur {1} chargements terminés",
"app.presentationUploder.completed": "{0} chargements terminés",
"app.presentationUploder.uploading": "Téléversement en cours {0} {1}",
"app.presentationUploder.uploadStatus": "{0} sur {1} téléversements terminés",
"app.presentationUploder.completed": "{0} téléversements terminés",
"app.presentationUploder.item" : "élément",
"app.presentationUploder.itemPlural" : "éléments",
"app.presentationUploder.clearErrors": "Effacer les erreurs",
@ -306,7 +306,7 @@
"app.poll.customInputInstructions.label": "Saisie personnalisée est activée - Ecrivez la question et la (les) réponse(s) dans le format proposé ou glissez-déposez un fichier texte dans le même format. ",
"app.poll.maxOptionsWarning.label": "Seules le 5 premières réponses peuvent être utilisées!",
"app.poll.pollPaneTitle": "Sondage",
"app.poll.enableMultipleResponseLabel": "Permettre aux participants de répondre plusieurs fois ?",
"app.poll.enableMultipleResponseLabel": "Question à choix multiple ?",
"app.poll.quickPollTitle": "Sondage rapide",
"app.poll.hidePollDesc": "Masque le volet du menu du sondage",
"app.poll.quickPollInstruction": "Sélectionnez une option ci-dessous pour démarrer votre sondage.",
@ -318,7 +318,7 @@
"app.poll.backLabel": "Débuter un sondage",
"app.poll.closeLabel": "Fermer",
"app.poll.waitingLabel": "En attente des réponses ({0}/{1})",
"app.poll.ariaInputCount": "Option {0} sur {1} du sondage personnalisé",
"app.poll.ariaInputCount": "Option de sondage personnalisée {0} sur {1} ",
"app.poll.customPlaceholder": "Ajouter une option de sondage",
"app.poll.noPresentationSelected": "Aucune présentation sélectionnée ! Veuillez en sélectionner une.",
"app.poll.clickHereToSelect": "Cliquez ici pour sélectionner",
@ -370,7 +370,7 @@
"app.polling.submitLabel": "Soumettre",
"app.polling.submitAriaLabel": "Soumettre votre réponse au sondage",
"app.polling.responsePlaceholder": "Entrez votre réponse",
"app.polling.responseSecret": "Sondage anonyme - le présentateur ne peut pas voir votre réponse.",
"app.polling.responseSecret": "Sondage anonyme - le présentateur ne vois pas qui répond.",
"app.polling.responseNotSecret": "Sondage normal - le présentateur peut voir votre réponse.",
"app.polling.pollAnswerLabel": "Réponse au sondage {0}",
"app.polling.pollAnswerDesc": "Choisir cette option pour voter {0}",

View File

@ -189,8 +189,8 @@
"app.meeting.meetingTimeRemaining": "Հանդիպման ավարտին մնացել է {0}",
"app.meeting.meetingTimeHasEnded": "Ժամանակը սպառվեց և հանդիպումը շուտով կավարտվի",
"app.meeting.endedByUserMessage": "Այս հանդիպումն ավարտվել է {0}",
"app.meeting.endedByNoModeratorMessageSingular": "Հանդիպումն ավարտվել է մեկ րոպեից ավելի մոդերատորի բացակայության պատճառով",
"app.meeting.endedByNoModeratorMessagePlural": "Հանդիպումն ավարտվել է {0} րոպեից ավելի մոդերատորի բացակայության պատճառով",
"app.meeting.endedByNoModeratorMessageSingular": "Հանդիպումն ավարտվեց, քանի որ մոդերատորը բացակայում է",
"app.meeting.endedByNoModeratorMessagePlural": "Հանդիպումն ավարտվեց, քանի որ մոդերատորը բացակայում է {0}  րոպե",
"app.meeting.endedMessage": "Դուք կուղղորդվեք դեպի գլխավոր էջ",
"app.meeting.alertMeetingEndsUnderMinutesSingular": "Հանդիպումը կավարտվի մեկ րոպեից",
"app.meeting.alertMeetingEndsUnderMinutesPlural": "Հանդիպումը կավարտվի {0} րոպեից",
@ -566,7 +566,7 @@
"app.talkingIndicator.moreThanMaxIndicatorsWereTalking" : "{0}+ խոսում էին",
"app.talkingIndicator.wasTalking" : "{0}ը դադարեց խոսել",
"app.actionsBar.actionsDropdown.actionsLabel": "Գործողություններ",
"app.actionsBar.actionsDropdown.presentationLabel": "Ներկայացումների կառավարում",
"app.actionsBar.actionsDropdown.presentationLabel": "Բեռնել/Կառավարել ներկայացումները",
"app.actionsBar.actionsDropdown.initPollLabel": "Սկսել հարցումը",
"app.actionsBar.actionsDropdown.desktopShareLabel": "Ցուցադրել Ձեր էկրանը",
"app.actionsBar.actionsDropdown.stopDesktopShareLabel": "Ընդհատել էկրանի ցուցադրումը",

View File

@ -0,0 +1,122 @@
# GNS3-BBB
Scripts to build a virtual BigBlueButton network in a gns3 project (for testing purposes).
**Prerequisites:** a Ubuntu server with KVM (Kernel Virtual Machine) support and enough CPU and RAM to support the virtual machines in the virtual network
**Note**: gns3 is very picky about matching GUI client and server versions. I typically put dpkg holds on the gns3 packages, since otherwise an apt upgrade on my laptop requires both an apt upgrade on my gns3 server *and* restarting the gns3 server, which implies stopping and restarting all of the running VMs.
**Note**: gns3 uses qemu, which can not run concurrently with VirtualBox. If VirtualBox virtual machines are running. gns3 virtual machines will not start, and vice versa.
**Note:** Once the script has been used to build the virtual network (takes about an hour), the virtual network can be stopped and restarted without having to re-run the script.
## Design
The script will build a gns3 project that looks like this:
![network diagram](README.png)
The network "highjacks" the 128.8.8.0/24 subnet, so it simulates public IP address space. You can set a different public subnet using the `--public-subnet` option to the script.
The DNS domain name is configured to match the bare metal hostname. If the bare metal machine is called `osito`, for example, the virtual machines will be given names like `BigBlueButton.osito` and `focal-260.osito`.
The `BigBlueButton` virtual machine (called `master_gateway` in the script) is named to match the gns3 project name, which is `BigBlueButton` by default. The project name (and the name of the master gateway) can be changed using the `--project` option.
The master gateway, in addition to providing DNS and DHCP service for the 128.8.8.0/24 subnet, also operates a STUN server that presents itself in DNS as `stun.l.google.com`, so that STUN operations, on both the BigBlueButton clients and servers, yield the 128.8.8.0/24 addresses as public addresses. `BigBlueButton` also operates an ACME CA signing service (so that `certbot` works), and mimics `resolver1.opendns.com` (used by `bbb-install` to check that the server can reach itself).
The master gateway also announces the 128.8.8.0/24 subnet to the bare metal machine using OSPF, and implements NAT, so that the bare metal machine can connect to the virtual servers.
The `focal-260-NAT` device announces itself into DHCP/DNS as `focal-260.DOMAIN` and forwards ports 80 and 443 (along with UDP ports) through to `focal-260` itself. Clients can therefore connect to `focal-260.DOMAIN`, just as they would to a typical BBB server. The NAT device itself listens for ssh on port 2222. The `--no-nat` option can be specified to create a server without an associated NAT gateway.
Default operation of the script is to install a server whose name is passed into the script and is used both as the hostname of the server and as the release name to install. Obvious server names include `focal-250`, `focal-25-dev`, and `focal-260`. You can specify the `-r`/`--repository` option to use a repository other than `ubuntu.bigbluebutton.org` (just like the install script). The `--install-script` option allows an alternate install script to be used.
Some special names are defined. Requesting a device name starting with `testclient` creates a test client that connects to NAT4 (overlapping server address space), NAT5 (private address not overlapping server address space), and NAT6 (carrier grade NAT). Likewise, `turn` and `natturn` devices can also be created, just by requesting them by name.
## Usage
1. You'll need several tools from Brent Baccala's NPDC repository on github, which is a submodule in the NPDC directory, so run this command to fetch it:
```
git submodule init
git submodule update
```
1. Read, understand, and run the `install-gns3.sh` script in `NPDC/GNS3`
1. Upload a current Ubuntu 20 cloud image to the gns3 server using NPDC's `GNS3/upload-image.py`:
`./upload-image.py https://cloud-images.ubuntu.com/releases/focal/release/ubuntu-20.04-server-cloudimg-amd64.img`
The most uncommon Python3 package that this script uses is `python3-requests-toolbelt`. `python3-clint` is also recommended, to get a progress bar.
If this step works, then you have REST API access to the GNS3 server.
1. You should now be able to boot an Ubuntu instance with this `NPDC/GNS3` script:
`./ubuntu.py -r 20 -m 1024 --debug`
Double-click on the icon that appears in the GUI to access the instance's console. You should also be able to login using `ssh ubuntu`.
The `--debug` option adds a login with username `ubuntu` and password `ubuntu`.
Login and verify, in particular, that networking is working properly. You should have Internet access.
1. Finally, build the BigBlueButton project in gns3 with `./gns3-bbb.py`
1. Install a server with `./gns3-bbb.py --wait-all focal-260`
The `--wait-all` option will cause the script to wait for BigBlueButton to install while you watch. Without this option, the script will pause to wait for the NAT device to boot before starting the BigBlueButton server, then terminate once the BigBlueButton server has begun its install sequence.
1. You can run tests directly from the bare metal machine. The script created an SSL certificate in its own directory called `bbb-dev-ca.crt` which can be installed and trusted on your web browser.
1. Add another server with `./gns3-bbb.py focal-250`
1. Remove a server and its associated NAT gateway and switch with `./gns3-bbb.py --delete focal-250`
1. `ssh` into the server devices directly.
1. You can `ssh` into a server's NAT gateway with `ssh -p 2222 focal-260`.
1. Since test servers come and go fairly frequently, I find the following stanza useful in my `.ssh/config`:
```
Host BigBlueButton NAT? testclient* focal-*
User ubuntu
UserKnownHostsFile /dev/null
StrictHostKeyChecking no
```
This stops `ssh` from complaining about server host keys changing, which happens every time you delete and rebuild a server.
### Installing test clients
1. Build a GUI image using NPDC's `GNS3/ubuntu.py`:
`./ubuntu.py -r 20 -s $((1024*1024)) -m 1024 --boot-script opendesktop.sh --gns3-appliance`
This step adds the GUI packages to the Ubuntu 20 cloud image and creates a new cloud image used for the test clients. It takes about half an hour.
1. Upload the resulting GUI image to the gns3 server using NPDC's `GNS3/upload-image.py`
1. Add a test client with `./gns3-bbb.py testclient`
1. You can access a testclient's GUI by double-clicking on its icon in the GNS3 GUI.
1. You can `ssh` into a testclient by specifying its NAT gateway as a jump host (`-J`) option to ssh: `ssh -J NAT4 testclient`
### Possible Test Environments
1. UDP ports can be blocked, forcing use of TURN, like this (blocks all servers from bare metal clients):
```
$ ssh BigBlueButton
ubuntu@BigBlueButton:~$ sudo iptables -A FORWARD -p udp -j REJECT
```
or like this (blocks one server from all clients):
```
$ ssh -p 2222 focal-260
ubuntu@focal-260-NAT:~$ sudo iptables -A FORWARD -p udp -j REJECT
```
2. Install a proxy on BigBlueButton and force its use by blocking TCP traffic? (bbb-install issue #583)

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@ -0,0 +1,25 @@
# This is a minimalist bird configuration that causes the psuedo-Internet
# subnet on ens5 to be announced via OSPF on ens4. This allows the bare
# metal system to pick up a route to the psuedo-Internet subnet. In
# particular, RTP audio and video to proxied servers won't work without
# this, because the server and client are far enough removed (two NAT
# gateways between them) that they don't have any shared address space.
# By advertising the psuedo-Internet 128.8.8.0/24 to the client, that
# ensures that the client can reach one of the addresses advertised
# by the server.
# The Device protocol is not a real routing protocol. It doesn't generate any
# routes and it only serves as a module for getting information about network
# interfaces from the kernel.
protocol device { }
protocol ospf OSPF {
area 0 {
interface "ens*" {
cost 10;
};
};
import none;
export all;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,10 @@
[Unit]
Description=step-ca
After=syslog.target network.target
[Service]
ExecStart=/usr/bin/step-ca /opt/ca/ca.json
Type=simple
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,95 @@
#!/bin/bash
#
# Install Big Blue Button testing client
#
# This script runs once as 'ubuntu'
# Make some changes to .bashrc, but they won't affect the shell that is already
# running in the GUI, so the user will need to '. ~/.bashrc' there.
#
# We need NODE_EXTRA_CA_CERTS so that the playwright tests can make API calls
# without getting certificate errors.
cat >> ~/.bashrc <<EOF
export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/bbb-dev/bbb-dev-ca.crt
export ACTIONS_RUNNER_DEBUG=true
export BBB_URL=https://bbb-ci.test/bigbluebutton/api
export BBB_SECRET=bbbci
EOF
# Which version of the repository should we use for the client test cases
BRANCH=v2.5.x-release
# if these are running, our apt operations may error out unable to get a lock
sudo systemctl stop unattended-upgrades.service
echo Waiting for apt-daily.service and apt-daily-upgrade.service
sudo systemd-run --property="After=apt-daily.service apt-daily-upgrade.service" --wait /bin/true
sudo apt update
sudo DEBIAN_FRONTEND=noninteractive apt -y upgrade
# git, since we're about to use it
# linux-image-extra-virtual to get snd-aloop module for dummy audio
# v4l2loopback-dkms to get video loopback for dummy webcam
# sudo apt -y install git-core ant ant-contrib openjdk-8-jdk-headless zip unzip linux-image-extra-virtual
sudo apt -y install git-core linux-image-extra-virtual v4l2loopback-dkms
# We don't need the whole git history, like this command would do:
# git clone https://github.com/bigbluebutton/bigbluebutton.git
# so instead we do this to pick up a single revision:
cd
mkdir bigbluebutton-$BRANCH
cd bigbluebutton-$BRANCH
git init
git remote add origin https://github.com/bigbluebutton/bigbluebutton.git
git fetch --depth 1 origin $BRANCH
git checkout FETCH_HEAD
# Focal distributes nodejs 10, which is too old for our playwright test suite. Use nodejs 16.
curl -sL https://deb.nodesource.com/setup_16.x | sudo -E bash -
sudo apt install -y nodejs
cd ~/bigbluebutton-$BRANCH/bigbluebutton-tests/playwright
npm install
npx --yes playwright install
# patch playwright's firefox so that it uses the system's root certificate authority
find /home/ubuntu/.cache/ms-playwright -name libnssckbi.so -exec mv {} {}.distrib \; -exec ln -s /usr/lib/x86_64-linux-gnu/pkcs11/p11-kit-trust.so {} \;
# playwright webkit doesn't have a fake audio device, but Linux does
# no point in enabling this since playwright can't grant permissions to use microphone on webkit (playwright issue #2973)
# sudo modprobe snd-aloop
# echo snd-aloop | sudo tee -a /etc/modules
# this is required to run webkit tests, but conflicts with BBB server dependencies,
# so can't be installed on the same machine as a BBB server
sudo npx playwright install-deps
# still need to either install an .env file or set environment variables in ~/.bashrc
# In addition to the system root CA store in /usr/local/share/ca-certificates (used by curl and others),
# we need to deal with two common browsers that don't use the system store.
# Get Firefox (already installed) to use system's root certificate authority
# Method suggested by https://askubuntu.com/a/1036637/71866
# Earlier this this script, we did something similar to modify playwright's version of firefox.
# This handles the standard system firefox.
sudo mv /usr/lib/firefox/libnssckbi.so /usr/lib/firefox/libnssckbi.so.distrib
sudo dpkg-divert --no-rename --add /usr/lib/firefox/libnssckbi.so
sudo ln -s /usr/lib/x86_64-linux-gnu/pkcs11/p11-kit-trust.so /usr/lib/firefox/libnssckbi.so
# Install chromium and the tools we need to install our certificate into Chromium's private store
sudo DEBIAN_FRONTEND=noninteractive apt -y install chromium-browser libnss3-tools jq
# chromium snap - we now need to install nssdb in ~/snap/chromium/2051/.pki instead of ~/.pki
# NSSDB=/home/ubuntu/.pki/nssdb
for CHROMIUM_SNAP in $(find /home/ubuntu/snap/chromium/ -mindepth 1 -maxdepth 1 -type d); do
NSSDB=$CHROMIUM_SNAP/.pki/nssdb
if [ ! -r $NSSDB ]; then
mkdir --parents $NSSDB
certutil -d sql:$NSSDB -N --empty-password
fi
certutil -d sql:$NSSDB -A -t 'C,,' -n bbb-dev-ca -i /usr/local/share/ca-certificates/bbb-dev/bbb-dev-ca.crt
done

View File

@ -0,0 +1,7 @@
#!/bin/bash
SECRET=$(grep sharedSecret /etc/bigbluebutton/bbb-apps-akka.conf | sed 's/^.*=//')
URL=$(grep bigbluebutton.web.serverURL= /etc/bigbluebutton/bbb-web.properties | sed 's/^.*=//')
echo BBB_URL="$URL/bigbluebutton/api"
echo BBB_SECRET=$SECRET

View File

@ -0,0 +1,43 @@
#!/bin/bash
#
# Install a Big Blue Button testing server on a VM
# if these are running, our apt operations may error out unable to get a lock
sudo systemctl stop unattended-upgrades.service
echo Waiting for apt-daily.service and apt-daily-upgrade.service
sudo systemd-run --property="After=apt-daily.service apt-daily-upgrade.service" --wait /bin/true
sudo apt update
sudo DEBIAN_FRONTEND=noninteractive apt -y upgrade
DOMAIN=$(hostname --domain)
FQDN=$(hostname --fqdn)
EMAIL="root@$FQDN"
# /bbb-install.sh (the proper version; either 2.4, 2.5 or 2.6) is created by gns3-bbb.py
# INSTALL_OPTIONS and RELEASE get passed in the environment from gns3-bbb.py
#
# INSTALL_OPTIONS can include -w (firewall) -a (api demos; deprecated in 2.6) -r (repository)
sudo /bbb-install.sh -v $RELEASE -s $FQDN -e $EMAIL $INSTALL_OPTIONS
sudo bbb-conf --salt bbbci
echo "NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/bbb-dev/bbb-dev-ca.crt" | sudo tee -a /usr/share/meteor/bundle/bbb-html5-with-roles.conf
# bbb-conf --salt doesn't set the shared secret on the web demo
if [ -r /var/lib/tomcat9/webapps/demo/bbb_api_conf.jsp ]; then
sudo sed -i '/salt/s/"[^"]*"/"bbbci"/' /var/lib/tomcat9/webapps/demo/bbb_api_conf.jsp
fi
# if nginx didn't start because of a hash bucket size issue,
# certbot didn't work properly and we need to re-run the entire install script
if systemctl -q is-failed nginx; then
sudo sed -i '/server_names_hash_bucket_size/s/^\(\s*\)# /\1/' /etc/nginx/nginx.conf
sudo /bbb-install.sh -v $RELEASE -s $FQDN -e $EMAIL $INSTALL_OPTIONS
fi
# We can't restart if nginx isn't running. It'll just complain "nginx.service is not active, cannot reload"
# sudo bbb-conf --restart
sudo bbb-conf --stop
sudo bbb-conf --start

View File

@ -21,3 +21,4 @@ exports.VIDEO_LOADING_WAIT_TIME = 15000;
exports.UPLOAD_PDF_WAIT_TIME = CI ? 40000 : 20000;
exports.CUSTOM_MEETING_ID = 'custom-meeting';
exports.PARAMETER_HIDE_PRESENTATION_TOAST = 'userdata-bbb_custom_style=.presentationUploaderToast{display: none;}.currentPresentationToast{display:none;}';

View File

@ -425,6 +425,12 @@ exports.multiUsersWhiteboardOff = 'button[data-test="turnMultiUsersWhiteboardOff
exports.whiteboardViewBox = 'svg g[clip-path="url(#viewBox)"]';
exports.changeWhiteboardAccess = 'li[data-test="changeWhiteboardAccess"]';
exports.pencil = 'button[data-test="pencilTool"]';
exports.resetZoomButton = 'button[data-test="resetZoomButton"]';
exports.zoomInButton = 'button[data-test="zoomInBtn"]';
exports.zoomOutButton = 'button[data-test="zoomOutBtn"]';
exports.wbPan = 'button[data-test="panButton"]';
exports.wbEraser = 'button[id="TD-PrimaryTools-Eraser"]';
exports.wbArrowShape = 'button[id="TD-PrimaryTools-ArrowTopRight"]';
// About modal
exports.showAboutModalButton = 'li[data-test="aboutModal"]';

View File

@ -27,6 +27,16 @@ async function checkTextContent(baseContent, checkData) {
await expect(check).toBeTruthy();
}
function constructClipObj(wbBox) {
return {
x: wbBox.x,
y: wbBox.y,
width: wbBox.width,
height: wbBox.height,
};
}
exports.checkElement = checkElement;
exports.checkElementLengthEqualTo = checkElementLengthEqualTo;
exports.checkTextContent = checkTextContent;
exports.constructClipObj = constructClipObj;

View File

@ -3,21 +3,23 @@ const { FocusOnPresentation } = require('./focusOnPresentation');
const { FocusOnVideo } = require('./focusOnVideo');
const { MultiUsers } = require('../user/multiusers');
const { encodeCustomParams } = require('../customparameters/util');
const { PARAMETER_HIDE_PRESENTATION_TOAST } = require('../core/constants');
const hidePresentationToast = encodeCustomParams(PARAMETER_HIDE_PRESENTATION_TOAST);
const CUSTOM_MEETING_ID = 'layout_management_meeting';
const CUSTOM_STYLE = `userdata-bbb_custom_style=.presentationUploaderToast{display: none;}.currentPresentationToast{display:none;}`;
test.describe.parallel('Layout management', () => {
test('Focus on presentation', async ({ browser, context, page }) => {
const focusOnPresentation = new FocusOnPresentation(browser, context);
await focusOnPresentation.initModPage(page, true, { customMeetingId: CUSTOM_MEETING_ID, customParameter: encodeCustomParams(CUSTOM_STYLE) });
await focusOnPresentation.initModPage2(true, context, { customParameter: encodeCustomParams(CUSTOM_STYLE) });
await focusOnPresentation.initModPage(page, true, { customMeetingId: CUSTOM_MEETING_ID, customParameter: hidePresentationToast });
await focusOnPresentation.initModPage2(true, context, { customParameter: hidePresentationToast });
await focusOnPresentation.test();
});
test('Focus on video', async ({ browser, context, page }) => {
const focusOnVideo = new FocusOnVideo(browser, context);
await focusOnVideo.initModPage(page, true, { customMeetingId: CUSTOM_MEETING_ID, customParameter: encodeCustomParams(CUSTOM_STYLE) });
await focusOnVideo.initModPage2(true, context, { customParameter: encodeCustomParams(CUSTOM_STYLE) });
await focusOnVideo.initModPage(page, true, { customMeetingId: CUSTOM_MEETING_ID, customParameter: hidePresentationToast });
await focusOnVideo.initModPage2(true, context, { customParameter: hidePresentationToast });
await focusOnVideo.test();
});
});

View File

@ -0,0 +1,35 @@
const { expect } = require('@playwright/test');
const e = require('../core/elements');
const { ELEMENT_WAIT_LONGER_TIME } = require('../core/constants');
const { MultiUsers } = require('../user/multiusers');
const { constructClipObj } = require('../core/util');
class DrawArrow extends MultiUsers {
constructor(browser, context) {
super(browser, context);
}
async test() {
await this.modPage.waitForSelector(e.whiteboard, ELEMENT_WAIT_LONGER_TIME);
const wbBox = await this.modPage.getElementBoundingBox(e.whiteboard);
const clipObj = constructClipObj(wbBox);
const screenshotOptions = {
maxDiffPixels: 1000,
clip: clipObj,
};
await this.modPage.waitAndClick(e.wbArrowShape);
await this.modPage.page.mouse.move(wbBox.x + 0.3 * wbBox.width, wbBox.y + 0.3 * wbBox.height);
await this.modPage.page.mouse.down();
await this.modPage.page.mouse.move(wbBox.x + 0.7 * wbBox.width, wbBox.y + 0.7 * wbBox.height);
await this.modPage.page.mouse.up();
await expect(this.modPage.page).toHaveScreenshot('moderator1-arrow.png', screenshotOptions);
await expect(this.modPage2.page).toHaveScreenshot('moderator2-arrow.png', screenshotOptions);
}
}
exports.DrawArrow = DrawArrow;

View File

@ -2,6 +2,7 @@ const { expect } = require('@playwright/test');
const e = require('../core/elements');
const { ELEMENT_WAIT_LONGER_TIME } = require('../core/constants');
const { MultiUsers } = require('../user/multiusers');
const { constructClipObj } = require('../core/util');
class DrawEllipse extends MultiUsers {
constructor(browser, context) {
@ -10,31 +11,25 @@ class DrawEllipse extends MultiUsers {
async test() {
await this.modPage.waitForSelector(e.whiteboard, ELEMENT_WAIT_LONGER_TIME);
const wbBox = await this.modPage.getElementBoundingBox(e.whiteboard);
const clipObj = constructClipObj(wbBox);
const screenshotOptions = {
maxDiffPixels: 1000,
clip: clipObj,
};
await this.modPage.waitAndClick(e.wbShapesButton);
await this.modPage.waitAndClick(e.wbEllipseShape);
const wbBox = await this.modPage.getElementBoundingBox(e.whiteboard);
await this.modPage.page.mouse.move(wbBox.x + 0.3 * wbBox.width, wbBox.y + 0.3 * wbBox.height);
await this.modPage.page.mouse.down();
await this.modPage.page.mouse.move(wbBox.x + 0.7 * wbBox.width, wbBox.y + 0.7 * wbBox.height);
await this.modPage.page.mouse.up();
const clipObj = {
x: wbBox.x,
y: wbBox.y,
width: wbBox.width,
height: wbBox.height,
};
await expect(this.modPage.page).toHaveScreenshot('moderator1-ellipse.png', screenshotOptions);
await expect(this.modPage.page).toHaveScreenshot('moderator1-ellipse.png', {
maxDiffPixels: 1000,
clip: clipObj,
});
await expect(this.modPage2.page).toHaveScreenshot('moderator2-ellipse.png', {
maxDiffPixels: 1000,
clip: clipObj,
});
await expect(this.modPage2.page).toHaveScreenshot('moderator2-ellipse.png', screenshotOptions);
}
}

View File

@ -2,6 +2,7 @@ const { expect } = require('@playwright/test');
const e = require('../core/elements');
const { ELEMENT_WAIT_LONGER_TIME } = require('../core/constants');
const { MultiUsers } = require('../user/multiusers');
const { constructClipObj } = require('../core/util');
class DrawLine extends MultiUsers {
constructor(browser, context) {
@ -10,31 +11,25 @@ class DrawLine extends MultiUsers {
async test() {
await this.modPage.waitForSelector(e.whiteboard, ELEMENT_WAIT_LONGER_TIME);
const wbBox = await this.modPage.getElementBoundingBox(e.whiteboard);
const clipObj = constructClipObj(wbBox);
const screenshotOptions = {
maxDiffPixels: 1000,
clip: clipObj,
};
await this.modPage.waitAndClick(e.wbShapesButton);
await this.modPage.waitAndClick(e.wbLineShape);
const wbBox = await this.modPage.getElementBoundingBox(e.whiteboard);
await this.modPage.page.mouse.move(wbBox.x + 0.3 * wbBox.width, wbBox.y + 0.3 * wbBox.height);
await this.modPage.page.mouse.down();
await this.modPage.page.mouse.move(wbBox.x + 0.7 * wbBox.width, wbBox.y + 0.7 * wbBox.height);
await this.modPage.page.mouse.up();
const clipObj = {
x: wbBox.x,
y: wbBox.y,
width: wbBox.width,
height: wbBox.height,
};
await expect(this.modPage.page).toHaveScreenshot('moderator1-line.png', screenshotOptions);
await expect(this.modPage.page).toHaveScreenshot('moderator1-line.png', {
maxDiffPixels: 1000,
clip: clipObj,
});
await expect(this.modPage2.page).toHaveScreenshot('moderator2-line.png', {
maxDiffPixels: 1000,
clip: clipObj,
});
await expect(this.modPage2.page).toHaveScreenshot('moderator2-line.png', screenshotOptions);
}
}

View File

@ -2,6 +2,7 @@ const { expect } = require('@playwright/test');
const e = require('../core/elements');
const { ELEMENT_WAIT_LONGER_TIME } = require('../core/constants');
const { MultiUsers } = require('../user/multiusers');
const { constructClipObj } = require('../core/util');
class DrawPencil extends MultiUsers {
constructor(browser, context) {
@ -10,9 +11,16 @@ class DrawPencil extends MultiUsers {
async test() {
await this.modPage.waitForSelector(e.whiteboard, ELEMENT_WAIT_LONGER_TIME);
await this.modPage.waitAndClick(e.wbPencilShape);
const wbBox = await this.modPage.getElementBoundingBox(e.whiteboard);
const clipObj = constructClipObj(wbBox);
const screenshotOptions = {
maxDiffPixels: 1000,
clip: clipObj,
};
await this.modPage.waitAndClick(e.wbPencilShape);
const moveOptions = { steps: 50 }; // to slow down
await this.modPage.page.mouse.move(wbBox.x + 0.2 * wbBox.width, wbBox.y + 0.2 * wbBox.height);
await this.modPage.page.mouse.down();
@ -21,22 +29,9 @@ class DrawPencil extends MultiUsers {
await this.modPage.page.mouse.move(wbBox.x + 0.8 * wbBox.width, wbBox.y + 0.4 * wbBox.height, moveOptions);
await this.modPage.page.mouse.up();
const clipObj = {
x: wbBox.x,
y: wbBox.y,
width: wbBox.width,
height: wbBox.height,
};
await expect(this.modPage.page).toHaveScreenshot('moderator1-pencil.png', screenshotOptions);
await expect(this.modPage.page).toHaveScreenshot('moderator1-pencil.png', {
maxDiffPixels: 1000,
clip: clipObj,
});
await expect(this.modPage2.page).toHaveScreenshot('moderator2-pencil.png', {
maxDiffPixels: 1000,
clip: clipObj,
});
await expect(this.modPage2.page).toHaveScreenshot('moderator2-pencil.png', screenshotOptions);
}
}

View File

@ -2,6 +2,7 @@ const { expect } = require('@playwright/test');
const e = require('../core/elements');
const { ELEMENT_WAIT_LONGER_TIME } = require('../core/constants');
const { MultiUsers } = require('../user/multiusers');
const { constructClipObj } = require('../core/util');
class DrawRectangle extends MultiUsers {
constructor(browser, context) {
@ -10,31 +11,25 @@ class DrawRectangle extends MultiUsers {
async test() {
await this.modPage.waitForSelector(e.whiteboard, ELEMENT_WAIT_LONGER_TIME);
const wbBox = await this.modPage.getElementBoundingBox(e.whiteboard);
const clipObj = constructClipObj(wbBox);
const screenshotOptions = {
maxDiffPixels: 1000,
clip: clipObj,
};
await this.modPage.waitAndClick(e.wbShapesButton);
await this.modPage.waitAndClick(e.wbRectangleShape);
const wbBox = await this.modPage.getElementBoundingBox(e.whiteboard);
await this.modPage.page.mouse.move(wbBox.x + 0.3 * wbBox.width, wbBox.y + 0.3 * wbBox.height);
await this.modPage.page.mouse.down();
await this.modPage.page.mouse.move(wbBox.x + 0.7 * wbBox.width, wbBox.y + 0.7 * wbBox.height);
await this.modPage.page.mouse.up();
const clipObj = {
x: wbBox.x,
y: wbBox.y,
width: wbBox.width,
height: wbBox.height,
};
await expect(this.modPage.page).toHaveScreenshot('moderator1-rectangle.png', screenshotOptions);
await expect(this.modPage.page).toHaveScreenshot('moderator1-rectangle.png', {
maxDiffPixels: 1000,
clip: clipObj,
});
await expect(this.modPage2.page).toHaveScreenshot('moderator2-rectangle.png', {
maxDiffPixels: 1000,
clip: clipObj,
});
await expect(this.modPage2.page).toHaveScreenshot('moderator2-rectangle.png', screenshotOptions);
}
}

View File

@ -2,6 +2,7 @@ const { expect } = require('@playwright/test');
const e = require('../core/elements');
const { ELEMENT_WAIT_LONGER_TIME } = require('../core/constants');
const { MultiUsers } = require('../user/multiusers');
const { constructClipObj } = require('../core/util');
class DrawStickyNote extends MultiUsers {
constructor(browser, context) {
@ -10,9 +11,16 @@ class DrawStickyNote extends MultiUsers {
async test() {
await this.modPage.waitForSelector(e.whiteboard, ELEMENT_WAIT_LONGER_TIME);
await this.modPage.waitAndClick(e.wbStickyNoteShape);
const wbBox = await this.modPage.getElementBoundingBox(e.whiteboard);
const clipObj = constructClipObj(wbBox);
const screenshotOptions = {
maxDiffPixels: 1000,
clip: clipObj,
};
await this.modPage.waitAndClick(e.wbStickyNoteShape);
await this.modPage.page.mouse.click(wbBox.x + 0.3 * wbBox.width, wbBox.y + 0.3 * wbBox.height);
await this.modPage.press('A');
@ -24,21 +32,8 @@ class DrawStickyNote extends MultiUsers {
await this.modPage.hasText(e.wbTypedStickyNote, 'AB');
await this.modPage2.hasText(e.wbTypedStickyNote, 'AB');
const clipObj = {
x: wbBox.x,
y: wbBox.y,
width: wbBox.width,
height: wbBox.height,
};
await expect(this.modPage.page).toHaveScreenshot('moderator1-sticky.png', {
maxDiffPixels: 1000,
clip: clipObj,
});
await expect(this.modPage2.page).toHaveScreenshot('moderator2-sticky.png', {
maxDiffPixels: 1000,
clip: clipObj,
});
await expect(this.modPage.page).toHaveScreenshot('moderator1-sticky.png', screenshotOptions);
await expect(this.modPage2.page).toHaveScreenshot('moderator2-sticky.png', screenshotOptions);
}
}

View File

@ -2,6 +2,7 @@ const { expect } = require('@playwright/test');
const e = require('../core/elements');
const { ELEMENT_WAIT_LONGER_TIME } = require('../core/constants');
const { MultiUsers } = require('../user/multiusers');
const { constructClipObj } = require('../core/util');
class DrawText extends MultiUsers {
constructor(browser, context) {
@ -10,9 +11,16 @@ class DrawText extends MultiUsers {
async test() {
await this.modPage.waitForSelector(e.whiteboard, ELEMENT_WAIT_LONGER_TIME);
await this.modPage.waitAndClick(e.wbTextShape);
const wbBox = await this.modPage.getElementBoundingBox(e.whiteboard);
const clipObj = constructClipObj(wbBox);
const screenshotOptions = {
maxDiffPixels: 1000,
clip: clipObj,
};
await this.modPage.waitAndClick(e.wbTextShape);
await this.modPage.page.mouse.click(wbBox.x + 0.3 * wbBox.width, wbBox.y + 0.3 * wbBox.height);
await this.modPage.press('A');
@ -21,21 +29,8 @@ class DrawText extends MultiUsers {
await this.modPage.press('B');
await this.modPage.page.mouse.click(wbBox.x + 0.6 * wbBox.width, wbBox.y + 0.6 * wbBox.height);
const clipObj = {
x: wbBox.x,
y: wbBox.y,
width: wbBox.width,
height: wbBox.height,
};
await expect(this.modPage.page).toHaveScreenshot('moderator1-text.png', {
maxDiffPixels: 1000,
clip: clipObj,
});
await expect(this.modPage2.page).toHaveScreenshot('moderator2-text.png', {
maxDiffPixels: 1000,
clip: clipObj,
});
await expect(this.modPage.page).toHaveScreenshot('moderator1-text.png', screenshotOptions);
await expect(this.modPage2.page).toHaveScreenshot('moderator2-text.png', screenshotOptions);
}
}

View File

@ -2,6 +2,7 @@ const { expect } = require('@playwright/test');
const e = require('../core/elements');
const { ELEMENT_WAIT_LONGER_TIME } = require('../core/constants');
const { MultiUsers } = require('../user/multiusers');
const { constructClipObj } = require('../core/util');
class DrawTriangle extends MultiUsers {
constructor(browser, context) {
@ -10,31 +11,25 @@ class DrawTriangle extends MultiUsers {
async test() {
await this.modPage.waitForSelector(e.whiteboard, ELEMENT_WAIT_LONGER_TIME);
const wbBox = await this.modPage.getElementBoundingBox(e.whiteboard);
const clipObj = constructClipObj(wbBox);
const screenshotOptions = {
maxDiffPixels: 1000,
clip: clipObj,
};
await this.modPage.waitAndClick(e.wbShapesButton);
await this.modPage.waitAndClick(e.wbTriangleShape);
const wbBox = await this.modPage.getElementBoundingBox(e.whiteboard);
await this.modPage.page.mouse.move(wbBox.x + 0.3 * wbBox.width, wbBox.y + 0.3 * wbBox.height);
await this.modPage.page.mouse.down();
await this.modPage.page.mouse.move(wbBox.x + 0.7 * wbBox.width, wbBox.y + 0.7 * wbBox.height);
await this.modPage.page.mouse.up();
const clipObj = {
x: wbBox.x,
y: wbBox.y,
width: wbBox.width,
height: wbBox.height,
};
await expect(this.modPage.page).toHaveScreenshot('moderator1-triangle.png', screenshotOptions);
await expect(this.modPage.page).toHaveScreenshot('moderator1-triangle.png', {
maxDiffPixels: 1000,
clip: clipObj,
});
await expect(this.modPage2.page).toHaveScreenshot('moderator2-triangle.png', {
maxDiffPixels: 1000,
clip: clipObj,
});
await expect(this.modPage2.page).toHaveScreenshot('moderator2-triangle.png', screenshotOptions);
}
}

View File

@ -0,0 +1,48 @@
const { expect } = require('@playwright/test');
const e = require('../core/elements');
const { ELEMENT_WAIT_LONGER_TIME } = require('../core/constants');
const { MultiUsers } = require('../user/multiusers');
const { constructClipObj } = require('../core/util');
const defaultZoomLevel = '100%';
const zoomedInZoomLevel = '125%';
const maxZoomLevel = '400%';
class Eraser extends MultiUsers {
constructor(browser, context) {
super(browser, context);
}
async test() {
await this.modPage.waitForSelector(e.whiteboard, ELEMENT_WAIT_LONGER_TIME);
const wbBox = await this.modPage.getElementBoundingBox(e.whiteboard);
const clipObj = constructClipObj(wbBox);
const screenshotOptions = {
maxDiffPixels: 1000,
clip: clipObj,
};
await this.modPage.waitAndClick(e.wbShapesButton);
await this.modPage.waitAndClick(e.wbLineShape);
await this.modPage.page.mouse.move(wbBox.x + 0.3 * wbBox.width, wbBox.y + 0.3 * wbBox.height);
await this.modPage.page.mouse.down();
await this.modPage.page.mouse.move(wbBox.x + 0.7 * wbBox.width, wbBox.y + 0.7 * wbBox.height);
await this.modPage.page.mouse.up();
await this.modPage.waitAndClick(e.wbEraser);
await this.modPage.page.mouse.move(wbBox.x + 0.3 * wbBox.width, wbBox.y + 0.7 * wbBox.height);
await this.modPage.page.mouse.down();
await this.modPage.page.mouse.move(wbBox.x + 0.7 * wbBox.width, wbBox.y + 0.3 * wbBox.height);
await expect(this.modPage.page).toHaveScreenshot('moderator1-eraser1.png', screenshotOptions);
await expect(this.modPage.page).toHaveScreenshot('moderator2-eraser1.png', screenshotOptions);
await this.modPage.page.mouse.up();
await expect(this.modPage.page).toHaveScreenshot('moderator1-eraser2.png', screenshotOptions);
await expect(this.modPage2.page).toHaveScreenshot('moderator2-eraser2.png', screenshotOptions);
}
}
exports.Eraser = Eraser;

View File

@ -0,0 +1,40 @@
const { expect } = require('@playwright/test');
const e = require('../core/elements');
const { ELEMENT_WAIT_LONGER_TIME } = require('../core/constants');
const { MultiUsers } = require('../user/multiusers');
const { constructClipObj } = require('../core/util');
const defaultZoomLevel = '100%';
const zoomedInZoomLevel = '125%';
const maxZoomLevel = '400%';
class Pan extends MultiUsers {
constructor(browser, context) {
super(browser, context);
}
async test() {
await this.modPage.waitForSelector(e.resetZoomButton, ELEMENT_WAIT_LONGER_TIME);
const wbBox = await this.modPage.getElementBoundingBox(e.whiteboard);
const clipObj = constructClipObj(wbBox);
const screenshotOptions = {
maxDiffPixels: 1000,
clip: clipObj,
};
for(let i = 100; i < 200; i += 25) {
await this.modPage.waitAndClick(e.zoomInButton);
}
await this.modPage.page.mouse.move(wbBox.x + 0.3 * wbBox.width, wbBox.y + 0.3 * wbBox.height);
await this.modPage.page.mouse.down();
await this.modPage.page.mouse.move(wbBox.x + 0.7 * wbBox.width, wbBox.y + 0.7 * wbBox.height);
await this.modPage.page.mouse.up();
await expect(this.modPage.page).toHaveScreenshot('moderator1-pan.png', screenshotOptions);
await expect(this.modPage2.page).toHaveScreenshot('moderator2-pan.png', screenshotOptions);
}
}
exports.Pan = Pan;

View File

@ -7,8 +7,15 @@ const { DrawLine } = require('./drawLine');
const { DrawPencil } = require('./drawPencil');
const { DrawText } = require('./drawText');
const { DrawStickyNote } = require('./drawStickyNote');
const { Zoom } = require('./zoom');
const { Pan } = require('./pan');
const { Eraser } = require('./eraser');
const { DrawArrow } = require('./drawArrow');
const { MultiUsers } = require('../user/multiusers');
const { encodeCustomParams } = require('../customparameters/util');
const { PARAMETER_HIDE_PRESENTATION_TOAST } = require('../core/constants');
const hidePresentationToast = encodeCustomParams(PARAMETER_HIDE_PRESENTATION_TOAST);
test.describe.parallel('Whiteboard @ci', () => {
test('Draw rectangle', async ({ browser, page }) => {
@ -24,7 +31,7 @@ test.describe.parallel('Whiteboard @ci', () => {
});
});
test.describe.parallel('Drawing - visual regression', () => {
test.describe.parallel('Whiteboard tools - visual regression', () => {
test.beforeEach(({ browserName }) => {
test.skip(browserName !== 'chromium',
'Drawing visual regression tests are enabled for Chromium only');
@ -32,50 +39,78 @@ test.describe.parallel('Drawing - visual regression', () => {
test('Draw rectangle', async ({ browser, context, page }) => {
const drawRectangle = new DrawRectangle(browser, context);
await drawRectangle.initModPage(page, true, { customMeetingId: 'draw_rectangle_meeting', customParameter: encodeCustomParams(`userdata-bbb_custom_style=.presentationUploaderToast{display: none;}.currentPresentationToast{display:none;}`) });
await drawRectangle.initModPage2(true, context, { customParameter: encodeCustomParams(`userdata-bbb_custom_style=.presentationUploaderToast{display: none;}.currentPresentationToast{display:none;}`) });
await drawRectangle.initModPage(page, true, { customMeetingId: 'draw_rectangle_meeting', customParameter: hidePresentationToast });
await drawRectangle.initModPage2(true, context, { customParameter: hidePresentationToast });
await drawRectangle.test();
});
test('Draw ellipse', async ({ browser, context, page }) => {
const drawEllipse = new DrawEllipse(browser, context);
await drawEllipse.initModPage(page, true, { customMeetingId: 'draw_ellipse_meeting', customParameter: encodeCustomParams(`userdata-bbb_custom_style=.presentationUploaderToast{display: none;}.currentPresentationToast{display:none;}`) });
await drawEllipse.initModPage2(true, context, { customParameter: encodeCustomParams(`userdata-bbb_custom_style=.presentationUploaderToast{display: none;}.currentPresentationToast{display:none;}`) });
await drawEllipse.initModPage(page, true, { customMeetingId: 'draw_ellipse_meeting', customParameter: hidePresentationToast });
await drawEllipse.initModPage2(true, context, { customParameter: hidePresentationToast });
await drawEllipse.test();
});
test('Draw triangle', async ({ browser, context, page }) => {
const drawTriangle = new DrawTriangle(browser, context);
await drawTriangle.initModPage(page, true, { customMeetingId: 'draw_triangle_meeting', customParameter: encodeCustomParams(`userdata-bbb_custom_style=.presentationUploaderToast{display: none;}.currentPresentationToast{display:none;}`) });
await drawTriangle.initModPage2(true, context, { customParameter: encodeCustomParams(`userdata-bbb_custom_style=.presentationUploaderToast{display: none;}.currentPresentationToast{display:none;}`) });
await drawTriangle.initModPage(page, true, { customMeetingId: 'draw_triangle_meeting', customParameter: hidePresentationToast });
await drawTriangle.initModPage2(true, context, { customParameter: hidePresentationToast });
await drawTriangle.test();
});
test('Draw line', async ({ browser, context, page }) => {
const drawLine = new DrawLine(browser, context);
await drawLine.initModPage(page, true, { customMeetingId: 'draw_line_meeting', customParameter: encodeCustomParams(`userdata-bbb_custom_style=.presentationUploaderToast{display: none;}.currentPresentationToast{display:none;}`) });
await drawLine.initModPage2(true, context, { customParameter: encodeCustomParams(`userdata-bbb_custom_style=.presentationUploaderToast{display: none;}.currentPresentationToast{display:none;}`) });
await drawLine.initModPage(page, true, { customMeetingId: 'draw_line_meeting', customParameter: hidePresentationToast });
await drawLine.initModPage2(true, context, { customParameter: hidePresentationToast });
await drawLine.test();
});
test('Draw with pencil', async ({ browser, context, page }) => {
const drawPencil = new DrawPencil(browser, context);
await drawPencil.initModPage(page, true, { customMeetingId: 'draw_pencil_meeting', customParameter: encodeCustomParams(`userdata-bbb_custom_style=.presentationUploaderToast{display: none;}.currentPresentationToast{display:none;}`) });
await drawPencil.initModPage2(true, context, { customParameter: encodeCustomParams(`userdata-bbb_custom_style=.presentationUploaderToast{display: none;}.currentPresentationToast{display:none;}`) });
await drawPencil.initModPage(page, true, { customMeetingId: 'draw_pencil_meeting', customParameter: hidePresentationToast });
await drawPencil.initModPage2(true, context, { customParameter: hidePresentationToast });
await drawPencil.test();
});
test('Type text', async ({ browser, context, page }) => {
const drawText = new DrawText(browser, context);
await drawText.initModPage(page, true, { customMeetingId: 'draw_text_meeting', customParameter: encodeCustomParams(`userdata-bbb_custom_style=.presentationUploaderToast{display: none;}.currentPresentationToast{display:none;}`) });
await drawText.initModPage2(true, context, { customParameter: encodeCustomParams(`userdata-bbb_custom_style=.presentationUploaderToast{display: none;}.currentPresentationToast{display:none;}`) });
await drawText.initModPage(page, true, { customMeetingId: 'draw_text_meeting', customParameter: hidePresentationToast });
await drawText.initModPage2(true, context, { customParameter: hidePresentationToast });
await drawText.test();
});
test('Create sticky note', async ({ browser, context, page }) => {
const drawStickyNote = new DrawStickyNote(browser, context);
await drawStickyNote.initModPage(page, true, { customMeetingId: 'draw_sticky_meeting', customParameter: encodeCustomParams(`userdata-bbb_custom_style=.presentationUploaderToast{display: none;}.currentPresentationToast{display:none;}`) });
await drawStickyNote.initModPage2(true, context, { customParameter: encodeCustomParams(`userdata-bbb_custom_style=.presentationUploaderToast{display: none;}.currentPresentationToast{display:none;}`) });
await drawStickyNote.initModPage(page, true, { customMeetingId: 'draw_sticky_meeting', customParameter: hidePresentationToast });
await drawStickyNote.initModPage2(true, context, { customParameter: hidePresentationToast });
await drawStickyNote.test();
});
test('Zoom', async ({ browser, context, page }) => {
const zoom = new Zoom(browser, context);
await zoom.initModPage(page, true, { customMeetingId: 'draw_line_meeting', customParameter: hidePresentationToast });
await zoom.initModPage2(true, context, { customParameter: hidePresentationToast });
await zoom.test();
});
test('Pan', async ({ browser, context, page }) => {
const pan = new Pan(browser, context);
await pan.initModPage(page, true, { customMeetingId: 'draw_line_meeting', customParameter: hidePresentationToast });
await pan.initModPage2(true, context, { customParameter: hidePresentationToast });
await pan.test();
});
test('Eraser', async ({ browser, context, page }) => {
const eraser = new Eraser(browser, context);
await eraser.initModPage(page, true, { customMeetingId: 'draw_line_meeting', customParameter: hidePresentationToast });
await eraser.initModPage2(true, context, { customParameter: hidePresentationToast });
await eraser.test();
});
test('Draw arrow', async ({ browser, context, page }) => {
const drawArrow = new DrawArrow(browser, context);
await drawArrow.initModPage(page, true, { customMeetingId: 'draw_line_meeting', customParameter: hidePresentationToast });
await drawArrow.initModPage2(true, context, { customParameter: hidePresentationToast });
await drawArrow.test();
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,49 @@
const { expect } = require('@playwright/test');
const e = require('../core/elements');
const { ELEMENT_WAIT_LONGER_TIME } = require('../core/constants');
const { MultiUsers } = require('../user/multiusers');
const { constructClipObj } = require('../core/util');
const defaultZoomLevel = '100%';
const zoomedInZoomLevel = '125%';
const maxZoomLevel = '400%';
class Zoom extends MultiUsers {
constructor(browser, context) {
super(browser, context);
}
async test() {
await this.modPage.waitForSelector(e.resetZoomButton, ELEMENT_WAIT_LONGER_TIME);
const wbBox = await this.modPage.getElementBoundingBox(e.whiteboard);
const clipObj = constructClipObj(wbBox);
const screenshotOptions = {
maxDiffPixels: 1000,
clip: clipObj,
};
// 100%
const zoomOutButtonLocator = await this.modPage.getLocator(e.zoomOutButton);
await expect(zoomOutButtonLocator).toBeDisabled();
const resetZoomButtonLocator = await this.modPage.getLocator(e.resetZoomButton);
await expect(resetZoomButtonLocator).toContainText(defaultZoomLevel);
// 125%
await this.modPage.waitAndClick(e.zoomInButton);
await expect(zoomOutButtonLocator).toBeEnabled();
await expect(resetZoomButtonLocator).toContainText(zoomedInZoomLevel);
await expect(this.modPage.page).toHaveScreenshot('moderator1-zoom125.png', screenshotOptions);
await expect(this.modPage2.page).toHaveScreenshot('moderator2-zoom125.png', screenshotOptions);
// max zoom
for(let i = 125; i < 400; i += 25) {
await this.modPage.waitAndClick(e.zoomInButton);
}
await expect(resetZoomButtonLocator).toContainText(maxZoomLevel);
await expect(this.modPage.page).toHaveScreenshot('moderator1-zoom400.png', screenshotOptions);
await expect(this.modPage2.page).toHaveScreenshot('moderator2-zoom400.png', screenshotOptions);
}
}
exports.Zoom = Zoom;

View File

@ -62,7 +62,10 @@ dependencies {
implementation "org.grails:grails-core:5.2.4"
implementation "org.springframework.boot:spring-boot-starter-actuator:${springVersion}"
implementation "org.springframework.boot:spring-boot-starter-tomcat:${springVersion}"
implementation "org.grails:grails-web-boot:5.2.5"
implementation "org.springframework:spring-webmvc:5.3.26"
implementation "org.grails:grails-logging"
implementation "org.grails:grails-plugin-rest:5.2.5"
implementation "org.grails:grails-plugin-databinding:5.2.4"
@ -123,6 +126,8 @@ tasks.named('bootWarMainClassName') {
configurations.implementation {
exclude group: 'io.micronaut', module: 'micronaut-aop'
exclude group: 'com.h2database', module: 'h2'
exclude group: 'org.graalvm.sdk', module: 'graal-sdk'
}
configurations {

View File

@ -1,4 +1,4 @@
grailsVersion=5.2.4
grailsVersion=5.3.2
gormVersion=7.1.0
gradleWrapperVersion=7.3.1
grailsGradlePluginVersion=5.0.0

View File

@ -282,16 +282,16 @@ to
so that FreeSWITCH announces the external IP address when a connection is established.
Check `/etc/bigbluebutton/nginx/sip.nginx` to ensure its binding to the external IP address of the firewall [Configure FreeSWITCH for using SSL](/administration/install#configure-freeswitch-for-using-ssl).
Check `/etc/bigbluebutton/nginx/sip.nginx` to ensure its binding to the external IP address of the firewall.
Check that `enableListenOnly` is set to true in `/usr/share/meteor/bundle/programs/server/assets/app/config/settings.yml`, as in
Check that `enableListenOnly` is set to true in `/etc/bigbluebutton/bbb-html5.yml`, as in
```bash
$ grep enableListenOnly /usr/share/meteor/bundle/programs/server/assets/app/config/settings.yml
$ grep enableListenOnly /etc/bigbluebutton/bbb-html5.yml
enableListenOnly: true
```
Next, edit `/usr/local/bigbluebutton/bbb-webrtc-sfu/config/default.yml` change the value to `ip` to match the external IP address of the server.
Next, edit `/etc/bigbluebutton/bbb-webrtc-sfu/production.yml` change the value to `ip` to match the external IP address of the server.
```yaml
freeswitch:
@ -300,7 +300,8 @@ freeswitch:
port: 5066
```
If your runnig 2.2.29 or later, the value of `sip_ip` depends on whether you have `sipjsHackViaWs` set to true or false in `/usr/share/meteor/bundle/programs/server/assets/app/config/settings.yml` (see [Configure FreeSWITCH for using SSL](/administration/install#configure-freeswitch-for-using-ssl)).
If your runnig 2.2.29 or later, the value of `sip_ip` depends on whether you have `sipjsHackViaWs`
set to true or false in `/etc/bigbluebutton/bbb-html5.yml`.
You also need to [setup Kurento to use a STUN server](#extra-steps-when-server-is-behind-nat).
@ -310,7 +311,8 @@ After making the above changes, restart BigBlueButton.
$ bbb-conf --restart
```
To test, launch FireFox and try connecting to your BigBlueButton server and join the audio. If you see the words '[ WebRTC Audio ]' in the lower right-hand corner, it worked.
To test, launch FireFox and try connecting to your BigBlueButton server and join the audio.
If you see the words '[ WebRTC Audio ]' in the lower right-hand corner, it worked.
If it didn't work, there are two likely error messages when you try to connect with audio.

View File

@ -43,10 +43,11 @@ chown meteor:meteor $HTML5_CONFIG
then when called by `bbb-conf`, the above `apply-conf.sh` script will
- use the helper function `enableUFWRules` to [restrict access to specific ports](#restrict-access-to-specific-ports), and
- use the helper function `enableUFWRules` to restrict access to specific ports, and
- set `enableScreensharing` to `false` in the HTML5 configuration file at `/usr/share/meteor/bundle/programs/server/assets/app/config/settings.yml`.
Notice that `apply-conf.sh` includes a helper script [apply-lib.sh](https://github.com/bigbluebutton/bigbluebutton/blob/master/bigbluebutton-config/bin/apply-lib.sh). This helper script contains some functions to make it easy to apply common configuration changes, along with some helper variables, such as `HTML5_CONFIG`.
Notice that `apply-conf.sh` includes a helper script [apply-lib.sh](https://github.com/bigbluebutton/bigbluebutton/blob/v2.6.x-release/bigbluebutton-config/bin/apply-lib.sh).
This helper script contains some functions to make it easy to apply common configuration changes, along with some helper variables, such as `HTML5_CONFIG`.
The contents of `apply-config.sh` are not owned by any package, so it will never be overwritten.
@ -1013,7 +1014,7 @@ Configuring IP firewalling is _essential for securing your installation_. By def
If your server is behind a firewall already -- such as running within your company or on an EC2 instance behind a Amazon Security Group -- and the firewall is enforcing the above restrictions, you don't need a second firewall and can skip this section.
BigBlueButton comes with a [UFW](https://launchpad.net/ufw) based ruleset. It it can be applied on restart (c.f. [Automatically apply configuration changes on restart](#automatically-apply-configuration-changes-on-restart)) and restricts access only to the following needed ports:
BigBlueButton comes with a [UFW](https://launchpad.net/ufw) based ruleset. It it can be applied on restart and restricts access only to the following needed ports:
- TCP/IP port 22 for SSH
- TCP/IP port 80 for HTTP
@ -1028,7 +1029,9 @@ tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN
tcp6 0 0 :::22 :::* LISTEN 1739/sshd
```
To restrict external access minimal needed ports for BigBlueButton (with [HTML5 client set as default](#make-the-html5-client-default)). BigBlueButton supplies a helper function that you can call in `/etc/bigbluebutton/bbb-conf/apply-conf.sh` to setup a minimal firewall (see [Setup Firewall](#setup-firewall).
To restrict external access minimal needed ports for BigBlueButton.
BigBlueButton supplies a helper function that you can call in `/etc/bigbluebutton/bbb-conf/apply-conf.sh`
to setup a minimal firewall (see [Setup Firewall](#setup-firewall)).
You can also do it manually with the following commands

View File

@ -268,7 +268,7 @@ ii bbb-webrtc-sfu 1:2.6-6 amd64 BigBlueButton WebRTC SFU
With Greenlight installed (that was the `-g` option), you can open `https://<hostname>/b` in a browser (where `<hostname>` is the hostname you specified in the `bbb-install-2.6.sh` command), create a local account, create a room and join it.
<img src="/img/greenlight_welcome.png" alt="BigBlueButton's Greenlight Interface"/>
![BigBlueButton's Greenlight Interface](/img/greenlight_welcome.png)
You can integrate BigBlueButton with one of the 3rd party integrations by providing the integration of the server's address and shared secret. You can use `bbb-conf` to display this information using `bbb-conf --secret`.

View File

@ -14,9 +14,15 @@ BigBlueButton is [certified](https://site.imsglobal.org/certifications/bigbluebu
![imscertifiedsm](/img/imscertifiedsm.png)
BigBlueButton can accept incoming LTI launch requests from a tool consumer, which is the IMS term for any platform that can make an LTI request to an external tool (such as BigBlueButton). Such platforms include Desire2Learn, BlackBoard, Pearson Learning Studio, etc. See [IMS Interoperability Conformance Certification Status](https://www.imsglobal.org/cc/statuschart.cfm) for a full list of LTI compliant platforms.
BigBlueButton can accept incoming LTI launch requests from a tool consumer,
which is the IMS term for any platform that can make an LTI request to an external tool (such as BigBlueButton).
Such platforms include Desire2Learn, BlackBoard, Pearson Learning Studio, etc.
See [IMS Interoperability Conformance Certification Status](https://www.imsglobal.org/cc/statuschart.cfm)
for a full list of LTI compliant platforms.
What this means is that with no custom code, any LTI compliant platform can add BigBlueButton virtual classrooms to its system. For example, the following video shows how BigBlueButton uses LTI to integrate with BlackBoard, click [BigBlueButton LTI video](https://www.youtube.com/watch?v=OSTGfvICYX4&feature=youtu.be&hd=1).
What this means is that with no custom code, any LTI compliant platform can add BigBlueButton virtual classrooms to its system.
For example, the following video shows how BigBlueButton uses LTI to integrate with BlackBoard,
click [BigBlueButton LTI video](https://www.youtube.com/watch?v=OSTGfvICYX4&feature=youtu.be&hd=1).
### Installation of LTI module
@ -61,9 +67,13 @@ If you make modifications to your own lti.properties, be sure to restart `bbb-lt
## Configuring BigBlueButton as an External Tool
All LTI consumers have the ability to launch an external application that is LTI-compliant. BigBlueButton is [LTI 1.0 compliant](https://www.imsglobal.org/cc/detail.cfm?ID=172).
All LTI consumers have the ability to launch an external application that is LTI-compliant.
BigBlueButton is [LTI 1.0 compliant](https://www.imsglobal.org/cc/detail.cfm?ID=172).
This means that your BigBlueButton server can receive a single sign-on request that includes roles and additional custom parameters. To configure an external tool in the LTI consumer, you need to provide three pieces of information: URL, customer identifier, and shared secret. After installing the `bbb-lti` package, you can use the command `bbb-conf --lti` to retrieve these values.
This means that your BigBlueButton server can receive a single sign-on request that includes roles and additional custom parameters.
To configure an external tool in the LTI consumer, you need to provide three pieces of information:
URL, customer identifier, and shared secret.
After installing the `bbb-lti` package, you can use the command `bbb-conf --lti` to retrieve these values.
Here are the LTI configuration variables from a test BigBlueButton server.
@ -77,15 +87,23 @@ $ bbb-conf --lti
Icon URL: https://demo.bigbluebutton.org/lti/img/icon.ico
```
In the external tool configuration, we recommend privacy settings are set to **public** to allow the LMS to send lis_person_sourcedid and lis_person_contact_email_primary. The `bbb-lti` module will use these parameters for user identification once logged into the BigBlueButton session. If none of them is sent by default a generic name is going to be used (Viewer for viewer and Moderator for moderator).
In the external tool configuration, we recommend privacy settings are set to **public**
to allow the LMS to send lis_person_sourcedid and lis_person_contact_email_primary.
The `bbb-lti` module will use these parameters for user identification once logged into the BigBlueButton session.
If none of them is sent by default a generic name is going to be used (Viewer for viewer and Moderator for moderator).
An important note is that if your LTI consumer uses https, as the LTI tool is displayed in an iframe you will see only a blank page. In that case you can configure the link to open the tool in a different window (most LTI consumers allow it) or use https with the URL provided (https://demo.bigbluebutton.org/lti/tool).
An important note is that if your LTI consumer uses https, as the LTI tool is displayed in an iframe, you will see only a blank page.
In that case you can configure the link to open the tool in a different window (most LTI consumers allow it)
or use https with the URL provided (e.g. `https://demo.bigbluebutton.org/lti/tool`).
## Launching BigBlueButton as an External Tool
The LTI launch request passes along the user's role (which `bbb-lti` will map to the two roles in BigBlueButton: moderator or viewer.
The LTI launch request passes along the user's role (which `bbb-lti` will map to the two roles in BigBlueButton): moderator or viewer.
If no role information is given, or if the role is privileged (i.e. . Faculty, Mentor, Administrator, Instructor, etc.), then when `bbb-lti` receives a valid launch request, it will start a BigBlueButton session and join the user as **moderator**. In all other cases, the user will join as a **viewer**.
If no role information is given, or if the role is privileged (i.e. . Faculty, Mentor, Administrator, Instructor, etc.),
then when `bbb-lti` receives a valid launch request,
it will start a BigBlueButton session and join the user as **moderator**.
In all other cases, the user will join as a **viewer**.
### Custom Parameters

View File

@ -658,4 +658,4 @@ The above will re-sync your clock.
## Set up HTTPS
Follow [Configure SSL on your BigBlueButton server](/administration/install#configure-ssl-on-your-bigbluebutton-server)
See the [installation instructions](/administration/install) on how to configure ssl on your BigBlueButton server.

View File

@ -21,9 +21,9 @@ If you would like to help translate BigBlueButton into your language, or you see
2. Choose the project
For helping to translate the HTML5 client, visit [BigBlueButton v2.4 HTML5 client](https://www.transifex.com/bigbluebutton/bigbluebutton-v24-html5-client/).
For helping to translate, visit the [BigBlueButton on Transifex](https://www.transifex.com/bigbluebutton/) (needs a login).
You'll see a list of languages ready for translation.
You'll see a list of languages and components of BigBlueButton ready for translation.
3. Click the name of the language you wish to translate
@ -35,9 +35,9 @@ You'll see a list of languages ready for translation.
#### Administrators can pull specific languages their Transifex account is associated with
In BigBlueButton 2.2, 2.3, 2.4, 2.5 the script used for pulling the locales is located in the `bigbluebutton-html5` directory
Up to BigBlueButton 2.5 the script used for pulling the locales is located in the `bigbluebutton-html5` directory
https://github.com/bigbluebutton/bigbluebutton/blob/v2.4.x-release/bigbluebutton-html5/transifex.sh
https://github.com/bigbluebutton/bigbluebutton/blob/v2.5.x-release/bigbluebutton-html5/transifex.sh
You can trigger the download of the latest strings by running a command of the format `./transifex.sh pt_BR de` (passing the code for the languages you'd like to download)

View File

@ -285,7 +285,7 @@ timestamp=1234567890
for this meeting. The application needs to detect the create event (`meeting_created_message`) to
have a proper mapping of internal to external meeting IDs. So make sure the web hooks application
is always running while BigBlueButton is running!
* If you register a hook with, for example, the URL "http://myserver.com/my/hook" and no `meetingID`
* If you register a hook with, for example, the URL `http://myserver.com/my/hook` and no `meetingID`
set (making it a global hook) and later try to register another hook with the same URL but with
a `meetingID` set, the first hook will not be removed nor modified, while the second hook will
not be created.

View File

@ -60,7 +60,7 @@ To do so, follow the steps below:
2) Download the migration rake tasks with the following command:
```bash
wget -P lib/tasks/migrations https://raw.githubusercontent.com/bigbluebutton/greenlight/master/lib/tasks/migrations/migrations.rake
wget -P lib/tasks/migrations https://raw.githubusercontent.com/bigbluebutton/greenlight/v2/lib/tasks/migrations/migrations.rake
```
The file **migrations.rake** should now be present in your **/lib/tasks/migrations** directory.
@ -125,8 +125,7 @@ sudo docker exec -it greenlight-v2 bundle exec rake migrations:roles
The Users will be migrated with their corresponding role.
Important notes:
- **Once the Users migration is completed, the migrated users will be prompted to reset their password via an automated email. The accounts passwords can't be migrated from Greenlight v2.**
- Pending, denied and deleted users will not be migrated to Greenlight v3.
- **The accounts passwords can't be migrated from Greenlight v2. A rake task that sends an email to all the users and prompts them to reset their password is provided for Greenlight v3. When the migration is completed, please jump to [After the Migration](#after-the-migration). Please note that if you are using external accounts, like Google or Microsoft, this is not applicable.**- Pending, denied and deleted users will not be migrated to Greenlight v3.
- Both local and external users will be migrated.
**To migrate all of your v2 users to v3, run the following command:**
@ -137,7 +136,7 @@ sudo docker exec -it greenlight-v2 bundle exec rake migrations:users
**To migrate only a portion of the users starting from *FIRST_USER_ID* to *LAST_USER_ID*, run this command instead:**
```bash
sudo docker exec -it greenlight-v2 bundle exec rake migrations:users[**FIRST_USER_ID, LAST_USER_ID**]
sudo docker exec -it greenlight-v2 bundle exec rake migrations:users\[<FIRST_USER_ID>,<LAST_USER_ID>]
```
*Administrators can use the last command to migrate resources in parallel, the same migration task can be run in separate processes each migrating a portion of the resources class simultaneously.*
@ -163,7 +162,7 @@ sudo docker exec -it greenlight-v2 bundle exec rake migrations:rooms
**To migrate only a portion of users starting from **FIRST_ROOM_ID** to **LAST_ROOM_ID**, run this command instead**:**
```bash
sudo docker exec -it greenlight-v2 bundle exec rake migrations:rooms[**FIRST_ROOM_ID, LAST_ROOM_ID**]
sudo docker exec -it greenlight-v2 bundle exec rake migrations:rooms\[<FIRST_ROOM_ID>,<LAST_ROOM_ID>]
```
*Note: The partitioning is based on resources id value and not there position in the database, so calling **rake migrations:rooms[1, 100]** will not migrate the first 100 active users rooms but rather active users rooms having an id of 1 to 100 if existed.*
@ -191,3 +190,24 @@ sudo docker exec -it greenlight-v2 bundle exec rake migrations:settings
```
**If you have an error, try re-running the migration task to resolve any failed resources migration.**
## After the Migration
Having completed the migration successfully, it is now imperative to inform users of the need to reset their Greenlight account passwords.
This can be achieved through the utilization of the rake task available in Greenlight v3.
**It is important to note, however, that this is not applicable for users who utilize external accounts such as Google or Microsoft.**
To send a reset password email to all your users, run the following command:
```bash
sudo docker exec -it greenlight-v3 bundle exec rake migration:reset_password_email\[<BASE URL>]
```
The &lt;BASE URL&gt; in the command above should be replaced with your Greenlight domain name.
Also, please note that the BigBlueButton recordings list will now be empty.
To re-sync the list of recordings, run the following command:
```bash
sudo docker exec -it greenlight-v3 bundle exec rake server_recordings_sync
```

View File

@ -145,7 +145,7 @@ When you click Apply, BigBlueButton will send prompts to each user to move them
This release introduces a new recording format that creates a single video file from audio, video, screen share, presentation, and whiteboard marks recorded during the session. The file format is webm (vp9 video), although configuration options is available to create an mp4 (h264 video) file instead.
Learn more about [how to enable generating MP4 (h264 video) output](https://docs.bigbluebutton.org/admin/customize.html#enable-generating-mp4-h264-video-output)
Learn more about [how to enable generating MP4 (h264 video) output](https://docs.bigbluebutton.org/administration/customize#enable-generating-mp4-h264-video-output)
#### Change of parameters naming

View File

@ -56,7 +56,7 @@ and then to rebuild a recording, use `sudo bbb-record --rebuild <internal_meetin
$ sudo bbb-record --rebuild 298b06603719217df51c5d030b6e9417cc036476-1559314745219
```
## mediasoup
## bbb-webrtc-sfu and mediasoup
### Webcams/screen sharing aren't working
@ -162,6 +162,27 @@ No. Scalability improves a lot with mediasoup, but there are still a couple of b
- The signaling server (bbb-webrtc-sfu): it does not scale vertically indefinitely. There's always work ongoing on this area that can be tracked in [this issue](https://github.com/mconf/mconf-tracker/issues/238);
- The mediasoup worker balancing algorithm implemented by bbb-webrtc-sfu is still focused on multiparty meetings with a restrained number of users. If your goal is thousand-user 1-N (streaming-like) meetings, you may max out CPU usage on certain mediasoup workers even though there are other idle oworkers free.
### bbb-webrtc-sfu fails to start with a SETSCHEDULER error
bbb-webrtc-sfu runs with CPUSchedulingPolicy=fifo. In systems without appropriate capabilities (SYS_NICE), the application will fail to start.
The error can be verified in journalctl logs as 214/SETSCHEDULER.
Similar to [bbb-html5](#bbb-html5-fails-to-start-with-a-setscheduler-error), you can override this by running
```
mkdir /etc/systemd/system/bbb-webrtc-sfu.service.d
```
and creating `/etc/systemd/system/bbb-webrtc-sfu.service.d/override.conf` with the following contents
```
[Service]
CPUSchedulingPolicy=other
Nice=-10
```
Then do `systemctl daemon-reload` and restart BigBlueButton.
## Kurento
### WebRTC video not working with Kurento
@ -384,7 +405,7 @@ $ sudo bbb-conf --check
### FreeSWITCH fails to start with a SETSCHEDULER error
When running in a container (like a chroot, OpenVZ or LXC), it might not be possible for FreeSWITCH to set its CPU priority to [real-time round robin](https://man7.org/linux/man-pages/man2/sched_setscheduler.2.html). If not, it will result in lower performance compared to a non-virtualized installation.
When running in a container (like a chroot, OpenVZ, LXC or LXD), it might not be possible for FreeSWITCH to set its CPU priority to [real-time round robin](https://man7.org/linux/man-pages/man2/sched_setscheduler.2.html). If not, it will result in lower performance compared to a non-virtualized installation.
If you running BigBlueButton in a container and an error starting FreeSWITCH, try running `systemctl status freeswitch.service` and see if you see the error related to SETSCHEDULER
@ -405,12 +426,15 @@ Oct 02 16:17:29 scw-9e2305 systemd[1]: freeswitch.service: Start request repeate
Oct 02 16:17:29 scw-9e2305 systemd[1]: Failed to start freeswitch.
```
If you see `SETSCHEDULER` in the error message, edit `/lib/systemd/system/freeswitch.service` and comment out the line containing `CPUSchedulingPolicy=rr` (round robin)
If you see `SETSCHEDULER` in the error message, edit `/lib/systemd/system/freeswitch.service` and comment the following:
```ini
IOSchedulingPriority=2
```properties
#LimitRTPRIO=infinity
#LimitRTTIME=7000000
#IOSchedulingClass=realtime
#IOSchedulingPriority=2
#CPUSchedulingPolicy=rr
CPUSchedulingPriority=89
#CPUSchedulingPriority=89
```
Save the file, run `systemctl daemon-reload`, and then restart BigBlueButton. FreeSWITCH should now startup without error.
@ -904,55 +928,16 @@ However, if you install BigBlueButton within an LXD container, you will get the
# Error: Unable to connect to the FreeSWITCH Event Socket Layer on port 8021
```
You'll also get an error from starting FreeSWITCH with `bbb-conf --restart`. When you try `systemctl status freeswitch.service`, you'll see an error with SETSCHEDULER.
If you check the output of `sudo bbb-conf --status`, you'll be able to identify that three different applications failed to start: FreeSWITCH, bbb-webrtc-sfu and bbb-html5.
Optionally, check their errors via `systemctl status <service-name>.service` and verify that their boot sequence failed due to a SETSCHEDULER error.
```bash
$ sudo systemctl status freeswitch.service
● freeswitch.service - freeswitch
Loaded: loaded (/lib/systemd/system/freeswitch.service; enabled; vendor preset: enabled)
Active: inactive (dead) (Result: exit-code) since Wed 2017-04-26 16:34:24 UTC; 23h ago
Process: 7038 ExecStart=/opt/freeswitch/bin/freeswitch -u freeswitch -g daemon -ncwait $DAEMON_OPTS (code=exited, status=214/SETSCHEDULER)
This error occurs because the default systemd unit scripts for FreeSWITCH, bbb-html5 and bbb-webrtc-sfu try to run with permissions not available to the LXD container.
To get them working within an LXD container, follow the steps outlined in the following sections:
- [FreeSWITCH fails to start with a SETSCHEDULER error](#freeswitch-fails-to-start-with-a-setscheduler-error)
- [bbb-webrtc-sfu fails to start with a SETSCHEDULER error](#bbb-webrtc-sfu-fails-to-start-with-a-setscheduler-error)
- [bbb-html5 fails to start with a SETSCHEDULER error](#bbb-html5-fails-to-start-with-a-setscheduler-error)
Apr 26 16:34:24 big systemd[1]: Failed to start freeswitch.
Apr 26 16:34:24 big systemd[1]: freeswitch.service: Unit entered failed state.
Apr 26 16:34:24 big systemd[1]: freeswitch.service: Failed with result 'exit-code'.
Apr 26 16:34:24 big systemd[1]: freeswitch.service: Service hold-off time over, scheduling restart.
Apr 26 16:34:24 big systemd[1]: Stopped freeswitch.
Apr 26 16:34:24 big systemd[1]: freeswitch.service: Start request repeated too quickly.
Apr 26 16:34:24 big systemd[1]: Failed to start freeswitch.
```
This error occurs because the default systemd unit script for FreeSWITCH tries to run with permissions not available to the LXD container. To run FreeSWITCH within an LXD container, edit `/lib/systemd/system/freeswitch.service` and replace with the following
```properties
[Unit]
Description=freeswitch
After=syslog.target network.target local-fs.target
[Service]
Type=forking
PIDFile=/opt/freeswitch/var/run/freeswitch/freeswitch.pid
Environment="DAEMON_OPTS=-nonat"
EnvironmentFile=-/etc/default/freeswitch
ExecStart=/opt/freeswitch/bin/freeswitch -u freeswitch -g daemon -ncwait $DAEMON_OPTS
TimeoutSec=45s
Restart=always
WorkingDirectory=/opt/freeswitch
User=freeswitch
Group=daemon
[Install]
WantedBy=multi-user.target
```
Then enter the following commands to load the new unit file and restart BigBlueButton.
```bash
$ sudo systemctl daemon-reload
$ sudo bbb-conf --restart
```
You can run BigBlueButton within a LXD container.
You can now run BigBlueButton within a LXD container.
### Unable to connect to redis

View File

@ -25,7 +25,10 @@ module BigBlueButton
module EDL
module Video
FFMPEG_WF_CODEC = 'libx264'
FFMPEG_WF_ARGS = ['-codec', FFMPEG_WF_CODEC.to_s, '-preset', 'veryfast', '-crf', '30', '-force_key_frames', 'expr:gte(t,n_forced*10)', '-pix_fmt', 'yuv420p']
FFMPEG_WF_ARGS = [
'-codec', FFMPEG_WF_CODEC.to_s, '-preset', 'fast', '-crf', '23',
'-x264opts', 'stitchable=1', '-force_key_frames', 'expr:gte(t,n_forced*10)', '-pix_fmt', 'yuv420p',
]
WF_EXT = 'mp4'
def self.dump(edl)
@ -420,13 +423,14 @@ module BigBlueButton
duration = cut[:next_timestamp] - cut[:timestamp]
BigBlueButton.logger.info " Cut start time #{cut[:timestamp]}, duration #{duration}"
aux_ffmpeg_processes = []
aux_ffmpeg_processes = {}
ffmpeg_inputs = [
{
format: 'lavfi',
filename: "color=c=white:s=#{layout[:width]}x#{layout[:height]}:r=#{layout[:framerate]}"
}
]
ffmpeg_input_pipes = {}
ffmpeg_filter = '[0]null'
layout[:areas].each do |layout_area|
area = cut[:areas][layout_area[:name]]
@ -545,10 +549,16 @@ module BigBlueButton
# Launch the ffmpeg process to use for this input to pre-process the video to constant video resolution
# This has to be done in an external process, since if it's done in the same process, the entire filter
# chain gets re-initialized on every resolution change, resulting in losing state on all stateful filters.
ffmpeg_preprocess_output = "#{output}.#{pad_name}.nut"
ffmpeg_preprocess_log = "#{output}.#{pad_name}.log"
FileUtils.rm_f(ffmpeg_preprocess_output)
File.mkfifo(ffmpeg_preprocess_output)
ffmpeg_preprocess_read, ffmpeg_preprocess_write = IO.pipe
# To reduce overhead, adjust the size of the fifo buffer larger
# By default, Linux allows pipe sizes to be increased to 1MB by normal users.
begin
ffmpeg_preprocess_write.fcntl(Fcntl::F_SETPIPE_SZ, 1_048_576)
rescue Errno::EPERM
BigBlueButton.logger.warn('Unable to increase pipe size to 1MB')
end
in_time = video[:timestamp] + seek_offset
out_time = in_time + duration
@ -564,23 +574,26 @@ module BigBlueButton
# Set up filters and inputs for video pre-processing ffmpeg command
ffmpeg_preprocess_command = [
'ffmpeg', '-y', '-v', 'info', '-nostats', '-nostdin', '-max_error_rate', '1.0',
# Ensure timebase conversion is not done, and frames prior to seek point run through filters.
'-vsync', 'passthrough', '-noaccurate_seek',
'ffmpeg', '-y', '-v', 'warning', '-nostats', '-nostdin', '-max_error_rate', '1.0',
# Ensure input isn't misdetected as cfr, and frames prior to seek point run through filters.
'-vsync', 'vfr', '-noaccurate_seek',
'-ss', ms_to_s(seek).to_s, '-itsoffset', ms_to_s(seek).to_s, '-i', video[:filename],
'-filter_complex', ffmpeg_preprocess_filter, '-map', '[out]',
'-c:v', 'rawvideo', "#{output}.#{pad_name}.nut",
# Copy timebase from input instead of guessing based on framerate
'-enc_time_base', '-1',
'-c:v', 'rawvideo', '-f', 'nut', "pipe:#{ffmpeg_preprocess_write.fileno}",
]
BigBlueButton.logger.debug("Executing: #{Shellwords.join(ffmpeg_preprocess_command)}")
ffmpeg_preprocess_pid = spawn(*ffmpeg_preprocess_command, err: [ffmpeg_preprocess_log, 'w'])
aux_ffmpeg_processes << {
pid: ffmpeg_preprocess_pid,
log: ffmpeg_preprocess_log
}
ffmpeg_inputs << {
filename: ffmpeg_preprocess_output
}
BigBlueButton.logger.info("Executing: #{Shellwords.join(ffmpeg_preprocess_command)}")
ffmpeg_preprocess_pid = spawn(
*ffmpeg_preprocess_command,
err: [ffmpeg_preprocess_log, 'w'],
ffmpeg_preprocess_write => ffmpeg_preprocess_write
)
ffmpeg_preprocess_write.close
BigBlueButton.logger.debug("preprocessing ffmpeg command pid #{ffmpeg_preprocess_pid}")
aux_ffmpeg_processes[ffmpeg_preprocess_pid] = { log: ffmpeg_preprocess_log }
ffmpeg_inputs << { filename: "pipe:#{ffmpeg_preprocess_read.fileno}", format: 'nut' }
ffmpeg_input_pipes[ffmpeg_preprocess_read] = ffmpeg_preprocess_read
ffmpeg_filter << "[#{input_index}]"
# Scale the video length for the deskshare timestamp workaround
ffmpeg_filter << "setpts=PTS*#{scale}," unless scale.nil?
@ -588,6 +601,10 @@ module BigBlueButton
ffmpeg_filter << "tpad=stop=-1:stop_mode=clone,fps=#{layout[:framerate]}:start_time=#{ms_to_s(in_time)}"
# Apply PTS offset so '0' time is aligned, and trim frames before start point
ffmpeg_filter << ",setpts=PTS-#{ms_to_s(in_time)}/TB,trim=start=0"
# Trim frames after stop time, which can be generated by the pre-processing ffmpeg if there's an unlucky
# large timestamp gap before a frame which changes resolution.
# The trim filter is needed to eat these frames so they don't queue up on the inputs of overlays.
ffmpeg_filter << ",trim=end=#{ms_to_s(duration)}"
ffmpeg_filter << "[#{pad_name}];"
end
@ -638,26 +655,54 @@ module BigBlueButton
ffmpeg_cmd += ['-an', *FFMPEG_WF_ARGS, '-r', layout[:framerate].to_s, output]
exitstatus = BigBlueButton.exec_ret(*ffmpeg_cmd)
BigBlueButton.logger.info("Executing: #{Shellwords.join(ffmpeg_cmd)}")
ffmpeg_log = "#{output}.log"
ffmpeg_pid = spawn(*ffmpeg_cmd, err: [ffmpeg_log, 'w'], **ffmpeg_input_pipes)
# We are explicitly keeping our copy of the read side of the pipes open here, since if there
# are any preprocessing ffmpeg commands still running when the main ffmpeg exits, we want to
# be able to signal them to exit cleanly while they're blocked trying to write. If the pipe
# was closed, they would exit with an error code before we can do anything.
aux_ffmpeg_exitstatuses = []
aux_ffmpeg_processes.each do |process|
aux_exitstatus = Process.waitpid2(process[:pid])
aux_ffmpeg_exitstatuses << aux_exitstatus[1]
BigBlueButton.logger.info("Command output: #{File.basename(process[:log])} #{aux_exitstatus[1]}")
File.open(process[:log], 'r') do |io|
ffmpeg_exitok = []
loop do
pid, exitstatus = Process.waitpid2(-1)
if pid == ffmpeg_pid
BigBlueButton.logger.debug("ffmpeg command #{exitstatus} (#{File.basename(ffmpeg_log)})")
# Tell any preprocessing ffmpeg processes which are blocking on writing
# to the pipe to exit cleanly
Process.kill('TERM', *aux_ffmpeg_processes.keys) unless aux_ffmpeg_processes.empty?
# Then unblock them by closing our copy of the read side of the pipe
ffmpeg_input_pipes.each_value(&:close)
ffmpeg_exitok << exitstatus.success?
log = ffmpeg_log
else
process = aux_ffmpeg_processes.delete(pid)
BigBlueButton.logger.debug("preprocessing ffmpeg command #{exitstatus} (#{File.basename(process[:log])})")
# Exit code 255 indicates that ffmpeg was terminated due to user request by signal
ffmpeg_exitok << (exitstatus.success? || exitstatus.exitstatus == 255)
log = process[:log]
end
# Read the temporary log file and include its contents into the processing log
File.open(log, 'r') do |io|
io.each_line do |line|
BigBlueButton.logger.info(line.chomp)
BigBlueButton.logger.info(line.chomp!)
end
end
rescue Errno::ECHILD
# All child ffmpeg processes have exited
break
end
raise 'At least one auxiliary ffmpeg process failed' unless aux_ffmpeg_exitstatuses.all?(&:success?)
raise "ffmpeg failed, exit code #{exitstatus}" if exitstatus != 0
raise 'At least one ffmpeg process failed' unless ffmpeg_exitok.all?
BigBlueButton.logger.info('All ffmpeg processes exited normally')
return output
end
end
end
end

View File

@ -330,7 +330,7 @@ module BigBlueButton
end
elsif is_in_forbidden_period && event.at_xpath('value').text == "VIEWER"
filename_to_add = BigBlueButton::Events.extract_filename_from_userId(userId, active_videos)
if filename != ""
if filename_to_add != ""
active_videos.delete(filename_to_add)
inactive_videos << filename_to_add

View File

@ -1,4 +1,4 @@
bbb_version: '2.6.0'
bbb_version: '2.6.1'
raw_audio_src: /var/freeswitch/meetings
kurento_video_src: /var/kurento/recordings
kurento_screenshare_src: /var/kurento/screenshare

View File

@ -893,7 +893,7 @@ def process_presentation(package_dir)
cursors = []
shapes = {}
tldraw = @version_atleast_2_6_0
tldraw_shapes = {}
tldraw_shapes = {'bbb_version': BigBlueButton::Events.bbb_version(@doc)}
# Iterate through the events.xml and store the events, building the
# xml files as we go

View File

@ -22,7 +22,7 @@ layout:
- name: deskshare
x: 0
y: 0
width: 920
width: 960
height: 720
pad: true
nopresentation_layout: