Merge remote-tracking branch 'upstream/v3.0.x-release' into publish-poll-open-chat

This commit is contained in:
André 2024-02-22 10:06:54 -03:00
commit 872924e537
78 changed files with 1310 additions and 545 deletions

View File

@ -104,7 +104,7 @@ object Polls {
} yield {
val pageId = if (poll.id.contains("deskshare")) "deskshare" else page.id
val updatedShape = shape + ("whiteboardId" -> pageId)
val annotation = new AnnotationVO(poll.id, updatedShape, pageId, requesterId)
val annotation = new AnnotationVO(s"shape:poll-result-${poll.id}", updatedShape, pageId, requesterId)
annotation
}
}
@ -253,12 +253,13 @@ object Polls {
private def pollResultToWhiteboardShape(result: SimplePollResultOutVO): scala.collection.immutable.Map[String, Object] = {
val shape = new scala.collection.mutable.HashMap[String, Object]()
shape += "numRespondents" -> new Integer(result.numRespondents)
shape += "numResponders" -> new Integer(result.numResponders)
shape += "numRespondents" -> Integer.valueOf(result.numRespondents)
shape += "numResponders" -> Integer.valueOf(result.numResponders)
shape += "questionType" -> result.questionType
shape += "questionText" -> result.questionText
shape += "id" -> result.id
shape += "questionText" -> result.questionText.getOrElse("")
shape += "id" -> s"shape:poll-result-${result.id}"
shape += "answers" -> result.answers
shape += "type" -> "geo"
shape.toMap
}

View File

@ -85,6 +85,9 @@ class RedisRecorderActor(
case m: UserLeftMeetingEvtMsg => handleUserLeftMeetingEvtMsg(m)
case m: PresenterAssignedEvtMsg => handlePresenterAssignedEvtMsg(m)
case m: UserEmojiChangedEvtMsg => handleUserEmojiChangedEvtMsg(m)
case m: UserAwayChangedEvtMsg => handleUserAwayChangedEvtMsg(m)
case m: UserRaiseHandChangedEvtMsg => handleUserRaiseHandChangedEvtMsg(m)
case m: UserReactionEmojiChangedEvtMsg => handleUserReactionEmojiChangedEvtMsg(m)
case m: UserRoleChangedEvtMsg => handleUserRoleChangedEvtMsg(m)
case m: UserBroadcastCamStartedEvtMsg => handleUserBroadcastCamStartedEvtMsg(m)
case m: UserBroadcastCamStoppedEvtMsg => handleUserBroadcastCamStoppedEvtMsg(m)
@ -379,6 +382,18 @@ class RedisRecorderActor(
handleUserStatusChange(msg.header.meetingId, msg.body.userId, "emojiStatus", msg.body.emoji)
}
private def handleUserAwayChangedEvtMsg(msg: UserAwayChangedEvtMsg) {
handleUserStatusChange(msg.header.meetingId, msg.body.userId, "away", if (msg.body.away) "true" else "false")
}
private def handleUserRaiseHandChangedEvtMsg(msg: UserRaiseHandChangedEvtMsg) {
handleUserStatusChange(msg.header.meetingId, msg.body.userId, "raiseHand", if (msg.body.raiseHand) "true" else "false")
}
private def handleUserReactionEmojiChangedEvtMsg(msg: UserReactionEmojiChangedEvtMsg) {
handleUserStatusChange(msg.header.meetingId, msg.body.userId, "reactionEmoji", msg.body.reactionEmoji)
}
private def handleUserRoleChangedEvtMsg(msg: UserRoleChangedEvtMsg) {
handleUserStatusChange(msg.header.meetingId, msg.body.userId, "role", msg.body.role)
}

View File

@ -181,6 +181,7 @@ public class MeetingService implements MessageListener {
removedUser.meetingId = us.meetingID;
removedUser.userId = us.internalUserId;
removedUser.sessionToken = us.authToken;
removedUser.role = us.role;
removedSessions.put(token, removedUser);
sessions.remove(token);
} else {

View File

@ -23,6 +23,11 @@ public class UserSessionBasicData {
public String sessionToken = null;
public String userId = null;
public String meetingId = null;
public String role = null;
public Boolean isModerator() {
return "MODERATOR".equalsIgnoreCase(this.role);
}
public String toString() {
return meetingId + " " + userId + " " + sessionToken;

View File

@ -90,6 +90,9 @@ export default function Auth() {
meeting {
name
ended
learningDashboard {
learningDashboardAccessToken
}
}
}
}`
@ -131,6 +134,12 @@ export default function Auth() {
<span>{curr.guestStatusDetails.guestLobbyMessage}</span>
<span>Your position is: {curr.guestStatusDetails.positionInWaitingQueue}</span>
</div>
} else if(curr.loggedOut) {
return <div>
{curr.meeting.name}
<br/><br/>
<span>You left the meeting.</span>
</div>
} else if(!curr.joined) {
return <div>
{curr.meeting.name}

View File

@ -798,6 +798,10 @@ from "meeting"
left join "user" "user_ended" on "user_ended"."userId" = "meeting"."endedBy"
;
create view "v_meeting_learningDashboard" as
select "meetingId", "learningDashboardAccessToken"
from "v_meeting";
-- ===================== CHAT TABLES

View File

@ -10,13 +10,13 @@ cd "$(dirname "$0")"
# Install Postgresql
apt update
apt install postgresql postgresql-contrib -y
sudo -u postgres psql -c "alter user postgres password 'bbb_graphql'"
sudo -u postgres psql -c "drop database if exists bbb_graphql with (force)"
sudo -u postgres psql -c "create database bbb_graphql WITH TEMPLATE template0 LC_COLLATE 'C.UTF-8'"
sudo -u postgres psql -c "alter database bbb_graphql set timezone to 'UTC'"
sudo -u postgres psql -U postgres -d bbb_graphql -a -f bbb_schema.sql --set ON_ERROR_STOP=on
sudo -u postgres psql -c "drop database if exists hasura_app with (force)"
sudo -u postgres psql -c "create database hasura_app"
runuser -u postgres -- psql -c "alter user postgres password 'bbb_graphql'"
runuser -u postgres -- psql -c "drop database if exists bbb_graphql with (force)"
runuser -u postgres -- psql -c "create database bbb_graphql WITH TEMPLATE template0 LC_COLLATE 'C.UTF-8'"
runuser -u postgres -- psql -c "alter database bbb_graphql set timezone to 'UTC'"
runuser -u postgres -- psql -U postgres -d bbb_graphql -a -f bbb_schema.sql --set ON_ERROR_STOP=on
runuser -u postgres -- psql -c "drop database if exists hasura_app with (force)"
runuser -u postgres -- psql -c "create database hasura_app"
echo "Creating frontend in bbb_graphql"
DATABASE_FRONTEND_USER="bbb_frontend"

View File

@ -52,6 +52,15 @@ object_relationships:
remote_table:
name: v_layout
schema: public
- name: learningDashboard
using:
manual_configuration:
column_mapping:
meetingId: meetingId
insertion_order: null
remote_table:
name: v_meeting_learningDashboard
schema: public
- name: lockSettings
using:
manual_configuration:

View File

@ -0,0 +1,20 @@
table:
name: v_meeting_learningDashboard
schema: public
select_permissions:
- role: bbb_client
permission:
columns:
- learningDashboardAccessToken
filter:
meetingId:
_eq: X-Hasura-ModeratorInMeeting
comment: ""
- role: not_joined_bbb_client
permission:
columns:
- learningDashboardAccessToken
filter:
meetingId:
_eq: X-Hasura-ModeratorInMeeting
comment: ""

View File

@ -16,6 +16,7 @@
- "!include public_v_meeting_clientSettings.yaml"
- "!include public_v_meeting_componentsFlags.yaml"
- "!include public_v_meeting_group.yaml"
- "!include public_v_meeting_learningDashboard.yaml"
- "!include public_v_meeting_lockSettings.yaml"
- "!include public_v_meeting_recording.yaml"
- "!include public_v_meeting_recordingPolicies.yaml"

View File

@ -16,13 +16,12 @@ if [ "$hasura_status" = "active" ]; then
fi
echo "Restarting database bbb_graphql"
sudo -u postgres psql -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE datname = 'bbb_graphql'"
sudo -u postgres psql -c "drop database if exists bbb_graphql with (force)"
sudo -u postgres psql -c "create database bbb_graphql WITH TEMPLATE template0 LC_COLLATE 'C.UTF-8'"
sudo -u postgres psql -c "alter database bbb_graphql set timezone to 'UTC'"
runuser -u postgres -- psql -q -c "drop database if exists bbb_graphql with (force)"
runuser -u postgres -- psql -q -c "create database bbb_graphql WITH TEMPLATE template0 LC_COLLATE 'C.UTF-8'"
runuser -u postgres -- psql -q -c "alter database bbb_graphql set timezone to 'UTC'"
echo "Creating tables in bbb_graphql"
sudo -u postgres psql -U postgres -d bbb_graphql -q -f bbb_schema.sql --set ON_ERROR_STOP=on
runuser -u postgres -- psql -U postgres -d bbb_graphql -q -f bbb_schema.sql --set ON_ERROR_STOP=on
echo "Creating frontend in bbb_graphql"
DATABASE_FRONTEND_USER="bbb_frontend"
@ -40,21 +39,20 @@ fi
sudo -u postgres psql -q -d bbb_graphql -c "GRANT SELECT ON v_user_connection_auth TO $DATABASE_FRONTEND_USER"
if [ "$hasura_status" = "active" ]; then
echo "Starting Hasura"
sudo systemctl start bbb-graphql-server
echo "Starting Hasura"
sudo systemctl start bbb-graphql-server
#Check if Hasura is ready before applying metadata
HASURA_PORT=8080
while ! netstat -tuln | grep ":$HASURA_PORT " > /dev/null; do
echo "Waiting for Hasura's port ($HASURA_PORT) to be ready..."
sleep 1
done
#Check if Hasura is ready before applying metadata
HASURA_PORT=8080
while ! netstat -tuln | grep ":$HASURA_PORT " > /dev/null; do
echo "Waiting for Hasura's port ($HASURA_PORT) to be ready..."
sleep 1
done
fi
if [ "$akka_apps_status" = "active" ]; then
echo "Starting Akka-apps"
sudo systemctl start bbb-apps-akka
fi
echo "Applying new metadata to Hasura"
hasura metadata apply --skip-update-check
timeout 15s hasura metadata apply --skip-update-check

View File

@ -93,7 +93,7 @@ Meteor.startup(() => {
</AuthenticatedHandler>
</JoinHandler>
<UsersAdapter />
<ChatAdapter />
{/* <ChatAdapter /> */}
</>
</ContextProviders>,
document.getElementById('app'),

View File

@ -1,5 +1,7 @@
import React, { Component } from 'react';
import { withTracker } from 'meteor/react-meteor-data';
import * as PluginSdk from 'bigbluebutton-html-plugin-sdk';
import { UI_DATA_LISTENER_SUBSCRIBED } from 'bigbluebutton-html-plugin-sdk/dist/cjs/ui-data-hooks/consts';
import PropTypes from 'prop-types';
import { IntlProvider } from 'react-intl';
import Settings from '/imports/ui/services/settings';
@ -45,17 +47,20 @@ class IntlStartup extends Component {
}
this.fetchLocalizedMessages = this.fetchLocalizedMessages.bind(this);
this.sendUiDataToPlugins = this.sendUiDataToPlugins.bind(this);
}
componentDidMount() {
const { locale, overrideLocaleFromPassedParameter } = this.props;
this.fetchLocalizedMessages(overrideLocaleFromPassedParameter || locale, true);
window.addEventListener(`${UI_DATA_LISTENER_SUBSCRIBED}-${PluginSdk.IntlLocaleUiDataNames.CURRENT_LOCALE}`, this.sendUiDataToPlugins);
}
componentDidUpdate(prevProps) {
const { fetching, messages, normalizedLocale } = this.state;
const { locale, overrideLocaleFromPassedParameter } = this.props;
this.sendUiDataToPlugins();
if (overrideLocaleFromPassedParameter !== prevProps.overrideLocaleFromPassedParameter) {
this.fetchLocalizedMessages(overrideLocaleFromPassedParameter);
} else {
@ -64,6 +69,22 @@ class IntlStartup extends Component {
}
}
componentWillUnmount() {
window.removeEventListener(`${UI_DATA_LISTENER_SUBSCRIBED}-${PluginSdk.IntlLocaleUiDataNames.CURRENT_LOCALE}`, this.sendUiDataToPlugins);
}
sendUiDataToPlugins() {
const {
locale,
} = this.props;
window.dispatchEvent(new CustomEvent(PluginSdk.IntlLocaleUiDataNames.CURRENT_LOCALE, {
detail: {
locale,
fallbackLocale: DEFAULT_LANGUAGE,
},
}));
}
fetchLocalizedMessages(locale, init = false) {
const url = `./locale?locale=${locale}&init=${init}`;
const localesPath = 'locales';
@ -185,18 +206,28 @@ class IntlStartup extends Component {
}
}
const IntlStartupContainer = withTracker(() => {
const IntlStartupContainer = (props) => {
const setLocalSettings = useUserChangedLocalSettings();
return (
<IntlStartup
{...{
setLocalSettings,
...props,
}}
/>
);
};
export default withTracker(() => {
const { locale } = Settings.application;
const overrideLocaleFromPassedParameter = getFromUserSettings('bbb_override_default_locale', null);
const setLocalSettings = useUserChangedLocalSettings();
return {
locale,
overrideLocaleFromPassedParameter,
setLocalSettings,
};
})(IntlStartup);
export default IntlStartupContainer;
})(IntlStartupContainer);
IntlStartup.propTypes = propTypes;
IntlStartup.defaultProps = defaultProps;

View File

@ -17,11 +17,11 @@ import { screenshareHasEnded } from '/imports/ui/components/screenshare/service'
import Settings from '/imports/ui/services/settings';
const propTypes = {
amIPresenter: PropTypes.bool.isRequired,
amIPresenter: PropTypes.bool,
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
amIModerator: PropTypes.bool.isRequired,
amIModerator: PropTypes.bool,
shortcuts: PropTypes.string,
handleTakePresenter: PropTypes.func.isRequired,
isTimerActive: PropTypes.bool.isRequired,
@ -45,6 +45,8 @@ const propTypes = {
const defaultProps = {
shortcuts: '',
settingsLayout: LAYOUT_TYPE.SMART_LAYOUT,
amIPresenter: false,
amIModerator: false,
};
const intlMessages = defineMessages({

View File

@ -179,11 +179,16 @@ const propTypes = {
formatMessage: PropTypes.func.isRequired,
}).isRequired,
userId: PropTypes.string.isRequired,
emoji: PropTypes.string.isRequired,
emoji: PropTypes.string,
sidebarContentPanel: PropTypes.string.isRequired,
layoutContextDispatch: PropTypes.func.isRequired,
};
const defaultProps = {
emoji: '',
};
ReactionsButton.propTypes = propTypes;
ReactionsButton.defaultProps = defaultProps;
export default withShortcutHelper(ReactionsButton, ['raiseHand']);

View File

@ -23,12 +23,16 @@ const { isSafari, isTabletApp } = browserInfo;
const propTypes = {
intl: PropTypes.objectOf(Object).isRequired,
enabled: PropTypes.bool.isRequired,
amIPresenter: PropTypes.bool.isRequired,
amIPresenter: PropTypes.bool,
isScreenBroadcasting: PropTypes.bool.isRequired,
isMeteorConnected: PropTypes.bool.isRequired,
screenshareDataSavingSetting: PropTypes.bool.isRequired,
};
const defaultProps = {
amIPresenter: false,
};
const intlMessages = defineMessages({
desktopShareLabel: {
id: 'app.actionsBar.actionsDropdown.desktopShareLabel',
@ -214,4 +218,5 @@ const ScreenshareButton = ({
};
ScreenshareButton.propTypes = propTypes;
ScreenshareButton.defaultProps = defaultProps;
export default injectIntl(memo(ScreenshareButton));

View File

@ -29,6 +29,7 @@ import WebcamContainer from '../webcam/container';
import PresentationContainer from '../presentation/container';
import ScreenshareContainer from '../screenshare/container';
import ExternalVideoPlayerContainer from '../external-video-player/external-video-player-graphql/component';
import GenericComponentContainer from '../generic-component-content/container';
import EmojiRainContainer from '../emoji-rain/container';
import Styled from './styles';
import { DEVICE_TYPE, ACTIONS, SMALL_VIEWPORT_BREAKPOINT, PANELS } from '../layout/enums';
@ -633,6 +634,13 @@ class App extends Component {
<NavBarContainer main="new" />
<WebcamContainer isLayoutSwapped={!presentationIsOpen} layoutType={selectedLayout} />
<ExternalVideoPlayerContainer />
<GenericComponentContainer
{...{
shouldShowScreenshare,
shouldShowSharedNotes,
shouldShowExternalVideo,
}}
/>
{shouldShowPresentation ? <PresentationContainer setPresentationFitToWidth={this.setPresentationFitToWidth} fitToWidth={presentationFitToWidth} darkTheme={darkTheme} presentationIsOpen={presentationIsOpen} layoutType={selectedLayout} /> : null}
{shouldShowScreenshare ? <ScreenshareContainer isLayoutSwapped={!presentationIsOpen} isPresenter={isPresenter} /> : null}
{shouldShowSharedNotes

View File

@ -80,6 +80,7 @@ const AppContainer = (props) => {
} = props;
const sidebarContent = layoutSelectInput((i) => i.sidebarContent);
const genericComponent = layoutSelectInput((i) => i.genericComponent);
const sidebarNavigation = layoutSelectInput((i) => i.sidebarNavigation);
const actionsBarStyle = layoutSelectOutput((i) => i.actionBar);
const captionsStyle = layoutSelectOutput((i) => i.captions);
@ -188,14 +189,18 @@ const AppContainer = (props) => {
const shouldShowExternalVideo = isExternalVideoEnabled() && isSharingVideo;
const shouldShowGenericComponent = genericComponent.hasGenericComponent;
const validateEnforceLayout = (currentUser) => {
const layoutTypes = Object.values(LAYOUT_TYPE);
const enforceLayout = currentUser?.enforceLayout;
return enforceLayout && layoutTypes.includes(enforceLayout) ? enforceLayout : null;
};
const shouldShowScreenshare = propsShouldShowScreenshare && (viewScreenshare || isPresenter);
const shouldShowPresentation = (!shouldShowScreenshare && !shouldShowSharedNotes && !shouldShowExternalVideo
const shouldShowScreenshare = propsShouldShowScreenshare
&& (viewScreenshare || isPresenter);
const shouldShowPresentation = (!shouldShowScreenshare && !shouldShowSharedNotes
&& !shouldShowExternalVideo && !shouldShowGenericComponent
&& (presentationIsOpen || presentationRestoreOnUpdate)) && isPresentationEnabled();
return currentUserId

View File

@ -1,4 +1,4 @@
import React, { PureComponent } from 'react';
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { withTracker } from 'meteor/react-meteor-data';
import { Session } from 'meteor/session';
@ -23,6 +23,7 @@ import Service from './service';
import AudioModalContainer from './audio-modal/container';
import Settings from '/imports/ui/services/settings';
import useToggleVoice from './audio-graphql/hooks/useToggleVoice';
import { usePreviousValue } from '/imports/ui/components/utils/hooks';
const APP_CONFIG = Meteor.settings.public.app;
const KURENTO_CONFIG = Meteor.settings.public.kurento;
@ -74,88 +75,87 @@ const intlMessages = defineMessages({
},
});
class AudioContainer extends PureComponent {
constructor(props) {
super(props);
const AudioContainer = (props) => {
const {
isAudioModalOpen,
setAudioModalIsOpen,
setVideoPreviewModalIsOpen,
isVideoPreviewModalOpen,
hasBreakoutRooms,
userSelectedMicrophone,
userSelectedListenOnly,
meetingIsBreakout,
init,
intl,
userLocks,
microphoneConstraints,
} = props;
this.init = props.init.bind(this);
}
const prevProps = usePreviousValue(props);
const toggleVoice = useToggleVoice();
const { hasBreakoutRooms: hadBreakoutRooms } = prevProps || {};
const userIsReturningFromBreakoutRoom = hadBreakoutRooms && !hasBreakoutRooms;
componentDidMount() {
const { meetingIsBreakout } = this.props;
this.init().then(() => {
if (meetingIsBreakout && !Service.isUsingAudio()) {
this.joinAudio();
}
});
}
componentDidUpdate(prevProps) {
if (this.userIsReturningFromBreakoutRoom(prevProps)) {
this.joinAudio();
}
}
/**
* Helper function to determine wheter user is returning from breakout room
* to main room.
* @param {Object} prevProps prevProps param from componentDidUpdate
* @return {boolean} True if user is returning from breakout room
* to main room. False, otherwise.
*/
userIsReturningFromBreakoutRoom(prevProps) {
const { hasBreakoutRooms } = this.props;
const { hasBreakoutRooms: hadBreakoutRooms } = prevProps;
return hadBreakoutRooms && !hasBreakoutRooms;
}
/**
* Helper function that join (or not) user in audio. If user previously
* selected microphone, it will automatically join mic (without audio modal).
* If user previously selected listen only option in audio modal, then it will
* automatically join listen only.
*/
joinAudio() {
const joinAudio = () => {
if (Service.isConnected()) return;
const {
userSelectedMicrophone,
userSelectedListenOnly,
} = this.props;
if (userSelectedMicrophone) {
joinMicrophone(true);
return;
}
if (userSelectedListenOnly) joinListenOnly();
};
useEffect(() => {
init(toggleVoice).then(() => {
if (meetingIsBreakout && !Service.isUsingAudio()) {
joinAudio();
}
});
}, []);
useEffect(() => {
if (userIsReturningFromBreakoutRoom) {
joinAudio();
}
}, [userIsReturningFromBreakoutRoom]);
if (Service.isConnected() && !Service.isListenOnly()) {
Service.updateAudioConstraints(microphoneConstraints);
if (userLocks.userMic && !Service.isMuted()) {
Service.toggleMuteMicrophone(toggleVoice);
notify(intl.formatMessage(intlMessages.reconectingAsListener), 'info', 'volume_level_2');
}
}
render() {
const { isAudioModalOpen, setAudioModalIsOpen,
setVideoPreviewModalIsOpen, isVideoPreviewModalOpen } = this.props;
return <>
{isAudioModalOpen ? <AudioModalContainer
{...{
priority: "low",
setIsOpen: setAudioModalIsOpen,
isOpen: isAudioModalOpen
}}
/> : null}
{isVideoPreviewModalOpen ? <VideoPreviewContainer
{...{
callbackToClose: () => {
setVideoPreviewModalIsOpen(false);
},
priority: "low",
setIsOpen: setVideoPreviewModalIsOpen,
isOpen: isVideoPreviewModalOpen
}}
/> : null}
</>;
}
}
return (
<>
{isAudioModalOpen ? (
<AudioModalContainer
{...{
priority: 'low',
setIsOpen: setAudioModalIsOpen,
isOpen: isAudioModalOpen,
}}
/>
) : null}
{isVideoPreviewModalOpen ? (
<VideoPreviewContainer
{...{
callbackToClose: () => {
setVideoPreviewModalIsOpen(false);
},
priority: 'low',
setIsOpen: setVideoPreviewModalIsOpen,
isOpen: isVideoPreviewModalOpen,
}}
/>
) : null}
</>
);
};
let didMountAutoJoin = false;
@ -183,13 +183,14 @@ const messages = {
},
};
export default lockContextContainer(injectIntl(withTracker(({ intl, userLocks, isAudioModalOpen, setAudioModalIsOpen,
setVideoPreviewModalIsOpen, isVideoPreviewModalOpen }) => {
export default lockContextContainer(injectIntl(withTracker(({
intl, userLocks, isAudioModalOpen, setAudioModalIsOpen, setVideoPreviewModalIsOpen,
}) => {
const { microphoneConstraints } = Settings.application;
const autoJoin = getFromUserSettings('bbb_auto_join_audio', APP_CONFIG.autoJoin);
const enableVideo = getFromUserSettings('bbb_enable_video', KURENTO_CONFIG.enableVideo);
const autoShareWebcam = getFromUserSettings('bbb_auto_share_webcam', KURENTO_CONFIG.autoShareWebcam);
const { userWebcam, userMic } = userLocks;
const { userWebcam } = userLocks;
const userSelectedMicrophone = didUserSelectedMicrophone();
const userSelectedListenOnly = didUserSelectedListenOnly();
@ -202,19 +203,9 @@ export default lockContextContainer(injectIntl(withTracker(({ intl, userLocks, i
setVideoPreviewModalIsOpen(true);
};
const toggleVoice = useToggleVoice();
if (Service.isConnected() && !Service.isListenOnly()) {
Service.updateAudioConstraints(microphoneConstraints);
if (userMic && !Service.isMuted()) {
Service.toggleMuteMicrophone(toggleVoice);
notify(intl.formatMessage(intlMessages.reconectingAsListener), 'info', 'volume_level_2');
}
}
const breakoutUserIsIn = BreakoutsService.getBreakoutUserIsIn(Auth.userID);
if(!!breakoutUserIsIn && !meetingIsBreakout) {
const userBreakout = Breakouts.find({id: breakoutUserIsIn.id})
if (!!breakoutUserIsIn && !meetingIsBreakout) {
const userBreakout = Breakouts.find({ id: breakoutUserIsIn.id });
userBreakout.observeChanges({
removed() {
// if the user joined a breakout room, the main room's audio was
@ -227,14 +218,14 @@ export default lockContextContainer(injectIntl(withTracker(({ intl, userLocks, i
openVideoPreviewModal();
}
return;
return;
}
setTimeout(() => {
openAudioModal();
if (enableVideo && autoShareWebcam) {
openVideoPreviewModal();
}
}, 0);
openAudioModal();
if (enableVideo && autoShareWebcam) {
openVideoPreviewModal();
}
}, 0);
},
});
}
@ -244,12 +235,11 @@ export default lockContextContainer(injectIntl(withTracker(({ intl, userLocks, i
meetingIsBreakout,
userSelectedMicrophone,
userSelectedListenOnly,
isAudioModalOpen,
isAudioModalOpen,
setAudioModalIsOpen,
init: async () => {
microphoneConstraints,
init: async (toggleVoice) => {
await Service.init(messages, intl, toggleVoice);
const enableVideo = getFromUserSettings('bbb_enable_video', KURENTO_CONFIG.enableVideo);
const autoShareWebcam = getFromUserSettings('bbb_auto_share_webcam', KURENTO_CONFIG.autoShareWebcam);
if ((!autoJoin || didMountAutoJoin)) {
if (enableVideo && autoShareWebcam) {
openVideoPreviewModal();
@ -258,8 +248,8 @@ export default lockContextContainer(injectIntl(withTracker(({ intl, userLocks, i
}
Session.set('audioModalIsOpen', true);
if (enableVideo && autoShareWebcam) {
openAudioModal()
openVideoPreviewModal();
openAudioModal();
openVideoPreviewModal();
didMountAutoJoin = true;
} else if (!(
userSelectedMicrophone
@ -273,9 +263,25 @@ export default lockContextContainer(injectIntl(withTracker(({ intl, userLocks, i
};
})(AudioContainer)));
AudioContainer.defaultProps = {
microphoneConstraints: undefined,
};
AudioContainer.propTypes = {
hasBreakoutRooms: PropTypes.bool.isRequired,
meetingIsBreakout: PropTypes.bool.isRequired,
userSelectedListenOnly: PropTypes.bool.isRequired,
userSelectedMicrophone: PropTypes.bool.isRequired,
isAudioModalOpen: PropTypes.bool.isRequired,
setAudioModalIsOpen: PropTypes.func.isRequired,
setVideoPreviewModalIsOpen: PropTypes.func.isRequired,
init: PropTypes.func.isRequired,
isVideoPreviewModalOpen: PropTypes.bool.isRequired,
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
userLocks: PropTypes.shape({
userMic: PropTypes.bool.isRequired,
}).isRequired,
microphoneConstraints: PropTypes.shape({}),
};

View File

@ -56,7 +56,7 @@ const ManageRoomLabel: React.FC<ManageRoomLabelProps> = ({
/>
) : (
<Styled.AssignBtns
random
$random
data-test="randomlyAssign"
label={intl.formatMessage(intlMessages.randomlyAssign)}
aria-describedby="randomlyAssignDesc"

View File

@ -246,7 +246,7 @@ const AssignBtns = styled(Button)`
white-space: nowrap;
margin-bottom: 0.5rem;
${({ random }) => random && `
${({ $random }) => $random && `
color: ${colorPrimary};
`}
`;

View File

@ -1,6 +1,6 @@
import styled, { css, keyframes } from 'styled-components';
import {
mdPaddingX,
smPaddingX,
borderSize,
listItemBgHover, borderSizeSmall,
borderRadius,
@ -14,7 +14,7 @@ import {
colorWhite,
colorGrayLighter,
colorGrayLightest,
colorBlueLight
colorBlueLight,
} from '/imports/ui/stylesheets/styled-components/palette';
import {
headingsFontWeight,
@ -91,7 +91,7 @@ const ellipsis = keyframes`
to {
width: 1.5em;
}
`
`;
const ConnectingAnimation = styled.span`
&:after {
@ -224,7 +224,7 @@ const Panel = styled(ScrollboxVertical)`
radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%;
background-color: #fff;
padding: ${mdPaddingX};
padding: ${smPaddingX};
display: flex;
flex-grow: 1;
flex-direction: column;

View File

@ -1,14 +1,11 @@
import styled from 'styled-components';
import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
import {
mdPaddingX,
mdPaddingY,
} from '/imports/ui/stylesheets/styled-components/general';
import { smPaddingX } from '/imports/ui/stylesheets/styled-components/general';
import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
const Captions = styled.div`
background-color: ${colorWhite};
padding: ${mdPaddingY} ${mdPaddingY} ${mdPaddingX} ${mdPaddingX};
padding: ${smPaddingX};
display: flex;
flex-grow: 1;
flex-direction: column;

View File

@ -1,8 +1,6 @@
import styled from 'styled-components';
import {
smPaddingX,
mdPaddingX,
mdPaddingY,
borderRadius,
} from '/imports/ui/stylesheets/styled-components/general';
import { ScrollboxVertical } from '/imports/ui/stylesheets/styled-components/scrollable';
@ -17,18 +15,7 @@ export const MessageListWrapper = styled.div`
position: relative;
overflow-x: hidden;
overflow-y: auto;
padding-left: ${smPaddingX};
margin-left: calc(-1 * ${mdPaddingX});
padding-right: ${smPaddingX};
margin-right: calc(-1 * ${mdPaddingY});
padding-bottom: ${mdPaddingX};
z-index: 2;
[dir='rtl'] & {
padding-right: ${mdPaddingX};
margin-right: calc(-1 * ${mdPaddingX});
padding-left: ${mdPaddingY};
margin-left: calc(-1 * ${mdPaddingX});
}
`;
export const MessageList = styled(ScrollboxVertical)`

View File

@ -1,7 +1,7 @@
import styled from 'styled-components';
import { colorWhite, colorPrimary } from '/imports/ui/stylesheets/styled-components/palette';
import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
import { mdPaddingX } from '/imports/ui/stylesheets/styled-components/general';
import { smPaddingX } from '/imports/ui/stylesheets/styled-components/general';
interface ChatProps {
isChrome: boolean;
@ -9,7 +9,7 @@ interface ChatProps {
export const Chat = styled.div<ChatProps>`
background-color: ${colorWhite};
padding: ${mdPaddingX};
padding: ${smPaddingX};
padding-bottom: 0;
display: flex;
flex-grow: 1;

View File

@ -77,7 +77,17 @@ class Tooltip extends Component {
animation: animations ? DEFAULT_ANIMATION : ANIMATION_NONE,
appendTo: document.body,
arrow: roundArrow,
boundary: 'window',
popperOptions: {
modifiers: [
{
name: 'preventOverflow',
options: {
altAxis: true,
boundary: document.documentElement,
},
},
],
},
content: title,
delay: overrideDelay,
duration: animations ? ANIMATION_DURATION : 0,
@ -89,7 +99,6 @@ class Tooltip extends Component {
placement: overridePlacement,
touch: ['hold', 1000],
theme: 'bbbtip',
multiple: false,
};
this.tooltip = Tippy(`#${this.tippySelectorId}`, options);
}

View File

@ -0,0 +1,63 @@
import React, { useEffect } from 'react';
import { useMutation } from '@apollo/client';
import { GenericComponent } from 'bigbluebutton-html-plugin-sdk';
import * as Styled from './styles';
import { GenericComponentProps } from './types';
import { EXTERNAL_VIDEO_STOP } from '../external-video-player/mutations';
import NotesService from '/imports/ui/components/notes/service';
import GenericComponentItem from './generic-component-item/component';
import { screenshareHasEnded } from '../screenshare/service';
const mapGenericComponentItems = (
genericComponents: GenericComponent[],
) => genericComponents.map((genericComponent) => (
<GenericComponentItem
key={genericComponent.id}
renderFunction={genericComponent.contentFunction}
/>
));
const GenericComponentContent: React.FC<GenericComponentProps> = ({
isResizing,
genericComponent,
renderFunctionComponents,
hasExternalVideoOnLayout,
isSharedNotesPinned,
hasScreenShareOnLayout,
}) => {
const [stopExternalVideoShare] = useMutation(EXTERNAL_VIDEO_STOP);
const {
height,
width,
top,
left,
right,
} = genericComponent;
const isMinimized = width === 0 && height === 0;
useEffect(() => {
if (hasExternalVideoOnLayout) stopExternalVideoShare();
if (isSharedNotesPinned) NotesService.pinSharedNotes(false);
if (hasScreenShareOnLayout) screenshareHasEnded();
}, []);
return (
<Styled.Container
style={{
height,
width,
top,
left,
right,
}}
isResizing={isResizing}
isMinimized={isMinimized}
>
{mapGenericComponentItems(renderFunctionComponents)}
</Styled.Container>
);
};
export default GenericComponentContent;

View File

@ -0,0 +1,71 @@
import React, { useContext, useEffect } from 'react';
import * as PluginSdk from 'bigbluebutton-html-plugin-sdk';
import {
layoutDispatch,
layoutSelectInput,
layoutSelectOutput,
} from '../layout/context';
import {
GenericComponent as GenericComponentFromLayout,
Input,
Output,
} from '../layout/layoutTypes';
import { PluginsContext } from '../components-data/plugin-context/context';
import { GenericComponentContainerProps } from './types';
import { ACTIONS } from '../layout/enums';
import GenericComponentContent from './component';
const GenericComponentContainer: React.FC<GenericComponentContainerProps> = (props: GenericComponentContainerProps) => {
const {
shouldShowScreenshare,
shouldShowSharedNotes,
shouldShowExternalVideo,
} = props;
const hasExternalVideoOnLayout: boolean = layoutSelectInput((i: Input) => i.externalVideo.hasExternalVideo);
const hasScreenShareOnLayout: boolean = layoutSelectInput((i: Input) => i.screenShare.hasScreenShare);
const isSharedNotesPinned: boolean = layoutSelectInput((i: Input) => i.sharedNotes.isPinned);
const {
pluginsExtensibleAreasAggregatedState,
} = useContext(PluginsContext);
let genericComponentExtensibleArea = [] as PluginSdk.GenericComponent[];
if (pluginsExtensibleAreasAggregatedState.genericComponents) {
genericComponentExtensibleArea = [
...pluginsExtensibleAreasAggregatedState.genericComponents as PluginSdk.GenericComponent[],
];
}
useEffect(() => {
if (shouldShowScreenshare || shouldShowSharedNotes || shouldShowExternalVideo) {
layoutContextDispatch({
type: ACTIONS.SET_HAS_GENERIC_COMPONENT,
value: false,
});
}
}, [
shouldShowScreenshare,
shouldShowSharedNotes,
shouldShowExternalVideo,
]);
const genericComponent: GenericComponentFromLayout = layoutSelectOutput((i: Output) => i.genericComponent);
const hasGenericComponentOnLayout: boolean = layoutSelectInput((i: Input) => i.genericComponent.hasGenericComponent);
const cameraDock = layoutSelectInput((i: Input) => i.cameraDock);
const { isResizing } = cameraDock;
const layoutContextDispatch = layoutDispatch();
if (!hasGenericComponentOnLayout || !genericComponentExtensibleArea) return null;
return (
<GenericComponentContent
hasExternalVideoOnLayout={hasExternalVideoOnLayout}
isSharedNotesPinned={isSharedNotesPinned}
hasScreenShareOnLayout={hasScreenShareOnLayout}
renderFunctionComponents={genericComponentExtensibleArea}
isResizing={isResizing}
genericComponent={genericComponent}
/>
);
};
export default GenericComponentContainer;

View File

@ -0,0 +1,26 @@
import React, { useEffect, useRef } from 'react';
import { GenericComponentItemProps } from './types';
const GenericComponentItem: React.FC<GenericComponentItemProps> = (props) => {
const {
renderFunction,
} = props;
const elementRef = useRef(null);
useEffect(() => {
if (elementRef.current && renderFunction) {
renderFunction(elementRef.current);
}
}, [elementRef]);
return (
<div
style={{
overflow: 'hidden',
}}
ref={elementRef}
/>
);
};
export default GenericComponentItem;

View File

@ -0,0 +1,3 @@
export interface GenericComponentItemProps {
renderFunction: (element: HTMLElement) => void;
}

View File

@ -0,0 +1,25 @@
import styled from 'styled-components';
type ContainerProps = {
isResizing: boolean;
isMinimized: boolean;
};
export const Container = styled.div<ContainerProps>`
position: absolute;
pointer-events: inherit;
background: var(--color-black);
z-index: 5;
display: grid;
grid-template-columns: 1fr 1fr;
${({ isResizing }) => isResizing && `
pointer-events: none;
`}
${({ isMinimized }) => isMinimized && `
display: none;
`}
`;
export default {
Container,
};

View File

@ -0,0 +1,17 @@
import { GenericComponent } from 'bigbluebutton-html-plugin-sdk';
import { GenericComponent as GenericComponentLayout } from '../layout/layoutTypes';
export interface GenericComponentContainerProps {
shouldShowScreenshare: boolean ;
shouldShowSharedNotes: boolean ;
shouldShowExternalVideo: boolean ;
}
export interface GenericComponentProps {
isResizing: boolean;
genericComponent: GenericComponentLayout;
renderFunctionComponents: GenericComponent[];
hasExternalVideoOnLayout: boolean;
isSharedNotesPinned: boolean;
hasScreenShareOnLayout: boolean;
}

View File

@ -1185,6 +1185,56 @@ const reducer = (state, action) => {
};
}
// GENERIC COMPONENT
case ACTIONS.SET_HAS_GENERIC_COMPONENT: {
const { genericComponent } = state.input;
if (genericComponent.hasGenericComponent === action.value) {
return state;
}
return {
...state,
input: {
...state.input,
genericComponent: {
...genericComponent,
hasGenericComponent: action.value,
},
},
};
}
case ACTIONS.SET_GENERIC_COMPONENT_OUTPUT: {
const {
width,
height,
top,
left,
right,
} = action.value;
const { genericComponent } = state.output;
if (genericComponent.width === width
&& genericComponent.height === height
&& genericComponent.top === top
&& genericComponent.left === left
&& genericComponent.right === right) {
return state;
}
return {
...state,
output: {
...state.output,
genericComponent: {
...genericComponent,
width,
height,
top,
left,
right,
},
},
};
}
// NOTES
case ACTIONS.SET_SHARED_NOTES_OUTPUT: {
const {

View File

@ -107,6 +107,9 @@ export const ACTIONS = {
SET_EXTERNAL_VIDEO_SIZE: 'setExternalVideoSize',
SET_EXTERNAL_VIDEO_OUTPUT: 'setExternalVideoOutput',
SET_HAS_GENERIC_COMPONENT: 'setHasGenericComponent',
SET_GENERIC_COMPONENT_OUTPUT: 'setGenericComponentOutput',
SET_SHARED_NOTES_OUTPUT: 'setSharedNotesOutput',
SET_NOTES_IS_PINNED: 'setNotesIsPinned',
};

View File

@ -95,6 +95,13 @@ export const INITIAL_INPUT_STATE = {
browserWidth: 0,
browserHeight: 0,
},
genericComponent: {
hasGenericComponent: false,
width: 0,
height: 0,
browserWidth: 0,
browserHeight: 0,
},
sharedNotes: {
isPinned: false,
width: 0,
@ -234,6 +241,15 @@ export const INITIAL_OUTPUT_STATE = {
tabOrder: 0,
zIndex: 1,
},
genericComponent: {
display: false,
width: 0,
height: 0,
top: 0,
left: 0,
tabOrder: 0,
zIndex: 1,
},
sharedNotes: {
display: false,
width: 0,

View File

@ -296,6 +296,18 @@ const CamerasOnlyLayout = (props) => {
},
});
layoutContextDispatch({
type: ACTIONS.SET_GENERIC_COMPONENT_OUTPUT,
value: {
display: false,
width: mediaBounds.width,
height: mediaBounds.height,
top: mediaBounds.top,
left: mediaBounds.left,
right: isRTL ? mediaBounds.right : null,
},
});
layoutContextDispatch({
type: ACTIONS.SET_SHARED_NOTES_OUTPUT,
value: {
@ -353,6 +365,9 @@ const CamerasOnlyLayout = (props) => {
externalVideo: {
hasExternalVideo: false,
},
genericComponent: {
hasGenericComponent: false,
},
screenShare: {
hasScreenShare: false,
},

View File

@ -33,6 +33,7 @@ const CustomLayout = (props) => {
const presentationInput = layoutSelectInput((i) => i.presentation);
const externalVideoInput = layoutSelectInput((i) => i.externalVideo);
const genericComponentInput = layoutSelectInput((i) => i.genericComponent);
const screenShareInput = layoutSelectInput((i) => i.screenShare);
const sharedNotesInput = layoutSelectInput((i) => i.sharedNotes);
@ -163,6 +164,9 @@ const CustomLayout = (props) => {
externalVideo: {
hasExternalVideo: input.externalVideo.hasExternalVideo,
},
genericComponent: {
hasGenericComponent: input.genericComponent.hasGenericComponent
},
screenShare: {
hasScreenShare: input.screenShare.hasScreenShare,
width: input.screenShare.width,
@ -204,6 +208,9 @@ const CustomLayout = (props) => {
externalVideo: {
hasExternalVideo: input.externalVideo.hasExternalVideo,
},
genericComponent: {
hasGenericComponent: input.genericComponent.hasGenericComponent
},
screenShare: {
hasScreenShare: input.screenShare.hasScreenShare,
width: input.screenShare.width,
@ -224,12 +231,14 @@ const CustomLayout = (props) => {
const calculatesSidebarContentHeight = (cameraDockHeight) => {
const { isOpen, slidesLength } = presentationInput;
const { hasExternalVideo } = externalVideoInput;
const { hasGenericComponent } = genericComponentInput;
const { hasScreenShare } = screenShareInput;
const { isPinned: isSharedNotesPinned } = sharedNotesInput;
const hasPresentation = isPresentationEnabled() && slidesLength !== 0;
const isGeneralMediaOff =
!hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned;
!hasPresentation && !hasExternalVideo
&& !hasScreenShare && !isSharedNotesPinned && !hasGenericComponent;
let sidebarContentHeight = 0;
if (sidebarContentInput.isOpen) {
@ -401,6 +410,7 @@ const CustomLayout = (props) => {
const calculatesMediaBounds = (sidebarNavWidth, sidebarContentWidth, cameraDockBounds) => {
const { isOpen, slidesLength } = presentationInput;
const { hasExternalVideo } = externalVideoInput;
const { hasGenericComponent } = genericComponentInput;
const { hasScreenShare } = screenShareInput;
const { isPinned: isSharedNotesPinned } = sharedNotesInput;
@ -415,7 +425,8 @@ const CustomLayout = (props) => {
const hasPresentation = isPresentationEnabled() && slidesLength !== 0;
const isGeneralMediaOff =
!hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned;
!hasPresentation && !hasExternalVideo &&
!hasScreenShare && !isSharedNotesPinned && !hasGenericComponent;
if (!isOpen || isGeneralMediaOff) {
mediaBounds.width = 0;
@ -430,7 +441,8 @@ const CustomLayout = (props) => {
if (
fullscreenElement === 'Presentation' ||
fullscreenElement === 'Screenshare' ||
fullscreenElement === 'ExternalVideo'
fullscreenElement === 'ExternalVideo' ||
fullscreenElement === 'GenericComponent'
) {
mediaBounds.width = windowWidth();
mediaBounds.height = windowHeight();
@ -736,6 +748,17 @@ const CustomLayout = (props) => {
right: isRTL ? mediaBounds.right + horizontalCameraDiff : null,
},
});
layoutContextDispatch({
type: ACTIONS.SET_GENERIC_COMPONENT_OUTPUT,
value: {
width: mediaBounds.width,
height: mediaBounds.height,
top: mediaBounds.top,
left: mediaBounds.left,
right: isRTL ? mediaBounds.right + horizontalCameraDiff : null,
},
});
layoutContextDispatch({
type: ACTIONS.SET_SHARED_NOTES_OUTPUT,

View File

@ -27,6 +27,7 @@ const LayoutEngine = ({ layoutType }) => {
const sidebarNavigationInput = layoutSelectInput((i) => i.sidebarNavigation);
const sidebarContentInput = layoutSelectInput((i) => i.sidebarContent);
const externalVideoInput = layoutSelectInput((i) => i.externalVideo);
const genericComponentInput = layoutSelectInput((i) => i.genericComponent);
const screenShareInput = layoutSelectInput((i) => i.screenShare);
const sharedNotesInput = layoutSelectInput((i) => i.sharedNotes);
@ -54,6 +55,7 @@ const LayoutEngine = ({ layoutType }) => {
const baseCameraDockBounds = (mediaAreaBounds, sidebarSize) => {
const { isOpen, slidesLength } = presentationInput;
const { hasExternalVideo } = externalVideoInput;
const { hasGenericComponent } = genericComponentInput;
const { hasScreenShare } = screenShareInput;
const { isPinned: isSharedNotesPinned } = sharedNotesInput;
@ -69,7 +71,8 @@ const LayoutEngine = ({ layoutType }) => {
const navBarHeight = calculatesNavbarHeight();
const hasPresentation = isPresentationEnabled() && slidesLength !== 0;
const isGeneralMediaOff = !hasPresentation
&& !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned;
&& !hasExternalVideo && !hasScreenShare
&& !isSharedNotesPinned && !hasGenericComponent;
if (!isOpen || isGeneralMediaOff) {
cameraDockBounds.width = mediaAreaBounds.width;

View File

@ -111,6 +111,7 @@ const ParticipantsAndChatOnlyLayout = (props) => {
fullscreenElement === 'Presentation'
|| fullscreenElement === 'Screenshare'
|| fullscreenElement === 'ExternalVideo'
|| fullscreenElement === 'GenericComponent'
) {
mediaBounds.width = windowWidth();
mediaBounds.height = windowHeight();
@ -334,6 +335,18 @@ const ParticipantsAndChatOnlyLayout = (props) => {
},
});
layoutContextDispatch({
type: ACTIONS.SET_GENERIC_COMPONENT_OUTPUT,
value: {
display: false,
width: 0,
height: 0,
top: mediaBounds.top,
left: mediaBounds.left,
right: mediaBounds.right,
},
});
layoutContextDispatch({
type: ACTIONS.SET_SHARED_NOTES_OUTPUT,
value: {
@ -396,6 +409,11 @@ const ParticipantsAndChatOnlyLayout = (props) => {
width: 0,
height: 0,
},
genericComponent: {
hasGenericComponent: false,
width: 0,
height: 0,
},
screenShare: {
hasScreenShare: false,
width: 0,

View File

@ -36,6 +36,7 @@ const PresentationFocusLayout = (props) => {
const presentationInput = layoutSelectInput((i) => i.presentation);
const externalVideoInput = layoutSelectInput((i) => i.externalVideo);
const genericComponentInput = layoutSelectInput((i) => i.genericComponent);
const screenShareInput = layoutSelectInput((i) => i.screenShare);
const sharedNotesInput = layoutSelectInput((i) => i.sharedNotes);
@ -106,6 +107,9 @@ const PresentationFocusLayout = (props) => {
externalVideo: {
hasExternalVideo: input.externalVideo.hasExternalVideo,
},
genericComponent: {
hasGenericComponent: input.genericComponent.hasGenericComponent,
},
screenShare: {
hasScreenShare: input.screenShare.hasScreenShare,
width: input.screenShare.width,
@ -144,6 +148,9 @@ const PresentationFocusLayout = (props) => {
externalVideo: {
hasExternalVideo: input.externalVideo.hasExternalVideo,
},
genericComponent: {
hasGenericComponent: input.genericComponent.hasGenericComponent,
},
screenShare: {
hasScreenShare: input.screenShare.hasScreenShare,
width: input.screenShare.width,
@ -161,12 +168,14 @@ const PresentationFocusLayout = (props) => {
const calculatesSidebarContentHeight = () => {
const { isOpen, slidesLength } = presentationInput;
const { hasExternalVideo } = externalVideoInput;
const { hasGenericComponent } = genericComponentInput;
const { hasScreenShare } = screenShareInput;
const { isPinned: isSharedNotesPinned } = sharedNotesInput;
const hasPresentation = isPresentationEnabled() && slidesLength !== 0;
const isGeneralMediaOff =
!hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned;
!hasPresentation && !hasExternalVideo &&
!hasScreenShare && !isSharedNotesPinned && !hasGenericComponent;
const { navBarHeight, sidebarContentMinHeight } = DEFAULT_VALUES;
let height = 0;
@ -267,7 +276,8 @@ const PresentationFocusLayout = (props) => {
if (
fullscreenElement === 'Presentation' ||
fullscreenElement === 'Screenshare' ||
fullscreenElement === 'ExternalVideo'
fullscreenElement === 'ExternalVideo' ||
fullscreenElement === 'GenericComponent'
) {
mediaBounds.width = windowWidth();
mediaBounds.height = windowHeight();
@ -496,6 +506,17 @@ const PresentationFocusLayout = (props) => {
},
});
layoutContextDispatch({
type: ACTIONS.SET_GENERIC_COMPONENT_OUTPUT,
value: {
width: isOpen ? mediaBounds.width : 0,
height: isOpen ? mediaBounds.height : 0,
top: mediaBounds.top,
left: mediaBounds.left,
right: mediaBounds.right,
},
});
layoutContextDispatch({
type: ACTIONS.SET_SHARED_NOTES_OUTPUT,
value: {

View File

@ -47,6 +47,7 @@ const PresentationOnlyLayout = (props) => {
fullscreenElement === 'Presentation'
|| fullscreenElement === 'Screenshare'
|| fullscreenElement === 'ExternalVideo'
|| fullscreenElement === 'GenericComponent'
) {
mediaBounds.width = windowWidth();
mediaBounds.height = windowHeight();
@ -287,6 +288,17 @@ const PresentationOnlyLayout = (props) => {
},
});
layoutContextDispatch({
type: ACTIONS.SET_GENERIC_COMPONENT_OUTPUT,
value: {
width: isOpen ? mediaBounds.width : 0,
height: isOpen ? mediaBounds.height : 0,
top: mediaBounds.top,
left: mediaBounds.left,
right: mediaBounds.right,
},
});
layoutContextDispatch({
type: ACTIONS.SET_SHARED_NOTES_OUTPUT,
value: {
@ -345,6 +357,9 @@ const PresentationOnlyLayout = (props) => {
externalVideo: {
hasExternalVideo: input.externalVideo.hasExternalVideo,
},
genericComponent: {
hasGenericComponent: input.genericComponent.hasGenericComponent,
},
screenShare: {
hasScreenShare: input.screenShare.hasScreenShare,
width: input.screenShare.width,

View File

@ -35,6 +35,7 @@ const SmartLayout = (props) => {
const actionbarInput = layoutSelectInput((i) => i.actionBar);
const navbarInput = layoutSelectInput((i) => i.navBar);
const externalVideoInput = layoutSelectInput((i) => i.externalVideo);
const genericComponentInput = layoutSelectInput((i) => i.genericComponent);
const screenShareInput = layoutSelectInput((i) => i.screenShare);
const sharedNotesInput = layoutSelectInput((i) => i.sharedNotes);
const layoutContextDispatch = layoutDispatch();
@ -102,6 +103,9 @@ const SmartLayout = (props) => {
externalVideo: {
hasExternalVideo: externalVideoInput.hasExternalVideo,
},
genericComponent: {
hasGenericComponent: genericComponentInput.hasGenericComponent,
},
screenShare: {
hasScreenShare: screenShareInput.hasScreenShare,
width: screenShareInput.width,
@ -143,6 +147,9 @@ const SmartLayout = (props) => {
externalVideo: {
hasExternalVideo: externalVideoInput.hasExternalVideo,
},
genericComponent: {
hasGenericComponent: genericComponentInput.hasGenericComponent,
},
screenShare: {
hasScreenShare: screenShareInput.hasScreenShare,
width: screenShareInput.width,
@ -281,12 +288,14 @@ const SmartLayout = (props) => {
const calculatesMediaBounds = (mediaAreaBounds, slideSize, sidebarSize, screenShareSize) => {
const { isOpen, slidesLength } = presentationInput;
const { hasExternalVideo } = externalVideoInput;
const { hasGenericComponent } = genericComponentInput;
const { hasScreenShare } = screenShareInput;
const { isPinned: isSharedNotesPinned } = sharedNotesInput;
const hasPresentation = isPresentationEnabled() && slidesLength !== 0;
const isGeneralMediaOff =
!hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned;
!hasPresentation && !hasExternalVideo &&
!hasScreenShare && !isSharedNotesPinned && !hasGenericComponent;
const mediaBounds = {};
const { element: fullscreenElement } = fullscreen;
@ -304,7 +313,8 @@ const SmartLayout = (props) => {
if (
fullscreenElement === 'Presentation' ||
fullscreenElement === 'Screenshare' ||
fullscreenElement === 'ExternalVideo'
fullscreenElement === 'ExternalVideo' ||
fullscreenElement === 'GenericComponent'
) {
mediaBounds.width = windowWidth();
mediaBounds.height = windowHeight();
@ -318,7 +328,8 @@ const SmartLayout = (props) => {
const mediaContentSize = hasScreenShare ? screenShareSize : slideSize;
if (cameraDockInput.numCameras > 0 && !cameraDockInput.isDragging) {
if (mediaContentSize.width !== 0 && mediaContentSize.height !== 0 && !hasExternalVideo) {
if (mediaContentSize.width !== 0 && mediaContentSize.height !== 0
&& !hasExternalVideo && !hasGenericComponent) {
if (mediaContentSize.width < mediaAreaBounds.width && !isMobile) {
if (mediaContentSize.width < mediaAreaBounds.width * 0.8) {
mediaBounds.width = mediaContentSize.width;
@ -567,6 +578,17 @@ const SmartLayout = (props) => {
right: isRTL ? mediaBounds.right + horizontalCameraDiff : null,
},
});
layoutContextDispatch({
type: ACTIONS.SET_GENERIC_COMPONENT_OUTPUT,
value: {
width: mediaBounds.width,
height: mediaBounds.height,
top: mediaBounds.top,
left: mediaBounds.left,
right: isRTL ? mediaBounds.right + horizontalCameraDiff : null,
},
});
layoutContextDispatch({
type: ACTIONS.SET_SHARED_NOTES_OUTPUT,

View File

@ -35,6 +35,7 @@ const VideoFocusLayout = (props) => {
const presentationInput = layoutSelectInput((i) => i.presentation);
const externalVideoInput = layoutSelectInput((i) => i.externalVideo);
const genericComponentInput = layoutSelectInput((i) => i.genericComponent);
const screenShareInput = layoutSelectInput((i) => i.screenShare);
const sharedNotesInput = layoutSelectInput((i) => i.sharedNotes);
@ -109,6 +110,9 @@ const VideoFocusLayout = (props) => {
externalVideo: {
hasExternalVideo: input.externalVideo.hasExternalVideo,
},
genericComponent: {
hasGenericComponent: input.genericComponent.hasGenericComponent,
},
screenShare: {
hasScreenShare: input.screenShare.hasScreenShare,
width: input.screenShare.width,
@ -147,6 +151,9 @@ const VideoFocusLayout = (props) => {
externalVideo: {
hasExternalVideo: input.externalVideo.hasExternalVideo,
},
genericComponent: {
hasGenericComponent: input.genericComponent.hasGenericComponent,
},
screenShare: {
hasScreenShare: input.screenShare.hasScreenShare,
width: input.screenShare.width,
@ -164,13 +171,15 @@ const VideoFocusLayout = (props) => {
const calculatesSidebarContentHeight = () => {
const { isOpen, slidesLength } = presentationInput;
const { hasExternalVideo } = externalVideoInput;
const { hasGenericComponent } = genericComponentInput;
const { hasScreenShare } = screenShareInput;
const { isPinned: isSharedNotesPinned } = sharedNotesInput;
const navBarHeight = calculatesNavbarHeight();
const hasPresentation = isPresentationEnabled() && slidesLength !== 0;
const isGeneralMediaOff =
!hasPresentation && !hasExternalVideo && !hasScreenShare && !isSharedNotesPinned;
!hasPresentation && !hasExternalVideo &&
!hasScreenShare && !isSharedNotesPinned && !hasGenericComponent;
let minHeight = 0;
let height = 0;
@ -262,7 +271,8 @@ const VideoFocusLayout = (props) => {
if (
fullscreenElement === 'Presentation' ||
fullscreenElement === 'Screenshare' ||
fullscreenElement === 'ExternalVideo'
fullscreenElement === 'ExternalVideo' ||
fullscreenElement === 'GenericComponent'
) {
mediaBounds.width = windowWidth();
mediaBounds.height = windowHeight();
@ -507,6 +517,17 @@ const VideoFocusLayout = (props) => {
},
});
layoutContextDispatch({
type: ACTIONS.SET_GENERIC_COMPONENT_OUTPUT,
value: {
width: mediaBounds.width,
height: mediaBounds.height,
top: mediaBounds.top,
left: mediaBounds.left,
right: isRTL ? mediaBounds.right : null,
},
});
layoutContextDispatch({
type: ACTIONS.SET_SHARED_NOTES_OUTPUT,
value: {

View File

@ -63,6 +63,20 @@ export interface ExternalVideo {
zIndex?: number;
right?: number;
}
export interface GenericComponent {
hasGenericComponent?: boolean;
browserHeight?: number;
browserWidth?: number;
height: number;
width: number;
display?: boolean;
left?: number;
tabOrder?: number;
top?: number;
zIndex?: number;
right?: number;
}
interface NavBar {
hasNavBar?: boolean;
height: number;
@ -215,7 +229,8 @@ interface Input {
browser: Browser;
cameraDock: CameraDock
customParameters: NonNullable<unknown>;
externalVideo: ExternalVideo
externalVideo: ExternalVideo;
genericComponent: GenericComponent;
navBar: NavBar;
notificationsBar: NotificationsBar;
presentation: Presentation;
@ -228,17 +243,18 @@ interface Input {
interface Output {
actionBar: ActionBar;
cameraDock: CameraDock;
captions: Captions;
dropZoneAreas: DropzoneAreas;
externalVideo: ExternalVideo;
mediaArea: Size;
navBar: NavBar;
presentation: Presentation;
screenShare: ScreenShare;
sharedNotes: SharedNotes;
sidebarContent: SidebarContent;
sidebarNavigation: SidebarNavigation;
cameraDock: CameraDock;
captions: Captions;
dropZoneAreas: DropzoneAreas;
externalVideo: ExternalVideo;
genericComponent: GenericComponent;
mediaArea: Size;
navBar: NavBar;
presentation: Presentation;
screenShare: ScreenShare;
sharedNotes: SharedNotes;
sidebarContent: SidebarContent;
sidebarNavigation: SidebarNavigation;
}
interface Layout {

View File

@ -172,9 +172,8 @@ const propTypes = {
intl: PropTypes.shape({
formatMessage: PropTypes.func.isRequired,
}).isRequired,
isModerator: PropTypes.bool.isRequired,
isModerator: PropTypes.bool,
isPresenter: PropTypes.bool.isRequired,
showToggleLabel: PropTypes.bool.isRequired,
application: PropTypes.shape({
selectedLayout: PropTypes.string.isRequired,
}).isRequired,
@ -182,6 +181,11 @@ const propTypes = {
setLocalSettings: PropTypes.func.isRequired,
};
const defaultProps = {
isModerator: false,
};
LayoutModalComponent.propTypes = propTypes;
LayoutModalComponent.defaultProps = defaultProps;
export default injectIntl(LayoutModalComponent);

View File

@ -15,20 +15,10 @@ import { layoutSelectInput, layoutSelectOutput, layoutDispatch } from '../layout
import { PluginsContext } from '/imports/ui/components/components-data/plugin-context/context';
import { PANELS } from '/imports/ui/components/layout/enums';
import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser';
import useChat from '/imports/ui/core/hooks/useChat';
const PUBLIC_CONFIG = Meteor.settings.public;
const checkUnreadMessages = ({
groupChatsMessages, groupChats, users, idChatOpen,
}) => {
const activeChats = userListService.getActiveChats({ groupChatsMessages, groupChats, users });
const hasUnreadMessages = activeChats
.filter((chat) => chat.userId !== idChatOpen)
.some((chat) => chat.unreadCounter > 0);
return hasUnreadMessages;
};
const NavBarContainer = ({ children, ...props }) => {
const usingChatContext = useContext(ChatContext);
const usingUsersContext = useContext(UsersContext);
@ -51,9 +41,12 @@ const NavBarContainer = ({ children, ...props }) => {
const { sidebarNavPanel } = sidebarNavigation;
const hasUnreadNotes = sidebarContentPanel !== PANELS.SHARED_NOTES && unread && !notesIsPinned;
const hasUnreadMessages = checkUnreadMessages(
{ groupChatsMessages, groupChats, users: users[Auth.meetingID] },
);
const { data: chats } = useChat((chat) => ({
totalUnread: chat.totalUnread,
}));
const hasUnreadMessages = chats && chats.reduce((acc, chat) => acc + chat?.totalUnread, 0) > 0;
const { data: currentUserData } = useCurrentUser((user) => ({
isModerator: user.isModerator,

View File

@ -109,7 +109,7 @@ const propTypes = {
isBreakoutRoom: PropTypes.bool,
isMeteorConnected: PropTypes.bool.isRequired,
isDropdownOpen: PropTypes.bool,
audioCaptionsEnabled: PropTypes.bool.isRequired,
audioCaptionsEnabled: PropTypes.bool,
audioCaptionsActive: PropTypes.bool.isRequired,
audioCaptionsSet: PropTypes.func.isRequired,
isMobile: PropTypes.bool.isRequired,
@ -126,6 +126,7 @@ const defaultProps = {
shortcuts: '',
isBreakoutRoom: false,
isDropdownOpen: false,
audioCaptionsEnabled: false,
};
const ALLOW_FULLSCREEN = Meteor.settings.public.app.allowFullscreen;

View File

@ -1,6 +1,6 @@
import styled from 'styled-components';
import {
mdPaddingX,
smPaddingX,
} from '/imports/ui/stylesheets/styled-components/general';
import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
import { smallOnly } from '/imports/ui/stylesheets/styled-components/breakpoints';
@ -8,7 +8,7 @@ import CommonHeader from '/imports/ui/components/common/control-header/component
const Notes = styled.div`
background-color: ${colorWhite};
padding: ${mdPaddingX};
padding: ${smPaddingX};
display: flex;
flex-grow: 1;
flex-direction: column;

View File

@ -0,0 +1,54 @@
import { useEffect, useState, useContext } from 'react';
import * as PluginSdk from 'bigbluebutton-html-plugin-sdk';
import {
ExtensibleAreaComponentManagerProps, ExtensibleArea,
ExtensibleAreaComponentManager,
} from '../../types';
import { PluginsContext } from '../../../../components-data/plugin-context/context';
const GenericComponentPluginStateContainer = ((
props: ExtensibleAreaComponentManagerProps,
) => {
const {
uuid,
generateItemWithId,
extensibleAreaMap,
pluginApi,
} = props;
const [
genericComponents,
setGenericComponents,
] = useState<PluginSdk.GenericComponentInterface[]>([]);
const {
pluginsExtensibleAreasAggregatedState,
setPluginsExtensibleAreasAggregatedState,
} = useContext(PluginsContext);
useEffect(() => {
// Change this plugin provided toolbar items
extensibleAreaMap[uuid].genericComponents = genericComponents;
// Update context with computed aggregated list of all plugin provided toolbar items
const aggregatedGenericComponents = (
[] as PluginSdk.GenericComponentInterface[]).concat(
...Object.values(extensibleAreaMap)
.map((extensibleArea: ExtensibleArea) => extensibleArea.genericComponents),
);
setPluginsExtensibleAreasAggregatedState(
{
...pluginsExtensibleAreasAggregatedState,
genericComponents: aggregatedGenericComponents,
},
);
}, [genericComponents]);
pluginApi.setGenericComponents = (items: PluginSdk.GenericComponentInterface[]) => {
const itemsWithId = items.map(generateItemWithId) as PluginSdk.GenericComponentInterface[];
return setGenericComponents(itemsWithId);
};
return null;
}) as ExtensibleAreaComponentManager;
export default GenericComponentPluginStateContainer;

View File

@ -17,6 +17,7 @@ import {
ExtensibleAreaComponentManager, ExtensibleAreaMap,
} from './types';
import FloatingWindowPluginStateContainer from './components/floating-window/manager';
import GenericComponentPluginStateContainer from './components/generic-component/manager';
const extensibleAreaMap: ExtensibleAreaMap = {};
@ -33,6 +34,7 @@ const extensibleAreaComponentManagers: ExtensibleAreaComponentManager[] = [
UserCameraDropdownPluginStateContainer,
UserListItemAdditionalInformationPluginStateContainer,
FloatingWindowPluginStateContainer,
GenericComponentPluginStateContainer,
];
function generateItemWithId<T extends PluginProvidedUiItemDescriptor>(

View File

@ -20,6 +20,7 @@ export interface ExtensibleArea {
userCameraDropdownItems: PluginSdk.UserCameraDropdownInterface[];
userListItemAdditionalInformation: PluginSdk.UserListItemAdditionalInformationInterface[];
floatingWindows: PluginSdk.FloatingWindowInterface[]
genericComponents: PluginSdk.GenericComponentInterface[]
}
/**

View File

@ -1,8 +1,10 @@
import * as React from 'react';
import PluginChatUiCommandsHandler from './chat/handler';
import PluginLayoutUiCommandsHandler from './layout/handler';
const PluginUiCommandsHandler = () => (
<>
<PluginLayoutUiCommandsHandler />
<PluginChatUiCommandsHandler />
</>
);

View File

@ -0,0 +1,60 @@
import { useEffect } from 'react';
import { Session } from 'meteor/session';
import {
LayoutCommandsEnum, LayoutComponentListEnum,
} from 'bigbluebutton-html-plugin-sdk/dist/cjs/ui-commands/layout/enums';
import { layoutDispatch } from '../../../layout/context';
import { ACTIONS } from '../../../layout/enums';
const PluginLayoutUiCommandsHandler = () => {
const layoutContextDispatch = layoutDispatch();
const handleLayoutSet = ((event: CustomEvent<LayoutComponentListEnum>) => {
const layout = event.detail;
switch (layout) {
case LayoutComponentListEnum.GENERIC_COMPONENT:
layoutContextDispatch({
type: ACTIONS.SET_HAS_GENERIC_COMPONENT,
value: true,
});
layoutContextDispatch({
type: ACTIONS.SET_PRESENTATION_IS_OPEN,
value: true,
});
break;
default:
break;
}
}) as EventListener;
const handleLayoutUnset = ((event: CustomEvent<LayoutComponentListEnum>) => {
const layout = event.detail;
switch (layout) {
case LayoutComponentListEnum.GENERIC_COMPONENT:
layoutContextDispatch({
type: ACTIONS.SET_HAS_GENERIC_COMPONENT,
value: false,
});
layoutContextDispatch({
type: ACTIONS.SET_PRESENTATION_IS_OPEN,
value: Session.get('presentationLastState'),
});
break;
default:
break;
}
}) as EventListener;
useEffect(() => {
window.addEventListener(LayoutCommandsEnum.SET, handleLayoutSet);
window.addEventListener(LayoutCommandsEnum.UNSET, handleLayoutUnset);
return () => {
window.addEventListener(LayoutCommandsEnum.SET, handleLayoutSet);
window.addEventListener(LayoutCommandsEnum.UNSET, handleLayoutUnset);
};
}, []);
return null;
};
export default PluginLayoutUiCommandsHandler;

View File

@ -906,7 +906,7 @@ export default injectIntl(Presentation);
Presentation.propTypes = {
// Defines a boolean value to detect whether a current user is a presenter
userIsPresenter: PropTypes.bool.isRequired,
userIsPresenter: PropTypes.bool,
currentSlide: PropTypes.shape({
presentationId: PropTypes.string.isRequired,
current: PropTypes.bool.isRequired,
@ -929,9 +929,9 @@ Presentation.propTypes = {
multiUser: PropTypes.bool.isRequired,
setPresentationIsOpen: PropTypes.func.isRequired,
layoutContextDispatch: PropTypes.func.isRequired,
presentationIsDownloadable: PropTypes.bool.isRequired,
presentationName: PropTypes.string.isRequired,
currentPresentationId: PropTypes.string.isRequired,
presentationIsDownloadable: PropTypes.bool,
presentationName: PropTypes.string,
currentPresentationId: PropTypes.string,
presentationIsOpen: PropTypes.bool.isRequired,
totalPages: PropTypes.number.isRequired,
publishedPoll: PropTypes.bool.isRequired,
@ -970,4 +970,8 @@ Presentation.defaultProps = {
presentationAreaSize: undefined,
presentationBounds: undefined,
downloadPresentationUri: undefined,
userIsPresenter: false,
presentationIsDownloadable: false,
currentPresentationId: '',
presentationName: '',
};

View File

@ -12,7 +12,7 @@ import Styled from './styles';
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 deviceInfo from '/imports/utils/deviceInfo';
import AppService from '/imports/ui/components/app/service';
const intlMessages = defineMessages({
@ -280,9 +280,9 @@ const PresentationMenu = (props) => {
);
}
const { isSafari } = browserInfo;
const { isIos } = deviceInfo;
if (!isSafari && allowSnapshotOfCurrentSlide) {
if (allowSnapshotOfCurrentSlide) {
menuItems.push(
{
key: 'list-item-screenshot',
@ -322,18 +322,38 @@ const PresentationMenu = (props) => {
&& shape.y >= 0,
);
const svgElem = await tldrawAPI.getSvg(shapes.map((shape) => shape.id));
const width = svgElem?.width?.baseVal?.value ?? window.screen.width;
const height = svgElem?.height?.baseVal?.value ?? window.screen.height;
const data = await toPng(svgElem, { width, height, backgroundColor: '#FFF' });
// workaround for ios
if (isIos) {
svgElem.setAttribute('width', backgroundShape.props.w);
svgElem.setAttribute('height', backgroundShape.props.h);
svgElem.setAttribute('viewBox', `1 1 ${backgroundShape.props.w} ${backgroundShape.props.h}`);
const anchor = document.createElement('a');
anchor.href = data;
anchor.setAttribute(
'download',
`${elementName}_${meetingName}_${new Date().toISOString()}.png`,
);
anchor.click();
const svgString = new XMLSerializer().serializeToString(svgElem);
const blob = new Blob([svgString], { type: 'image/svg+xml' });
const data = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = data;
anchor.setAttribute(
'download',
`${elementName}_${meetingName}_${new Date().toISOString()}.svg`,
);
anchor.click();
} else {
const width = svgElem?.width?.baseVal?.value ?? window.screen.width;
const height = svgElem?.height?.baseVal?.value ?? window.screen.height;
const data = await toPng(svgElem, { width, height, backgroundColor: '#FFF' });
const anchor = document.createElement('a');
anchor.href = data;
anchor.setAttribute(
'download',
`${elementName}_${meetingName}_${new Date().toISOString()}.png`,
);
anchor.click();
}
setState({
loading: false,

View File

@ -117,9 +117,6 @@ PresentationToolbarContainer.propTypes = {
numberOfSlides: PropTypes.number.isRequired,
// Actions required for the presenter toolbar
nextSlide: PropTypes.func.isRequired,
previousSlide: PropTypes.func.isRequired,
skipToSlide: PropTypes.func.isRequired,
layoutSwapped: PropTypes.bool,
};

View File

@ -44,13 +44,13 @@ const propTypes = {
allowDownloadConverted: PropTypes.bool.isRequired,
allowDownloadWithAnnotations: PropTypes.bool.isRequired,
item: PropTypes.shape({
id: PropTypes.string.isRequired,
filename: PropTypes.string.isRequired,
id: PropTypes.string,
filename: PropTypes.string,
filenameConverted: PropTypes.string,
isCurrent: PropTypes.bool.isRequired,
isCurrent: PropTypes.bool,
temporaryPresentationId: PropTypes.string,
isDownloadable: PropTypes.bool.isRequired,
isRemovable: PropTypes.bool.isRequired,
isDownloadable: PropTypes.bool,
isRemovable: PropTypes.bool,
conversion: PropTypes.shape({
done: PropTypes.bool,
error: PropTypes.bool,
@ -61,17 +61,21 @@ const propTypes = {
upload: PropTypes.shape({
done: PropTypes.bool,
error: PropTypes.bool,
}).isRequired,
}),
exportation: PropTypes.shape({
status: PropTypes.string,
}),
uploadTimestamp: PropTypes.string,
downloadableExtension: PropTypes.string,
}).isRequired,
}),
closeModal: PropTypes.func.isRequired,
disabled: PropTypes.bool.isRequired,
};
const defaultProps = {
item: {},
};
class PresentationDownloadDropdown extends PureComponent {
constructor(props) {
super(props);
@ -204,5 +208,6 @@ class PresentationDownloadDropdown extends PureComponent {
}
PresentationDownloadDropdown.propTypes = propTypes;
PresentationDownloadDropdown.defaultProps = defaultProps;
export default injectIntl(PresentationDownloadDropdown);

View File

@ -125,7 +125,6 @@ const PresentationContainer = styled.div`
left: 0;
right: 0;
bottom: 0;
z-index: 1;
`;
const Presentation = styled.div`

View File

@ -1,6 +1,6 @@
import styled from 'styled-components';
import { colorWhite } from '/imports/ui/stylesheets/styled-components/palette';
import { borderSize, navbarHeight } from '/imports/ui/stylesheets/styled-components/general';
import { borderSize, navbarHeight, smPaddingX } from '/imports/ui/stylesheets/styled-components/general';
import { smallOnly, mediumUp } from '/imports/ui/stylesheets/styled-components/breakpoints';
const Poll = styled.div`
@ -16,7 +16,7 @@ const Poll = styled.div`
height: 100%;
background-color: ${colorWhite};
min-width: 20em;
padding: 1rem;
padding: ${smPaddingX};
@media ${smallOnly} {
top: 0;

View File

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import Service from './service';
import injectWbResizeEvent from '/imports/ui/components/presentation/resize-wrapper/component';
import Header from '/imports/ui/components/common/control-header/component';
import Styled from './styles';
const intlMessages = defineMessages({
@ -81,7 +82,6 @@ const propTypes = {
}).isRequired,
layoutContextDispatch: PropTypes.shape().isRequired,
timeOffset: PropTypes.number.isRequired,
isRTL: PropTypes.bool.isRequired,
isActive: PropTypes.bool.isRequired,
isModerator: PropTypes.bool.isRequired,
currentTrack: PropTypes.string.isRequired,
@ -434,7 +434,6 @@ class Timer extends Component {
render() {
const {
intl,
isRTL,
isActive,
isModerator,
layoutContextDispatch,
@ -453,16 +452,13 @@ class Timer extends Component {
<Styled.TimerSidebarContent
data-test="timer"
>
<Styled.TimerHeader>
<Styled.TimerTitle>
<Styled.TimerMinimizeButton
onClick={() => Service.closePanel(layoutContextDispatch)}
aria-label={intl.formatMessage(intlMessages.hideTimerLabel)}
label={intl.formatMessage(message)}
icon={isRTL ? 'right_arrow' : 'left_arrow'}
/>
</Styled.TimerTitle>
</Styled.TimerHeader>
<Header
leftButtonProps={{
onClick: () => { Service.closePanel(layoutContextDispatch); },
'aria-label': intl.formatMessage(intlMessages.hideTimerLabel),
label: intl.formatMessage(message),
}}
/>
{this.renderContent()}
</Styled.TimerSidebarContent>
);

View File

@ -115,13 +115,9 @@ const TimerContainer = ({ children, ...props }) => {
);
};
export default withTracker(() => {
const isRTL = document.documentElement.getAttribute('dir') === 'rtl';
return {
isRTL,
isActive: Service.isActive(),
timeOffset: Service.getTimeOffset(),
timer: Service.getTimer(),
currentTrack: Service.getCurrentTrack(),
};
})(TimerContainer);
export default withTracker(() => ({
isActive: Service.isActive(),
timeOffset: Service.getTimeOffset(),
timer: Service.getTimer(),
currentTrack: Service.getCurrentTrack(),
}))(TimerContainer);

View File

@ -2,9 +2,7 @@ import styled from 'styled-components';
import {
borderSize,
borderSizeLarge,
mdPaddingX,
mdPaddingY,
pollHeaderOffset,
smPaddingX,
toastContentWidth,
borderRadius,
} from '../../stylesheets/styled-components/general';
@ -22,12 +20,7 @@ import Button from '/imports/ui/components/common/button/component';
const TimerSidebarContent = styled.div`
background-color: ${colorWhite};
padding:
${mdPaddingX}
${mdPaddingY}
${mdPaddingX}
${mdPaddingX};
padding: ${smPaddingX};
display: flex;
flex-grow: 1;
flex-direction: column;
@ -39,7 +32,6 @@ const TimerSidebarContent = styled.div`
const TimerHeader = styled.header`
position: relative;
top: ${pollHeaderOffset};
display: flex;
flex-direction: row;
align-items: center;

View File

@ -13,10 +13,22 @@ import { isChatEnabled } from '/imports/ui/services/features';
import UserTitleContainer from '../user-list-graphql/user-participants-title/component';
const propTypes = {
currentUser: PropTypes.shape({}).isRequired,
currentUser: PropTypes.shape({
role: PropTypes.string.isRequired,
presenter: PropTypes.bool.isRequired,
}),
compact: PropTypes.bool,
isTimerActive: PropTypes.bool.isRequired,
};
const defaultProps = {
currentUser: {
role: '',
presenter: false,
},
compact: false,
};
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
class UserContent extends PureComponent {
@ -46,5 +58,6 @@ class UserContent extends PureComponent {
}
UserContent.propTypes = propTypes;
UserContent.defaultProps = defaultProps;
export default UserContent;

View File

@ -1,8 +1,9 @@
import React, { useState, useContext, useEffect } from 'react';
import React, { useState, useContext } from 'react';
import { User } from '/imports/ui/Types/user';
import { LockSettings, UsersPolicies } from '/imports/ui/Types/meeting';
import { useIntl, defineMessages } from 'react-intl';
import * as PluginSdk from 'bigbluebutton-html-plugin-sdk';
import logger from '/imports/startup/client/logger';
import { UserListDropdownItemType } from 'bigbluebutton-html-plugin-sdk/dist/cjs/extensible-areas/user-list-dropdown-item/enums';
import {
SET_AWAY,
@ -65,6 +66,11 @@ interface DropdownItem {
onClick: (() => void) | undefined;
}
interface Writer {
pageId: string;
userId: string;
}
const messages = defineMessages({
statusTriggerLabel: {
id: 'app.actionsBar.emojiMenu.statusTriggerLabel',
@ -214,30 +220,39 @@ const UserActions: React.FC<UserActionsProps> = ({
const layoutContextDispatch = layoutDispatch();
const [presentationSetWriters] = useMutation(PRESENTATION_SET_WRITERS);
const [getWriters, { data: usersData }] = useLazyQuery(CURRENT_PAGE_WRITERS_QUERY, { fetchPolicy: 'no-cache' });
const writers = usersData?.pres_page_writers || null;
const [getWriters] = useLazyQuery(CURRENT_PAGE_WRITERS_QUERY, { fetchPolicy: 'no-cache' });
const voiceToggle = useToggleVoice();
// users will only be fetched when getWriters is called
useEffect(() => {
if (writers) {
changeWhiteboardAccess();
}
}, [writers]);
const handleWhiteboardAccessChange = async () => {
try {
// Fetch the writers data
const { data } = await getWriters();
const allWriters: Writer[] = data?.pres_page_writers || [];
const currentWriters = allWriters?.filter((writer: Writer) => writer.pageId === pageId);
const changeWhiteboardAccess = () => {
if (pageId) {
const { userId } = user;
const usersIds = writers.map((writer: { userId: string }) => writer.userId);
const hasAccess = writers?.some((writer: { userId: string }) => writer.userId === userId);
const newUsersIds = hasAccess ? usersIds.filter((id: string) => id !== userId) : [...usersIds, userId];
// Determine if the user has access
const { userId, presPagesWritable } = user;
const hasAccess = presPagesWritable.some(
(page: { userId: string; isCurrentPage: boolean }) => (page?.userId === userId && page?.isCurrentPage),
);
presentationSetWriters({
// Prepare the updated list of user IDs for whiteboard access
const usersIds = currentWriters?.map((writer: { userId: string }) => writer?.userId);
const newUsersIds: string[] = hasAccess
? usersIds.filter((id: string) => id !== userId)
: [...usersIds, userId];
// Update the writers
await presentationSetWriters({
variables: {
pageId,
usersIds: newUsersIds,
},
});
} catch (error) {
logger.warn({
logCode: 'user_action_whiteboard_access_failed',
}, 'Error updating whiteboard access.');
}
};
@ -285,7 +300,9 @@ const UserActions: React.FC<UserActionsProps> = ({
(item: PluginSdk.UserListDropdownInterface) => (user?.userId === item?.userId),
);
const hasWhiteboardAccess = user.presPagesWritable?.length > 0;
const hasWhiteboardAccess = user.presPagesWritable?.some(
(page: { pageId: string; userId: string }) => (page.pageId === pageId && page.userId === user.userId),
);
const [setAway] = useMutation(SET_AWAY);
const [setRole] = useMutation(SET_ROLE);
@ -434,7 +451,7 @@ const UserActions: React.FC<UserActionsProps> = ({
? intl.formatMessage(messages.removeWhiteboardAccess)
: intl.formatMessage(messages.giveWhiteboardAccess),
onClick: () => {
getWriters();
handleWhiteboardAccessChange();
setSelected(false);
},
icon: 'pen_tool',

View File

@ -1,7 +1,7 @@
import React from 'react';
import { useMutation } from '@apollo/client';
import { withTracker } from 'meteor/react-meteor-data';
import Service from './service';
import { useMutation } from '@apollo/client';
import VideoPreview from './component';
import VideoService from '../video-provider/service';
import ScreenShareService from '/imports/ui/components/screenshare/service';
@ -10,9 +10,8 @@ import { SCREENSHARING_ERRORS } from '/imports/api/screenshare/client/bridge/err
import { EXTERNAL_VIDEO_STOP } from '../external-video-player/mutations';
import { CAMERA_BROADCAST_STOP } from '../video-provider/mutations';
const VideoPreviewContainer = (props) => <VideoPreview {...props} />;
export default withTracker(({ setIsOpen, callbackToClose }) => {
const VideoPreviewContainer = (props) => {
const { buildStartSharingCameraAsContent, buildStopSharing, ...rest } = props;
const [stopExternalVideoShare] = useMutation(EXTERNAL_VIDEO_STOP);
const [cameraBroadcastStop] = useMutation(CAMERA_BROADCAST_STOP);
@ -20,58 +19,71 @@ export default withTracker(({ setIsOpen, callbackToClose }) => {
cameraBroadcastStop({ variables: { cameraId } });
};
return {
startSharing: (deviceId) => {
callbackToClose();
setIsOpen(false);
VideoService.joinVideo(deviceId);
},
startSharingCameraAsContent: (deviceId) => {
callbackToClose();
setIsOpen(false);
const handleFailure = (error) => {
const {
errorCode = SCREENSHARING_ERRORS.UNKNOWN_ERROR.errorCode,
errorMessage = error.message,
} = error;
const startSharingCameraAsContent = buildStartSharingCameraAsContent(stopExternalVideoShare);
const stopSharing = buildStopSharing(sendUserUnshareWebcam);
logger.error({
logCode: 'camera_as_content_failed',
extraInfo: { errorCode, errorMessage },
}, `Sharing camera as content failed: ${errorMessage} (code=${errorCode})`);
return (
<VideoPreview
{...{
startSharingCameraAsContent,
stopSharing,
...rest,
}}
/>
);
};
export default withTracker(({ setIsOpen, callbackToClose }) => ({
startSharing: (deviceId) => {
callbackToClose();
setIsOpen(false);
VideoService.joinVideo(deviceId);
},
buildStartSharingCameraAsContent: (stopExternalVideoShare) => (deviceId) => {
callbackToClose();
setIsOpen(false);
const handleFailure = (error) => {
const {
errorCode = SCREENSHARING_ERRORS.UNKNOWN_ERROR.errorCode,
errorMessage = error.message,
} = error;
logger.error({
logCode: 'camera_as_content_failed',
extraInfo: { errorCode, errorMessage },
}, `Sharing camera as content failed: ${errorMessage} (code=${errorCode})`);
ScreenShareService.screenshareHasEnded();
};
ScreenShareService.shareScreen(
stopExternalVideoShare,
true, handleFailure, { stream: Service.getStream(deviceId)._mediaStream }
);
ScreenShareService.setCameraAsContentDeviceId(deviceId);
},
stopSharing: (deviceId) => {
callbackToClose();
setIsOpen(false);
if (deviceId) {
const streamId = VideoService.getMyStreamId(deviceId);
if (streamId) VideoService.stopVideo(streamId, sendUserUnshareWebcam);
} else {
VideoService.exitVideo(sendUserUnshareWebcam);
}
},
stopSharingCameraAsContent: () => {
callbackToClose();
setIsOpen(false);
ScreenShareService.screenshareHasEnded();
},
sharedDevices: VideoService.getSharedDevices(),
cameraAsContentDeviceId: ScreenShareService.getCameraAsContentDeviceId(),
isCamLocked: VideoService.isUserLocked(),
camCapReached: VideoService.hasCapReached(),
closeModal: () => {
callbackToClose();
setIsOpen(false);
},
webcamDeviceId: Service.webcamDeviceId(),
hasVideoStream: VideoService.hasVideoStream(),
};
})(VideoPreviewContainer);
};
ScreenShareService.shareScreen(
stopExternalVideoShare,
true, handleFailure, { stream: Service.getStream(deviceId)._mediaStream }
);
ScreenShareService.setCameraAsContentDeviceId(deviceId);
},
buildStopSharing: (sendUserUnshareWebcam) => (deviceId) => {
callbackToClose();
setIsOpen(false);
if (deviceId) {
const streamId = VideoService.getMyStreamId(deviceId);
if (streamId) VideoService.stopVideo(streamId, sendUserUnshareWebcam);
} else {
VideoService.exitVideo(sendUserUnshareWebcam);
}
},
stopSharingCameraAsContent: () => {
callbackToClose();
setIsOpen(false);
ScreenShareService.screenshareHasEnded();
},
sharedDevices: VideoService.getSharedDevices(),
cameraAsContentDeviceId: ScreenShareService.getCameraAsContentDeviceId(),
isCamLocked: VideoService.isUserLocked(),
camCapReached: VideoService.hasCapReached(),
closeModal: () => {
callbackToClose();
setIsOpen(false);
},
webcamDeviceId: Service.webcamDeviceId(),
hasVideoStream: VideoService.hasVideoStream(),
}))(VideoPreviewContainer);

View File

@ -14,7 +14,7 @@ import {
} from '/imports/ui/stylesheets/styled-components/palette';
import {
borderSize,
mdPaddingX,
smPaddingX,
mdPaddingY,
userIndicatorsOffset,
indicatorPadding,
@ -198,8 +198,7 @@ const CustomButton = styled(Button)`
const Panel = styled.div<PanelProps>`
background-color: ${colorWhite};
padding: ${mdPaddingX} ${mdPaddingY} ${mdPaddingX} ${mdPaddingX};
padding: ${smPaddingX};
display: flex;
flex-grow: 1;
flex-direction: column;

View File

@ -38,18 +38,6 @@ const TOOLBAR_SMALL = 28;
const TOOLBAR_LARGE = 32;
const MOUNTED_RESIZE_DELAY = 1500;
// Shallow cloning with nested structures
const deepCloneUsingShallow = (obj) => {
const clonedObj = clone(obj);
if (obj.props) {
clonedObj.props = clone(obj.props);
}
if (obj.props) {
clonedObj.meta = clone(obj.meta);
}
return clonedObj;
};
// Helper functions
const deleteLocalStorageItemsWithPrefix = (prefix) => {
const keysToRemove = Object.keys(localStorage).filter((key) =>
@ -166,18 +154,37 @@ export default Whiteboard = React.memo(function Whiteboard(props) {
const isMouseDownRef = useRef(false);
const isMountedRef = useRef(false);
const isWheelZoomRef = useRef(false);
const whiteboardIdRef = React.useRef(whiteboardId);
const curPageIdRef = React.useRef(curPageId);
const hasWBAccessRef = React.useRef(hasWBAccess);
const THRESHOLD = 0.1;
const lastKnownHeight = React.useRef(presentationAreaHeight);
const lastKnownWidth = React.useRef(presentationAreaWidth);
React.useEffect(() => {
curPageIdRef.current = curPageId;
}, [curPageId]);
React.useEffect(() => {
whiteboardIdRef.current = whiteboardId;
}, [whiteboardId]);
React.useEffect(() => {
hasWBAccessRef.current = hasWBAccess;
if (!hasWBAccess && !isPresenter) {
tlEditorRef?.current?.setCurrentTool('select');
}
}, [hasWBAccess]);
const language = React.useMemo(() => {
return mapLanguage(Settings?.application?.locale?.toLowerCase() || "en");
}, [Settings?.application?.locale]);
const [cursorPosition, updateCursorPosition] = useCursor(
publishCursorUpdate,
whiteboardId
whiteboardIdRef.current
);
React.useEffect(() => {
@ -195,20 +202,16 @@ export default Whiteboard = React.memo(function Whiteboard(props) {
const { shapesToAdd, shapesToUpdate, shapesToRemove } = React.useMemo(() => {
const selectedShapeIds = tlEditorRef.current?.selectedShapeIds || [];
const localShapes = tlEditorRef.current?.currentPageShapes;
const filteredShapes =
localShapes?.filter((item) => item?.index !== "a0") || [];
const localLookup = new Map(
filteredShapes.map((shape) => [shape.id, shape])
);
const filteredShapes = localShapes?.filter((item) => item?.index !== "a0") || [];
const localLookup = new Map(filteredShapes.map((shape) => [shape.id, shape]));
const remoteShapeIds = Object.keys(prevShapesRef.current);
const toAdd = [];
const toUpdate = [];
const toRemove = [];
filteredShapes.forEach((localShape) => {
// If a local shape does not exist in the remote shapes, it should be removed
if (!remoteShapeIds.includes(localShape.id)) {
toRemove.push(localShape);
toRemove.push(localShape.id);
}
});
@ -216,18 +219,14 @@ export default Whiteboard = React.memo(function Whiteboard(props) {
if (!remoteShape.id) return;
const localShape = localLookup.get(remoteShape.id);
const prevShape = prevShapesRef.current[remoteShape.id];
// Create a deep clone of remoteShape and remove the isModerator property
const comparisonRemoteShape = deepCloneUsingShallow(remoteShape);
delete comparisonRemoteShape.isModerator;
delete comparisonRemoteShape.questionType;
if (!localShape) {
if (prevShapesRef.current[`${remoteShape.id}`].meta?.createdBy !== currentUser?.userId) {
// If the shape does not exist in local, add it to toAdd
delete remoteShape.isModerator
delete remoteShape.questionType
toAdd.push(remoteShape);
}
} else if (!isEqual(localShape, comparisonRemoteShape) && prevShape) {
// Capture the differences
} else if (!isEqual(localShape, remoteShape) && prevShape) {
const diff = {
id: remoteShape.id,
type: remoteShape.type,
@ -235,12 +234,8 @@ export default Whiteboard = React.memo(function Whiteboard(props) {
};
if (!selectedShapeIds.includes(remoteShape.id) && prevShape?.meta?.updatedBy !== currentUser?.userId) {
// Compare each property
Object.keys(remoteShape).forEach((key) => {
if (
key !== "isModerator" &&
!isEqual(remoteShape[key], localShape[key])
) {
if (key !== "isModerator" && !isEqual(remoteShape[key], localShape[key])) {
diff[key] = remoteShape[key];
}
});
@ -254,26 +249,19 @@ export default Whiteboard = React.memo(function Whiteboard(props) {
});
}
delete diff.isModerator
delete diff.questionType
toUpdate.push(diff);
}
}
});
toAdd.forEach((shape) => {
delete shape.isModerator;
delete shape.questionType;
});
toUpdate.forEach((shape) => {
delete shape.isModerator;
delete shape.questionType;
});
return {
shapesToAdd: toAdd,
shapesToUpdate: toUpdate,
shapesToRemove: toRemove,
};
}, [prevShapesRef.current]);
}, [prevShapesRef.current, curPageIdRef.current]);
const setCamera = (zoom, x = 0, y = 0) => {
if (tlEditorRef.current) {
@ -310,12 +298,12 @@ export default Whiteboard = React.memo(function Whiteboard(props) {
{ whiteboardRef, tlEditorRef, isWheelZoomRef, initialZoomRef },
{
isPresenter,
hasWBAccess,
hasWBAccess: hasWBAccessRef.current,
isMouseDownRef,
whiteboardToolbarAutoHide,
animations,
publishCursorUpdate,
whiteboardId,
whiteboardId: whiteboardIdRef.current,
cursorPosition,
updateCursorPosition,
toggleToolsAnimations,
@ -329,10 +317,58 @@ export default Whiteboard = React.memo(function Whiteboard(props) {
tlEditorRef.current = tlEditor;
}, [tlEditor]);
React.useEffect(() => {
let undoRedoIntervalId = null;
const undo = () => {
tlEditorRef?.current?.history?.undo();
};
const redo = () => {
tlEditorRef?.current?.history?.redo();
};
const handleKeyDown = (event) => {
if ((event.ctrlKey && event.key === 'z' && !event.shiftKey) || (event.ctrlKey && event.shiftKey && event.key === 'Z')) {
event.preventDefault();
event.stopPropagation();
if (!undoRedoIntervalId) {
undoRedoIntervalId = setInterval(() => {
if (event.ctrlKey && event.key === 'z' && !event.shiftKey) {
undo();
} else if (event.ctrlKey && event.shiftKey && event.key === 'Z') {
redo();
}
}, 150);
}
}
};
const handleKeyUp = (event) => {
if ((event.key === 'z' || event.key === 'Z') && undoRedoIntervalId) {
clearInterval(undoRedoIntervalId);
undoRedoIntervalId = null;
}
};
whiteboardRef.current?.addEventListener('keydown', handleKeyDown, { capture: true });
whiteboardRef.current?.addEventListener('keyup', handleKeyUp, { capture: true });
return () => {
whiteboardRef.current?.removeEventListener('keydown', handleKeyDown);
whiteboardRef.current?.removeEventListener('keyup', handleKeyUp);
if (undoRedoIntervalId) {
clearInterval(undoRedoIntervalId);
}
};
}, [whiteboardRef.current]);
React.useEffect(() => {
zoomValueRef.current = zoomValue;
if (tlEditor && curPageId && currentPresentationPage && isPresenter && isWheelZoomRef.current === false) {
if (tlEditor && curPageIdRef.current && currentPresentationPage && isPresenter && isWheelZoomRef.current === false) {
const zoomFitSlide = calculateZoomValue(
currentPresentationPage.scaledWidth,
currentPresentationPage.scaledHeight
@ -391,7 +427,7 @@ export default Whiteboard = React.memo(function Whiteboard(props) {
// Update the previous zoom value ref with the current zoom value
prevZoomValueRef.current = zoomValue;
}, [zoomValue, tlEditor, curPageId, isWheelZoomRef.current]);
}, [zoomValue, tlEditor, curPageIdRef.current, isWheelZoomRef.current]);
React.useEffect(() => {
if (
@ -496,7 +532,7 @@ export default Whiteboard = React.memo(function Whiteboard(props) {
}
}
}
}, [presentationAreaHeight, presentationAreaWidth, curPageId]);
}, [presentationAreaHeight, presentationAreaWidth, curPageIdRef.current]);
React.useEffect(() => {
if (!fitToWidth && isPresenter) {
@ -549,17 +585,21 @@ export default Whiteboard = React.memo(function Whiteboard(props) {
React.useEffect(() => {
// Check if there are any changes to be made
if (shapesToAdd.length || shapesToUpdate.length || shapesToRemove.length) {
tlEditor?.store?.mergeRemoteChanges(() => {
if (shapesToRemove.length > 0) {
tlEditor?.store?.remove(shapesToRemove.map((shape) => shape.id));
}
if (shapesToAdd.length) {
tlEditor?.store?.put(shapesToAdd);
}
if (shapesToUpdate.length) {
tlEditor?.updateShapes(shapesToUpdate);
}
});
const tlStoreUpdateTimeoutId = setTimeout(() => {
tlEditor?.store?.mergeRemoteChanges(() => {
if (shapesToRemove.length > 0) {
tlEditor?.store?.remove(shapesToRemove);
}
if (shapesToAdd.length) {
tlEditor?.store?.put(shapesToAdd);
}
if (shapesToUpdate.length) {
tlEditor?.updateShapes(shapesToUpdate);
}
});
}, 150);
return () => clearTimeout(tlStoreUpdateTimeoutId);
}
}, [shapesToAdd, shapesToUpdate, shapesToRemove]);
@ -617,30 +657,39 @@ export default Whiteboard = React.memo(function Whiteboard(props) {
// set current tldraw page when presentation id updates
React.useEffect(() => {
if (tlEditor && curPageId !== "0") {
// Check if the page exists
const pageExists =
tlEditorRef.current.currentPageId === `page:${curPageId}`;
if (tlEditorRef.current && curPageIdRef.current !== "0") {
const pages = [
{
meta: {},
id: `page:${curPageIdRef.current}`,
name: `Slide ${curPageIdRef.current}`,
index: `a1`,
typeName: "page",
},
];
// If the page does not exist, create it
if (!pageExists) {
tlEditorRef.current.createPage({ id: `page:${curPageId}` });
}
// Set the current page
tlEditor.setCurrentPage(`page:${curPageId}`);
tlEditorRef.current.store.mergeRemoteChanges(() => {
tlEditorRef.current.batch(() => {
tlEditorRef.current.store.put(pages);
tlEditorRef.current.deletePage(tlEditorRef.current.currentPageId);
tlEditorRef.current.setCurrentPage(`page:${curPageIdRef.current}`);
tlEditorRef.current.store.put(assets);
tlEditorRef.current.createShapes(bgShape);
tlEditorRef.current.history.clear();
});
});
whiteboardToolbarAutoHide &&
toggleToolsAnimations(
"fade-in",
"fade-out",
"0s",
hasWBAccess || isPresenter
hasWBAccessRef.current || isPresenter
);
slideChanged.current = false;
slideNext.current = null;
}
}, [curPageId]);
}, [curPageIdRef.current]);
React.useEffect(() => {
setTldrawIsMounting(true);
@ -690,7 +739,7 @@ export default Whiteboard = React.memo(function Whiteboard(props) {
}, [
isMountedRef.current,
presentationId,
curPageId,
curPageIdRef.current,
isMultiUserActive,
isPresenter,
animations,
@ -714,8 +763,6 @@ export default Whiteboard = React.memo(function Whiteboard(props) {
editor?.user?.updateUserPreferences({ locale: language });
console.log("EDITOR : ", editor);
const debouncePersistShape = debounce({ delay: 0 }, persistShapeWrapper);
const colorStyles = ['black', 'blue', 'green', 'grey', 'light-blue', 'light-green', 'light-red', 'light-violet', 'orange', 'red', 'violet', 'yellow'];
@ -753,7 +800,7 @@ export default Whiteboard = React.memo(function Whiteboard(props) {
createdBy: currentUser?.userId,
},
};
persistShapeWrapper(updatedRecord, whiteboardId, isModerator);
persistShapeWrapper(updatedRecord, whiteboardIdRef.current, isModerator);
});
Object.values(updated).forEach(([_, record]) => {
@ -764,7 +811,7 @@ export default Whiteboard = React.memo(function Whiteboard(props) {
createdBy: shapes[record?.id]?.meta?.createdBy,
},
};
persistShapeWrapper(updatedRecord, whiteboardId, isModerator);
persistShapeWrapper(updatedRecord, whiteboardIdRef.current, isModerator);
});
Object.values(removed).forEach((record) => {
@ -784,7 +831,7 @@ export default Whiteboard = React.memo(function Whiteboard(props) {
updateCursorPosition(nextPointer?.x, nextPointer?.y);
}
const camKey = `camera:page:${curPageId}`;
const camKey = `camera:page:${curPageIdRef.current}`;
const { [camKey]: cameras } = updated;
if (cameras) {
const [prevCam, nextCam] = cameras;
@ -813,12 +860,12 @@ export default Whiteboard = React.memo(function Whiteboard(props) {
{ source: "user" }
);
if (editor && curPageId) {
if (editor && curPageIdRef.current) {
const pages = [
{
meta: {},
id: `page:${curPageId}`,
name: `Slide ${curPageId}`,
id: `page:${curPageIdRef.current}`,
name: `Slide ${curPageIdRef.current}`,
index: `a1`,
typeName: "page",
},
@ -828,7 +875,7 @@ export default Whiteboard = React.memo(function Whiteboard(props) {
editor.batch(() => {
editor.store.put(pages);
editor.deletePage(editor.currentPageId);
editor.setCurrentPage(`page:${curPageId}`);
editor.setCurrentPage(`page:${curPageIdRef.current}`);
editor.store.put(assets);
editor.createShapes(bgShape);
editor.history.clear();
@ -918,33 +965,33 @@ export default Whiteboard = React.memo(function Whiteboard(props) {
<div
ref={whiteboardRef}
id={"whiteboard-element"}
key={`animations=-${animations}-${hasWBAccess}-${isPresenter}-${isModerator}-${whiteboardToolbarAutoHide}-${language}`}
key={`animations=-${animations}-${isPresenter}-${isModerator}-${whiteboardToolbarAutoHide}-${language}`}
>
<Tldraw
key={`tldrawv2-${curPageId}-${presentationId}-${animations}-${shapes}`}
key={`tldrawv2-${presentationId}-${animations}`}
forceMobile={true}
hideUi={hasWBAccess || isPresenter ? false : true}
hideUi={hasWBAccessRef.current || isPresenter ? false : true}
onMount={handleTldrawMount}
/>
<Styled.TldrawV2GlobalStyle
{...{ hasWBAccess, isPresenter, isRTL, isMultiUserActive, isToolbarVisible }}
{...{ hasWBAccess: hasWBAccessRef.current, isPresenter, isRTL, isMultiUserActive, isToolbarVisible }}
/>
</div>
);
});
Whiteboard.propTypes = {
isPresenter: PropTypes.bool.isRequired,
isPresenter: PropTypes.bool,
isIphone: PropTypes.bool.isRequired,
removeShapes: PropTypes.func.isRequired,
initDefaultPages: PropTypes.func.isRequired,
persistShapeWrapper: PropTypes.func.isRequired,
notifyNotAllowedChange: PropTypes.func.isRequired,
shapes: PropTypes.objectOf(PropTypes.shape).isRequired,
assets: PropTypes.objectOf(PropTypes.shape).isRequired,
assets: PropTypes.arrayOf(PropTypes.shape).isRequired,
currentUser: PropTypes.shape({
userId: PropTypes.string.isRequired,
}).isRequired,
}),
whiteboardId: PropTypes.string,
zoomSlide: PropTypes.func.isRequired,
curPageId: PropTypes.string.isRequired,
@ -980,7 +1027,7 @@ Whiteboard.propTypes = {
fullscreenAction: PropTypes.string.isRequired,
fullscreenRef: PropTypes.instanceOf(Element),
handleToggleFullScreen: PropTypes.func.isRequired,
numberOfSlides: PropTypes.number.isRequired,
numberOfPages: PropTypes.number,
sidebarNavigationWidth: PropTypes.number,
presentationId: PropTypes.string,
};
@ -991,4 +1038,9 @@ Whiteboard.defaultProps = {
whiteboardId: undefined,
sidebarNavigationWidth: 0,
presentationId: undefined,
currentUser: {
userId: '',
},
isPresenter: false,
numberOfPages: 0,
};

View File

@ -161,7 +161,6 @@ const WhiteboardContainer = (props) => {
pageAnnotations,
intl,
curPageId,
pollResults,
currentPresentationPage,
);
@ -222,9 +221,8 @@ const WhiteboardContainer = (props) => {
const hasShapeAccess = (id) => {
const owner = shapes[id]?.meta?.createdBy;
const isBackgroundShape = id?.includes(':BG-');
const isPollsResult = shapes[id]?.id?.includes('poll-result');
const hasAccess = (!isBackgroundShape && !isPollsResult)
&& ((owner && owner === currentUser?.userId) || (isPresenter) || (isModerator)) || !shapes[id];
const hasAccess = (!isBackgroundShape
&& ((owner && owner === currentUser?.userId) || isPresenter || isModerator)) || !shapes[id];
return hasAccess;
};
@ -258,7 +256,6 @@ const WhiteboardContainer = (props) => {
assets,
removeShapes,
zoomSlide,
numberOfSlides: currentPresentationPage?.totalPages,
notifyNotAllowedChange,
notifyShapeNumberExceeded,
whiteboardToolbarAutoHide:

View File

@ -165,38 +165,9 @@ const toggleToolsAnimations = (activeAnim, anim, time, hasWBAccess = false) => {
checkElementsAndRun();
};
const formatAnnotations = (annotations, intl, curPageId, pollResults, currentPresentationPage) => {
const formatAnnotations = (annotations, intl, curPageId, currentPresentationPage) => {
const result = {};
if (pollResults) {
// check if pollResults is already added to annotations
const hasPollResultsAnnotation = annotations.find(
(annotation) => annotation.annotationId === pollResults.pollId,
);
if (!hasPollResultsAnnotation) {
const answers = pollResults.responses.map((response) => ({
id: response.optionId,
key: response.optionDesc,
numVotes: response.optionResponsesCount,
}));
const pollResultsAnnotation = {
id: pollResults.pollId,
annotationInfo: JSON.stringify({
answers,
id: pollResults.pollId,
whiteboardId: curPageId,
questionType: true,
questionText: pollResults.questionText,
}),
wbId: curPageId,
userId: Auth.userID,
};
annotations.push(pollResultsAnnotation);
}
}
annotations.forEach((annotation) => {
if (annotation.annotationInfo === '') return;
@ -204,70 +175,101 @@ const formatAnnotations = (annotations, intl, curPageId, pollResults, currentPre
if (annotationInfo.questionType) {
// poll result, convert it to text and create tldraw shape
annotationInfo.answers = annotationInfo.answers.reduce(
caseInsensitiveReducer, [],
);
let pollResult = PollService.getPollResultString(annotationInfo, intl)
.split('<br/>').join('\n').replace(/(<([^>]+)>)/ig, '');
if (!annotationInfo.props) {
annotationInfo.answers = annotationInfo.answers.reduce(
caseInsensitiveReducer, [],
);
let pollResult = PollService.getPollResultString(annotationInfo, intl)
.split('<br/>').join('\n').replace(/(<([^>]+)>)/ig, '');
const lines = pollResult.split('\n');
const longestLine = lines.reduce((a, b) => a.length > b.length ? a : b, '').length;
const lines = pollResult.split('\n');
const longestLine = lines.reduce((a, b) => (a.length > b.length ? a : b), '').length;
// add empty spaces before first | in each of the lines to make them all the same length
pollResult = lines.map((line) => {
if (!line.includes('|') || line.length === longestLine) return line;
// add empty spaces before first | in each of the lines to make them all the same length
pollResult = lines.map((line) => {
if (!line.includes('|') || line.length === longestLine) return line;
const splitLine = line.split(' |');
const spaces = ' '.repeat(longestLine - line.length);
return `${splitLine[0]} ${spaces}|${splitLine[1]}`;
}).join('\n');
const splitLine = line.split(' |');
const spaces = ' '.repeat(longestLine - line.length);
return `${splitLine[0]} ${spaces}|${splitLine[1]}`;
}).join('\n');
// Text measurement estimation
const averageCharWidth = 16;
const lineHeight = 32;
// Text measurement estimation
const averageCharWidth = 16;
const lineHeight = 32;
const annotationWidth = longestLine * averageCharWidth; // Estimate width
const annotationHeight = lines.length * lineHeight; // Estimate height
const annotationWidth = longestLine * averageCharWidth; // Estimate width
const annotationHeight = lines.length * lineHeight; // Estimate height
const slideWidth = currentPresentationPage?.scaledWidth;
const slideHeight = currentPresentationPage?.scaledHeight;
const xPosition = slideWidth - annotationWidth;
const yPosition = slideHeight - annotationHeight;
const slideWidth = currentPresentationPage?.scaledWidth;
const slideHeight = currentPresentationPage?.scaledHeight;
const xPosition = slideWidth - annotationWidth;
const yPosition = slideHeight - annotationHeight;
let cpg = parseInt(annotationInfo?.id?.split('/')[1]);
if (cpg !== parseInt(curPageId)) return;
annotationInfo = {
"x": xPosition,
"isLocked": false,
"y": yPosition,
"rotation": 0,
"typeName": "shape",
"opacity": 1,
"parentId": `page:${curPageId}`,
"index": "a1",
"id": `shape:poll-result-${annotationInfo.id}`,
"meta": {
},
"type": "geo",
"props": {
"url": "",
"text": `${pollResult}`,
"color": "black",
"font": "mono",
"fill": "semi",
"dash": "draw",
"h": annotationHeight,
"w": annotationWidth,
"size": "m",
"growY": 0,
"align": "middle",
"geo": "rectangle",
"verticalAlign": "middle",
"labelColor": "black"
}
annotationInfo = {
x: xPosition,
y: yPosition,
isLocked: false,
rotation: 0,
typeName: 'shape',
opacity: 1,
parentId: `page:${curPageId}`,
index: 'a1',
id: `${annotationInfo.id}`,
meta: {},
type: 'geo',
props: {
url: '',
text: `${pollResult}`,
color: 'black',
font: 'mono',
fill: 'semi',
dash: 'draw',
w: annotationWidth,
h: annotationHeight,
size: 'm',
growY: 0,
align: 'middle',
geo: 'rectangle',
verticalAlign: 'middle',
labelColor: 'black',
},
};
} else {
annotationInfo = {
x: annotationInfo.x,
isLocked: annotationInfo.isLocked,
y: annotationInfo.y,
rotation: annotationInfo.rotation,
typeName: annotationInfo.typeName,
opacity: annotationInfo.opacity,
parentId: annotationInfo.parentId,
index: annotationInfo.index,
id: annotationInfo.id,
meta: annotationInfo.meta,
type: 'geo',
props: {
url: '',
text: annotationInfo.props.text,
color: annotationInfo.props.color,
font: annotationInfo.props.font,
fill: annotationInfo.props.fill,
dash: annotationInfo.props.dash,
h: annotationInfo.props.h,
w: annotationInfo.props.w,
size: annotationInfo.props.size,
growY: 0,
align: 'middle',
geo: annotationInfo.props.geo,
verticalAlign: 'middle',
labelColor: annotationInfo.props.labelColor,
},
};
}
const cpg = parseInt(annotationInfo?.id?.split?.('/')?.[1], 10);
if (cpg !== parseInt(curPageId, 10)) return;
annotationInfo.questionType = false;
}
result[annotationInfo.id] = annotationInfo;

View File

@ -101,7 +101,6 @@ const TldrawV2GlobalStyle = createGlobalStyle`
.tl-collaborator__cursor {
height: auto !important;
width: auto !important;
transition: transform 0.25s ease-out !important;
}
`;

View File

@ -111,7 +111,9 @@ class Settings {
const { status } = Meteor.status();
if (status === 'connected') {
c.stop();
mutation(userSettings);
if (typeof mutation === 'function') {
mutation(userSettings);
}
}
});
}

View File

@ -3418,9 +3418,9 @@
"dev": true
},
"bigbluebutton-html-plugin-sdk": {
"version": "0.0.35",
"resolved": "https://registry.npmjs.org/bigbluebutton-html-plugin-sdk/-/bigbluebutton-html-plugin-sdk-0.0.35.tgz",
"integrity": "sha512-KD38ThSmr8JfitpjXWTas5SoKuNM4j++G36H7jez4jdvQLz3AJLJAvSGfJrqjnxAwS4oQU5V2Z5bfKrR1a14Vg==",
"version": "0.0.37",
"resolved": "https://registry.npmjs.org/bigbluebutton-html-plugin-sdk/-/bigbluebutton-html-plugin-sdk-0.0.37.tgz",
"integrity": "sha512-3zkM22DYkElg+cZTMMt+fB3xLPHG4Rj0lJvagRRJNUw+BTWJWnTE/CP8u51HVuU+P6rTnJIiPFGddigDr7Pk3A==",
"requires": {
"@apollo/client": "^3.8.7"
}

View File

@ -47,7 +47,7 @@
"autoprefixer": "^10.4.4",
"axios": "^1.6.4",
"babel-runtime": "~6.26.0",
"bigbluebutton-html-plugin-sdk": "0.0.35",
"bigbluebutton-html-plugin-sdk": "0.0.37",
"bowser": "^2.11.0",
"browser-bunyan": "^1.8.0",
"classnames": "^2.2.6",

View File

@ -942,7 +942,7 @@ def BBB_server_standalone(hostname, x=100, y=300):
install_options.append('-g')
install_options_str = ' '.join(install_options)
user_data['runcmd'].append(f'sudo -u ubuntu RELEASE="{args.release}" INSTALL_OPTIONS="{install_options_str}" /testserver.sh')
user_data['runcmd'].append(f'runuser -u ubuntu RELEASE="{args.release}" INSTALL_OPTIONS="{install_options_str}" /testserver.sh')
if notification_url:
user_data['phone_home'] = {'url': notification_url, 'tries': 1}

View File

@ -124,7 +124,7 @@ class ConnectionController {
builder {
"response" "authorized"
"X-Hasura-Role" "not_joined_bbb_client"
"X-Hasura-ModeratorInMeeting" ""
"X-Hasura-ModeratorInMeeting" removedUserSession.isModerator() ? removedUserSession.meetingId : ""
"X-Hasura-PresenterInMeeting" ""
"X-Hasura-UserId" removedUserSession.userId
"X-Hasura-MeetingId" removedUserSession.meetingId

View File

@ -5,19 +5,19 @@ case "$1" in
fc-cache -f
sudo -u postgres psql -c "alter user postgres password 'bbb_graphql'"
sudo -u postgres psql -c "drop database if exists bbb_graphql with (force)"
sudo -u postgres psql -c "create database bbb_graphql WITH TEMPLATE template0 LC_COLLATE 'C.UTF-8'"
sudo -u postgres psql -c "alter database bbb_graphql set timezone to 'UTC'"
sudo -u postgres psql -U postgres -d bbb_graphql -q -f /usr/share/bbb-graphql-server/bbb_schema.sql --set ON_ERROR_STOP=on
runuser -u postgres -- psql -c "alter user postgres password 'bbb_graphql'"
runuser -u postgres -- psql -c "drop database if exists bbb_graphql with (force)"
runuser -u postgres -- psql -c "create database bbb_graphql WITH TEMPLATE template0 LC_COLLATE 'C.UTF-8'"
runuser -u postgres -- psql -c "alter database bbb_graphql set timezone to 'UTC'"
runuser -u postgres -- psql -U postgres -d bbb_graphql -q -f /usr/share/bbb-graphql-server/bbb_schema.sql --set ON_ERROR_STOP=on
DATABASE_NAME="hasura_app"
DB_EXISTS=$(sudo -u postgres psql -U postgres -tAc "SELECT 1 FROM pg_database WHERE datname='$DATABASE_NAME'")
DB_EXISTS=$(runuser -u postgres -- psql -U postgres -tAc "SELECT 1 FROM pg_database WHERE datname='$DATABASE_NAME'")
if [ "$DB_EXISTS" = '1' ]
then
echo "Database $DATABASE_NAME already exists"
else
sudo -u postgres psql -c "create database hasura_app"
runuser -u postgres -- psql -c "create database hasura_app"
echo "Database $DATABASE_NAME created"
fi

View File

@ -344,7 +344,7 @@ const createEndpointTableData = [
"name": "disabledFeatures",
"required": false,
"type": "String",
"description": (<>List (comma-separated) of features to disable in a particular meeting. (added 2.5)<br /><br />Available options to disable:<br /><ul><li><code className="language-plaintext highlighter-rouge">breakoutRooms</code>- <b>Breakout Rooms</b> </li><li><code className="language-plaintext highlighter-rouge">captions</code>- <b>Closed Caption</b> </li><li><code className="language-plaintext highlighter-rouge">chat</code>- <b>Chat</b></li><li><code className="language-plaintext highlighter-rouge">downloadPresentationWithAnnotations</code>- <b>Annotated presentation download</b></li><li><code className="language-plaintext highlighter-rouge">snapshotOfCurrentSlide</code>- <b>Allow snapshot of the current slide</b></li><li><code className="language-plaintext highlighter-rouge">externalVideos</code>- <b>Share an external video</b> </li><li><code className="language-plaintext highlighter-rouge">importPresentationWithAnnotationsFromBreakoutRooms</code>- <b>Capture breakout presentation</b></li><li><code className="language-plaintext highlighter-rouge">importSharedNotesFromBreakoutRooms</code>- <b>Capture breakout shared notes</b></li><li><code className="language-plaintext highlighter-rouge">layouts</code>- <b>Layouts</b> (allow only default layout)</li><li><code className="language-plaintext highlighter-rouge">learningDashboard</code>- <b>Learning Analytics Dashboard</b></li><li><code className="language-plaintext highlighter-rouge">polls</code>- <b>Polls</b> </li><li><code className="language-plaintext highlighter-rouge">screenshare</code>- <b>Screen Sharing</b></li><li><code className="language-plaintext highlighter-rouge">sharedNotes</code>- <b>Shared Notes</b></li><li><code className="language-plaintext highlighter-rouge">virtualBackgrounds</code>- <b>Virtual Backgrounds</b></li><li><code className="language-plaintext highlighter-rouge">customVirtualBackgrounds</code>- <b>Virtual Backgrounds Upload</b></li><li><code className="language-plaintext highlighter-rouge">liveTranscription</code>- <b>Live Transcription</b></li><li><code className="language-plaintext highlighter-rouge">presentation</code>- <b>Presentation</b></li><li><code className="language-plaintext highlighter-rouge">cameraAsContent</code>-<b>Enables/Disables camera as a content</b></li><li><code className="language-plaintext highlighter-rouge">timer</code>- <b>disables timer</b></li></ul></>)
"description": (<>List (comma-separated) of features to disable in a particular meeting. (added 2.5)<br /><br />Available options to disable:<br /><ul><li><code className="language-plaintext highlighter-rouge">breakoutRooms</code>- <b>Breakout Rooms</b> </li><li><code className="language-plaintext highlighter-rouge">captions</code>- <b>Closed Caption</b> </li><li><code className="language-plaintext highlighter-rouge">chat</code>- <b>Chat</b></li><li><code className="language-plaintext highlighter-rouge">downloadPresentationWithAnnotations</code>- <b>Annotated presentation download</b></li><li><code className="language-plaintext highlighter-rouge">snapshotOfCurrentSlide</code>- <b>Allow snapshot of the current slide</b></li><li><code className="language-plaintext highlighter-rouge">externalVideos</code>- <b>Share an external video</b> </li><li><code className="language-plaintext highlighter-rouge">importPresentationWithAnnotationsFromBreakoutRooms</code>- <b>Capture breakout presentation</b></li><li><code className="language-plaintext highlighter-rouge">importSharedNotesFromBreakoutRooms</code>- <b>Capture breakout shared notes</b></li><li><code className="language-plaintext highlighter-rouge">layouts</code>- <b>Layouts</b> (allow only default layout)</li><li><code className="language-plaintext highlighter-rouge">learningDashboard</code>- <b>Learning Analytics Dashboard</b></li><li><code className="language-plaintext highlighter-rouge">learningDashboardDownloadSessionData</code>- <b>Learning Analytics Dashboard Download Session Data (prevents the option to download)</b></li><li><code className="language-plaintext highlighter-rouge">polls</code>- <b>Polls</b> </li><li><code className="language-plaintext highlighter-rouge">screenshare</code>- <b>Screen Sharing</b></li><li><code className="language-plaintext highlighter-rouge">sharedNotes</code>- <b>Shared Notes</b></li><li><code className="language-plaintext highlighter-rouge">virtualBackgrounds</code>- <b>Virtual Backgrounds</b></li><li><code className="language-plaintext highlighter-rouge">customVirtualBackgrounds</code>- <b>Virtual Backgrounds Upload</b></li><li><code className="language-plaintext highlighter-rouge">liveTranscription</code>- <b>Live Transcription</b></li><li><code className="language-plaintext highlighter-rouge">presentation</code>- <b>Presentation</b></li><li><code className="language-plaintext highlighter-rouge">cameraAsContent</code>-<b>Enables/Disables camera as a content</b></li><li><code className="language-plaintext highlighter-rouge">timer</code>- <b>disables timer</b></li></ul></>)
},
{
"name": "disabledFeaturesExclude",

View File

@ -104,7 +104,7 @@ Updated in 2.6:
Updated in 2.7:
- **create** - **Added:** `preUploadedPresentation`, `preUploadedPresentationName`, `disabledFeatures` options`cameraAsContent`, `snapshotOfCurrentSlide`, `downloadPresentationOriginalFile`, `downloadPresentationConvertedToPdf`, `timer`.
- **create** - **Added:** `preUploadedPresentation`, `preUploadedPresentationName`, `disabledFeatures` options`cameraAsContent`, `snapshotOfCurrentSlide`, `downloadPresentationOriginalFile`, `downloadPresentationConvertedToPdf`, `timer`, `learningDashboardDownloadSessionData` (2.7.5).
- **join** - **Added:** `redirectErrorUrl`, `userdata-bbb_fullaudio_bridge`
Updated in 3.0: