diff --git a/bigbluebutton-html5/imports/startup/client/base.jsx b/bigbluebutton-html5/imports/startup/client/base.jsx index 9349978d2f07254330b45ed384030401ffa77066..2230a1bac51ac4bda9ea4b0202ec74dcd4205423 100755 --- a/bigbluebutton-html5/imports/startup/client/base.jsx +++ b/bigbluebutton-html5/imports/startup/client/base.jsx @@ -19,6 +19,8 @@ import AudioService from '/imports/ui/components/audio/service'; import { FormattedMessage } from 'react-intl'; import { notify } from '/imports/ui/services/notification'; +const BREAKOUT_END_NOTIFY_DELAY = 50; + const HTML = document.getElementsByTagName('html')[0]; let breakoutNotified = false; @@ -237,6 +239,7 @@ const BaseContainer = withTracker(() => { const meeting = Meetings.findOne({ meetingId }, { fields: { meetingEnded: 1, + meetingProp: 1, }, }); @@ -248,22 +251,32 @@ const BaseContainer = withTracker(() => { const ejected = User && User.ejected; let userSubscriptionHandler; - - Breakouts.find().observeChanges({ + Breakouts.find({}, { fields: { _id: 1 } }).observeChanges({ added() { breakoutNotified = false; }, removed() { - if (!AudioService.isUsingAudio() && !breakoutNotified) { - if (meeting && !meeting.meetingEnded) { - notify( - <FormattedMessage - id="app.toast.breakoutRoomEnded" - description="message when the breakout room is ended" - />, - 'info', - 'rooms', - ); + // Need to check the number of breakouts left because if a user's role changes to viewer + // then all but one room is removed. The data here isn't reactive so no need to filter + // the fields + const numBreakouts = Breakouts.find().count(); + if (!AudioService.isUsingAudio() && !breakoutNotified && numBreakouts === 0) { + if (meeting && !meeting.meetingEnded && !meeting.meetingProp.isBreakout) { + // There's a race condition when reloading a tab where the collection gets cleared + // out and then refilled. The removal of the old data triggers the notification so + // instead wait a bit and check to see that records weren't added right after. + setTimeout(() => { + if (breakoutNotified) { + notify( + <FormattedMessage + id="app.toast.breakoutRoomEnded" + description="message when the breakout room is ended" + />, + 'info', + 'rooms', + ); + } + }, BREAKOUT_END_NOTIFY_DELAY); } breakoutNotified = true; } diff --git a/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/component.jsx b/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/component.jsx index f0f349136ca5a3c6c89533750a202e5b27b82c97..a63f158a7af19c61f670be5158f322884a2923f8 100755 --- a/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/actions-bar/create-breakout-room/component.jsx @@ -170,8 +170,6 @@ class BreakoutRoom extends PureComponent { numberOfRoomsIsValid: true, }; - this.breakoutFormId = _.uniqueId('breakout-form-'); - this.freeJoinId = _.uniqueId('free-join-check-'); this.btnLevelId = _.uniqueId('btn-set-level-'); } @@ -308,7 +306,7 @@ class BreakoutRoom extends PureComponent { } setFreeJoin(e) { - this.setState({ freeJoin: e.target.checked }); + this.setState({ freeJoin: e.target.checked, valid: true }); } setRecord(e) { @@ -455,8 +453,8 @@ class BreakoutRoom extends PureComponent { if (isInvitation) return null; return ( - <React.Fragment> - <div className={styles.breakoutSettings} key={this.breakoutFormId}> + <React.Fragment key="breakout-form"> + <div className={styles.breakoutSettings}> <div> <p className={cx(styles.labelText, !numberOfRoomsIsValid @@ -573,8 +571,8 @@ class BreakoutRoom extends PureComponent { record, } = this.state; return ( - <div className={styles.checkBoxesContainer}> - <label htmlFor="freeJoinCheckbox" className={styles.freeJoinLabel} key={this.freeJoinId}> + <div className={styles.checkBoxesContainer} key="breakout-checkboxes"> + <label htmlFor="freeJoinCheckbox" className={styles.freeJoinLabel} key="free-join-breakouts"> <input type="checkbox" id="freeJoinCheckbox" @@ -587,7 +585,7 @@ class BreakoutRoom extends PureComponent { </label> { isBreakoutRecordable ? ( - <label htmlFor="recordBreakoutCheckbox" className={styles.freeJoinLabel} key={this.freeJoinId}> + <label htmlFor="recordBreakoutCheckbox" className={styles.freeJoinLabel} key="record-breakouts"> <input id="recordBreakoutCheckbox" type="checkbox" diff --git a/bigbluebutton-html5/imports/ui/components/breakout-room/component.jsx b/bigbluebutton-html5/imports/ui/components/breakout-room/component.jsx index 6aad239d925a5f33bffc12b28c84e39dcd67257f..2d0a90bb6fad3e0bd323f77b89c66969b74877a1 100644 --- a/bigbluebutton-html5/imports/ui/components/breakout-room/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/breakout-room/component.jsx @@ -155,7 +155,7 @@ class BreakoutRoom extends PureComponent { this.setState({ joinedAudioOnly: false, breakoutId }); } - renderUserActions(breakoutId, number) { + renderUserActions(breakoutId, joinedUsers, number) { const { isMicrophoneUser, amIModerator, @@ -189,7 +189,7 @@ class BreakoutRoom extends PureComponent { }; return ( <div className={styles.breakoutActions}> - {isUserInBreakoutRoom(breakoutId) + {isUserInBreakoutRoom(joinedUsers) ? ( <span className={styles.alreadyConnected}> {intl.formatMessage(intlMessages.alreadyConnected)} @@ -242,7 +242,6 @@ class BreakoutRoom extends PureComponent { const { breakoutRooms, intl, - getNumUsersByBreakoutId, } = this.props; const { @@ -260,7 +259,7 @@ class BreakoutRoom extends PureComponent { {intl.formatMessage(intlMessages.breakoutRoom, breakout.sequence.toString())} <span className={styles.usersAssignedNumberLabel}> ( - {getNumUsersByBreakoutId(breakout.breakoutId)} + {breakout.joinedUsers.length} ) </span> </span> @@ -269,7 +268,11 @@ class BreakoutRoom extends PureComponent { {intl.formatMessage(intlMessages.generatingURL)} <span className={styles.connectingAnimation} /> </span> - ) : this.renderUserActions(breakout.breakoutId, breakout.sequence.toString())} + ) : this.renderUserActions( + breakout.breakoutId, + breakout.joinedUsers, + breakout.sequence.toString(), + )} </div> <div className={styles.joinedUserNames}> {breakout.joinedUsers diff --git a/bigbluebutton-html5/imports/ui/components/breakout-room/container.jsx b/bigbluebutton-html5/imports/ui/components/breakout-room/container.jsx index c32cb3f069bf33314d64fcdbdcaf32d8fec140df..74113934765ed695a838f6f4c26eac72123a84fe 100644 --- a/bigbluebutton-html5/imports/ui/components/breakout-room/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/breakout-room/container.jsx @@ -18,7 +18,6 @@ export default withTracker((props) => { meetingId, amIModerator, closeBreakoutPanel, - getNumUsersByBreakoutId, isUserInBreakoutRoom, } = Service; const breakoutRooms = findBreakouts(); @@ -37,7 +36,6 @@ export default withTracker((props) => { meetingId: meetingId(), amIModerator: amIModerator(), closeBreakoutPanel, - getNumUsersByBreakoutId, isMeteorConnected, isUserInBreakoutRoom, }; diff --git a/bigbluebutton-html5/imports/ui/components/breakout-room/invitation/component.jsx b/bigbluebutton-html5/imports/ui/components/breakout-room/invitation/component.jsx index af0a733c443a87734caee48cc376572ea16c1223..3071a22776fe62b59103d3807080893a537f0556 100644 --- a/bigbluebutton-html5/imports/ui/components/breakout-room/invitation/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/breakout-room/invitation/component.jsx @@ -1,8 +1,31 @@ import React, { Component } from 'react'; +import PropTypes from 'prop-types'; import { Session } from 'meteor/session'; import { withModalMounter } from '/imports/ui/components/modal/service'; import BreakoutJoinConfirmation from '/imports/ui/components/breakout-join-confirmation/container'; +const BREAKOUT_MODAL_DELAY = 200; + +const propTypes = { + mountModal: PropTypes.func.isRequired, + currentBreakoutUser: PropTypes.shape({ + insertedTime: PropTypes.number.isRequired, + }), + getBreakoutByUser: PropTypes.func.isRequired, + breakoutUserIsIn: PropTypes.shape({ + sequence: PropTypes.number.isRequired, + }), + breakouts: PropTypes.arrayOf(PropTypes.shape({ + freeJoin: PropTypes.bool.isRequired, + })), +}; + +const defaultProps = { + currentBreakoutUser: undefined, + breakoutUserIsIn: undefined, + breakouts: [], +}; + const openBreakoutJoinConfirmation = (breakout, breakoutName, mountModal) => mountModal( <BreakoutJoinConfirmation breakout={breakout} @@ -13,40 +36,80 @@ const openBreakoutJoinConfirmation = (breakout, breakoutName, mountModal) => mou const closeBreakoutJoinConfirmation = mountModal => mountModal(null); class BreakoutRoomInvitation extends Component { + constructor(props) { + super(props); + + this.state = { + didSendBreakoutInvite: false, + }; + } + + componentDidMount() { + // use dummy old data on mount so it works if no data changes + this.checkBreakouts({ breakouts: [] }); + } + componentDidUpdate(oldProps) { + this.checkBreakouts(oldProps); + } + + checkBreakouts(oldProps) { const { breakouts, mountModal, currentBreakoutUser, getBreakoutByUser, + breakoutUserIsIn, } = this.props; - const hadBreakouts = oldProps.breakouts.length; - const hasBreakouts = breakouts.length; + const { + didSendBreakoutInvite, + } = this.state; + + const hadBreakouts = oldProps.breakouts.length > 0; + const hasBreakouts = breakouts.length > 0; if (!hasBreakouts && hadBreakouts) { closeBreakoutJoinConfirmation(mountModal); } - if (hasBreakouts && currentBreakoutUser) { - const currentIsertedTime = currentBreakoutUser.insertedTime; - const oldCurrentUser = oldProps.currentBreakoutUser || {}; - const oldInsertedTime = oldCurrentUser.insertedTime; - - if (currentIsertedTime !== oldInsertedTime) { - const breakoutRoom = getBreakoutByUser(currentBreakoutUser); - const breakoutId = Session.get('lastBreakoutOpened'); - if (breakoutRoom.breakoutId !== breakoutId) { - this.inviteUserToBreakout(breakoutRoom); + if (hasBreakouts && !breakoutUserIsIn) { + // Have to check for freeJoin breakouts first because currentBreakoutUser will + // populate after a room has been joined + const freeJoinBreakout = breakouts.find(breakout => breakout.freeJoin); + if (freeJoinBreakout) { + if (!didSendBreakoutInvite) { + this.inviteUserToBreakout(freeJoinBreakout); + this.setState({ didSendBreakoutInvite: true }); + } + } else if (currentBreakoutUser) { + const currentInsertedTime = currentBreakoutUser.insertedTime; + const oldCurrentUser = oldProps.currentBreakoutUser || {}; + const oldInsertedTime = oldCurrentUser.insertedTime; + if (currentInsertedTime !== oldInsertedTime) { + const breakoutRoom = getBreakoutByUser(currentBreakoutUser); + const breakoutId = Session.get('lastBreakoutOpened'); + if (breakoutRoom.breakoutId !== breakoutId) { + this.inviteUserToBreakout(breakoutRoom); + } } } } + + if (!hasBreakouts && didSendBreakoutInvite) { + this.setState({ didSendBreakoutInvite: false }); + } } inviteUserToBreakout(breakout) { const { mountModal, } = this.props; - openBreakoutJoinConfirmation.call(this, breakout, breakout.name, mountModal); + // There's a race condition on page load with modals. Only one modal can be shown at a + // time and new ones overwrite old ones. We delay the opening of the breakout modal + // because it should always be on top if breakouts are running. + setTimeout(() => { + openBreakoutJoinConfirmation.call(this, breakout, breakout.name, mountModal); + }, BREAKOUT_MODAL_DELAY); } render() { @@ -54,4 +117,7 @@ class BreakoutRoomInvitation extends Component { } } +BreakoutRoomInvitation.propTypes = propTypes; +BreakoutRoomInvitation.defaultProps = defaultProps; + export default withModalMounter(BreakoutRoomInvitation); diff --git a/bigbluebutton-html5/imports/ui/components/breakout-room/invitation/container.jsx b/bigbluebutton-html5/imports/ui/components/breakout-room/invitation/container.jsx index 5790bf96636af4b5303edaab673bf22f81e74ef3..5409394dbd142b47c81a930145d2a7130dff9e48 100644 --- a/bigbluebutton-html5/imports/ui/components/breakout-room/invitation/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/breakout-room/invitation/container.jsx @@ -3,14 +3,19 @@ import { withTracker } from 'meteor/react-meteor-data'; import BreakoutRoomInvitation from './component'; import BreakoutService from '../service'; import Auth from '/imports/ui/services/auth'; +import AppService from '/imports/ui/components/app/service'; -const BreakoutRoomInvitationContainer = props => ( - <BreakoutRoomInvitation {...props} /> -); +const BreakoutRoomInvitationContainer = ({ isMeetingBreakout, ...props }) => { + if (isMeetingBreakout) return null; + return ( + <BreakoutRoomInvitation {...props} /> + ); +}; export default withTracker(() => ({ - breakouts: BreakoutService.getBreakouts(), + isMeetingBreakout: AppService.meetingIsBreakout(), + breakouts: BreakoutService.getBreakoutsNoTime(), getBreakoutByUser: BreakoutService.getBreakoutByUser, currentBreakoutUser: BreakoutService.getBreakoutUserByUserId(Auth.userID), - getBreakoutByUserId: BreakoutService.getBreakoutByUserId, + breakoutUserIsIn: BreakoutService.getBreakoutUserIsIn(Auth.userID), }))(BreakoutRoomInvitationContainer); diff --git a/bigbluebutton-html5/imports/ui/components/breakout-room/service.js b/bigbluebutton-html5/imports/ui/components/breakout-room/service.js index d2b04f6da22dfa8c2dcb5a6299015983a047861a..28d549d134b134eb3dd261ec8bf1c0b26746dfa1 100644 --- a/bigbluebutton-html5/imports/ui/components/breakout-room/service.js +++ b/bigbluebutton-html5/imports/ui/components/breakout-room/service.js @@ -60,12 +60,10 @@ const amIModerator = () => { return User.role === ROLE_MODERATOR; }; -const getNumUsersByBreakoutId = breakoutId => Users.find({ - meetingId: breakoutId, - connectionStatus: 'online', -}, { fields: {} }).count(); - -const getBreakoutByUserId = userId => Breakouts.find({ 'users.userId': userId }).fetch(); +const getBreakoutByUserId = userId => Breakouts.find( + { 'users.userId': userId }, + { fields: { timeRemaining: 0 } }, +).fetch(); const getBreakoutByUser = user => Breakouts.findOne({ users: user }); @@ -87,8 +85,21 @@ const getBreakoutUserByUserId = userId => fp.pipe( )(userId); const getBreakouts = () => Breakouts.find({}, { sort: { sequence: 1 } }).fetch(); +const getBreakoutsNoTime = () => Breakouts.find( + {}, + { + sort: { sequence: 1 }, + fields: { timeRemaining: 0 }, + }, +).fetch(); + +const getBreakoutUserIsIn = userId => Breakouts.findOne({ 'joinedUsers.userId': new RegExp(`^${userId}`) }, { fields: { sequence: 1 } }); -const isUserInBreakoutRoom = breakoutId => Breakouts.find({ breakoutId, 'joinedUsers.userId': new RegExp(`^${Auth.userID}`) }).count(); +const isUserInBreakoutRoom = (joinedUsers) => { + const userId = Auth.userID; + + return !!joinedUsers.find(user => user.userId.startsWith(userId)); +}; export default { findBreakouts, @@ -100,10 +111,11 @@ export default { meetingId: () => Auth.meetingID, closeBreakoutPanel, amIModerator, - getNumUsersByBreakoutId, getBreakoutUserByUserId, getBreakoutByUser, getBreakouts, + getBreakoutsNoTime, getBreakoutByUserId, + getBreakoutUserIsIn, isUserInBreakoutRoom, }; diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/container.jsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/container.jsx index 076b78d9e7e48fde3aa60c8f4910c8a2a01f71c7..c5b781f538245824b2e208f636fb4ba900b763bc 100755 --- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/container.jsx +++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-list-item/container.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { withTracker } from 'meteor/react-meteor-data'; -import Breakouts from '/imports/api/breakouts'; +import BreakoutService from '/imports/ui/components/breakout-room/service'; import Meetings from '/imports/api/meetings'; import Auth from '/imports/ui/services/auth'; import UserListItem from './component'; @@ -10,7 +10,7 @@ const UserListItemContainer = props => <UserListItem {...props} />; const isMe = intId => intId === Auth.userID; export default withTracker(({ user }) => { - const findUserInBreakout = Breakouts.findOne({ 'joinedUsers.userId': new RegExp(`^${user.userId}`) }); + const findUserInBreakout = BreakoutService.getBreakoutUserIsIn(user.userId); const breakoutSequence = (findUserInBreakout || {}).sequence; const Meeting = Meetings.findOne({ meetingId: Auth.meetingID }, { fields: { lockSettingsProps: 1 } });