diff --git a/app/package.json b/app/package.json index ec92c5bd..fae4e97c 100644 --- a/app/package.json +++ b/app/package.json @@ -27,6 +27,7 @@ "react": "^16.10.2", "react-cookie-consent": "^2.5.0", "react-dom": "^16.10.2", + "react-flip-toolkit": "^7.0.9", "react-intl": "^3.4.0", "react-redux": "^7.1.1", "react-router-dom": "^5.1.2", diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index 9ce89aa3..aea0c74a 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -31,8 +31,7 @@ let Spotlights; let requestTimeout, transportOptions, lastN, - mobileLastN, - defaultResolution; + mobileLastN; if (process.env.NODE_ENV !== 'test') { @@ -40,8 +39,7 @@ if (process.env.NODE_ENV !== 'test') requestTimeout, transportOptions, lastN, - mobileLastN, - defaultResolution + mobileLastN } = window.config); } @@ -205,9 +203,6 @@ export default class RoomClient // Our WebTorrent client this._webTorrent = null; - if (defaultResolution) - store.dispatch(settingsActions.setVideoResolution(defaultResolution)); - // Max spotlights if (device.platform === 'desktop') this._maxSpotlights = lastN; @@ -1111,6 +1106,37 @@ export default class RoomClient meActions.setAudioOutputInProgress(false)); } + async changeAudioOutputDevice(deviceId) + { + logger.debug('changeAudioOutputDevice() [deviceId: %s]', deviceId); + + store.dispatch( + meActions.setAudioOutputInProgress(true)); + + try + { + const device = this._audioOutputDevices[deviceId]; + + if (!device) + throw new Error('Selected audio output device no longer avaibale'); + + logger.debug( + 'changeAudioOutputDevice() | new selected [audio output device:%o]', + device); + + store.dispatch(settingsActions.setSelectedAudioOutputDevice(deviceId)); + + await this._updateAudioOutputDevices(); + } + catch (error) + { + logger.error('changeAudioOutputDevice() failed: %o', error); + } + + store.dispatch( + meActions.setAudioOutputInProgress(false)); + } + async changeVideoResolution(resolution) { logger.debug('changeVideoResolution() [resolution: %s]', resolution); @@ -1538,6 +1564,26 @@ export default class RoomClient } } + async lowerPeerHand(peerId) + { + logger.debug('lowerPeerHand() [peerId:"%s"]', peerId); + + store.dispatch( + peerActions.setPeerRaisedHandInProgress(peerId, true)); + + try + { + await this.sendRequest('moderator:lowerHand', { peerId }); + } + catch (error) + { + logger.error('lowerPeerHand() | [error:"%o"]', error); + } + + store.dispatch( + peerActions.setPeerRaisedHandInProgress(peerId, false)); + } + async setRaisedHand(raisedHand) { logger.debug('setRaisedHand: ', raisedHand); @@ -1780,6 +1826,49 @@ export default class RoomClient this._recvTransport = null; } + store.dispatch(roomActions.setRoomState('connecting')); + }); + + store.dispatch( + producerActions.removeProducer(this._screenSharingProducer.id)); + + this._screenSharingProducer = null; + } + + if (this._webcamProducer) + { + this._webcamProducer.close(); + + store.dispatch( + producerActions.removeProducer(this._webcamProducer.id)); + + this._webcamProducer = null; + } + + if (this._micProducer) + { + this._micProducer.close(); + + store.dispatch( + producerActions.removeProducer(this._micProducer.id)); + + this._micProducer = null; + } + + if (this._sendTransport) + { + this._sendTransport.close(); + + this._sendTransport = null; + } + + if (this._recvTransport) + { + this._recvTransport.close(); + + this._recvTransport = null; + } + store.dispatch(roomActions.setRoomState('connecting')); }); @@ -2539,6 +2628,13 @@ export default class RoomClient break; } + case 'moderator:lowerHand': + { + this.setRaisedHand(false); + + break; + } + case 'gotRole': { const { peerId, role } = notification.data; diff --git a/app/src/actions/peerActions.js b/app/src/actions/peerActions.js index fee30a54..414b744d 100644 --- a/app/src/actions/peerActions.js +++ b/app/src/actions/peerActions.js @@ -40,6 +40,12 @@ export const setPeerRaisedHand = (peerId, raisedHand, raisedHandTimestamp) => payload : { peerId, raisedHand, raisedHandTimestamp } }); +export const setPeerRaisedHandInProgress = (peerId, flag) => + ({ + type : 'SET_PEER_RAISED_HAND_IN_PROGRESS', + payload : { peerId, flag } + }); + export const setPeerPicture = (peerId, picture) => ({ type : 'SET_PEER_PICTURE', diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListMe.js b/app/src/components/MeetingDrawer/ParticipantList/ListMe.js index d230db26..33873d20 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ListMe.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ListMe.js @@ -2,6 +2,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { withStyles } from '@material-ui/core/styles'; import { withRoomContext } from '../../../RoomContext'; +import classnames from 'classnames'; import PropTypes from 'prop-types'; import * as appPropTypes from '../../appPropTypes'; import { useIntl } from 'react-intl'; @@ -23,7 +24,7 @@ const styles = (theme) => { borderRadius : '50%', height : '2rem', - marginTop : theme.spacing(1) + marginTop : theme.spacing(0.5) }, peerInfo : { @@ -33,6 +34,10 @@ const styles = (theme) => flexGrow : 1, alignItems : 'center' }, + buttons : + { + padding : theme.spacing(1) + }, green : { color : 'rgba(0, 153, 0, 1)' @@ -71,7 +76,9 @@ const ListMe = (props) => id : 'tooltip.raisedHand', defaultMessage : 'Raise hand' })} - className={me.raisedHand ? classes.green : null} + className={ + classnames(me.raisedHand ? classes.green : null, classes.buttons) + } disabled={me.raisedHandInProgress} color='primary' onClick={(e) => diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js index 1aa70a11..d8b3fb31 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js @@ -6,6 +6,7 @@ import PropTypes from 'prop-types'; import * as appPropTypes from '../../appPropTypes'; import { withRoomContext } from '../../../RoomContext'; import { useIntl } from 'react-intl'; +import { green } from '@material-ui/core/colors'; import IconButton from '@material-ui/core/IconButton'; import Tooltip from '@material-ui/core/Tooltip'; import VideocamIcon from '@material-ui/icons/Videocam'; @@ -17,6 +18,7 @@ import ScreenOffIcon from '@material-ui/icons/StopScreenShare'; import ExitIcon from '@material-ui/icons/ExitToApp'; import EmptyAvatar from '../../../images/avatar-empty.jpeg'; import PanIcon from '@material-ui/icons/PanTool'; +import RecordVoiceOverIcon from '@material-ui/icons/RecordVoiceOver'; const styles = (theme) => ({ @@ -31,7 +33,7 @@ const styles = (theme) => { borderRadius : '50%', height : '2rem', - marginTop : theme.spacing(1) + marginTop : theme.spacing(0.5) }, peerInfo : { @@ -44,11 +46,16 @@ const styles = (theme) => indicators : { display : 'flex', - padding : theme.spacing(1.5) + padding : theme.spacing(1) + }, + buttons : + { + padding : theme.spacing(1) }, green : { - color : 'rgba(0, 153, 0, 1)' + color : 'rgba(0, 153, 0, 1)', + marginLeft : theme.spacing(2) } }); @@ -59,6 +66,7 @@ const ListPeer = (props) => const { roomClient, isModerator, + spotlight, peer, micConsumer, webcamConsumer, @@ -94,11 +102,30 @@ const ListPeer = (props) =>
{peer.displayName}
-
- { peer.raisedHand && - - } -
+ { peer.raisedHand && + + { + e.stopPropagation(); + + roomClient.lowerPeerHand(peer.id); + }} + > + + + } + { spotlight && + + + + } { screenConsumer && })} color={screenVisible ? 'primary' : 'secondary'} disabled={peer.peerScreenInProgress} + className={classes.buttons} onClick={(e) => { e.stopPropagation(); @@ -145,6 +173,7 @@ const ListPeer = (props) => })} color={webcamEnabled ? 'primary' : 'secondary'} disabled={peer.peerVideoInProgress} + className={classes.buttons} onClick={(e) => { e.stopPropagation(); @@ -175,6 +204,7 @@ const ListPeer = (props) => })} color={micEnabled ? 'primary' : 'secondary'} disabled={peer.peerAudioInProgress} + className={classes.buttons} onClick={(e) => { e.stopPropagation(); @@ -205,6 +235,7 @@ const ListPeer = (props) => defaultMessage : 'Kick out participant' })} disabled={peer.peerKickInProgress} + className={classes.buttons} color='secondary' onClick={(e) => { @@ -227,6 +258,7 @@ ListPeer.propTypes = roomClient : PropTypes.any.isRequired, advancedMode : PropTypes.bool, isModerator : PropTypes.bool, + spotlight : PropTypes.bool, peer : appPropTypes.Peer.isRequired, micConsumer : appPropTypes.Consumer, webcamConsumer : appPropTypes.Consumer, diff --git a/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js b/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js index af35dbd0..07e5d241 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js @@ -1,13 +1,13 @@ import React from 'react'; import { connect } from 'react-redux'; import { - passivePeersSelector, - spotlightSortedPeersSelector + participantListSelector } from '../../Selectors'; -import classNames from 'classnames'; +import classnames from 'classnames'; import { withStyles } from '@material-ui/core/styles'; import { withRoomContext } from '../../../RoomContext'; import PropTypes from 'prop-types'; +import { Flipper, Flipped } from 'react-flip-toolkit'; import { FormattedMessage } from 'react-intl'; import ListPeer from './ListPeer'; import ListMe from './ListMe'; @@ -76,9 +76,9 @@ class ParticipantList extends React.PureComponent roomClient, advancedMode, isModerator, - passivePeers, + participants, + spotlights, selectedPeerId, - spotlightPeers, classes } = this.props; @@ -107,50 +107,42 @@ class ParticipantList extends React.PureComponent - ); @@ -162,9 +154,9 @@ ParticipantList.propTypes = roomClient : PropTypes.any.isRequired, advancedMode : PropTypes.bool, isModerator : PropTypes.bool, - passivePeers : PropTypes.array, + participants : PropTypes.array, + spotlights : PropTypes.array, selectedPeerId : PropTypes.string, - spotlightPeers : PropTypes.array, classes : PropTypes.object.isRequired }; @@ -174,9 +166,9 @@ const mapStateToProps = (state) => isModerator : state.me.roles.some((role) => state.room.permissionsFromRoles.MODERATE_ROOM.includes(role)), - passivePeers : passivePeersSelector(state), - selectedPeerId : state.room.selectedPeerId, - spotlightPeers : spotlightSortedPeersSelector(state) + participants : participantListSelector(state), + spotlights : state.room.spotlights, + selectedPeerId : state.room.selectedPeerId }; }; diff --git a/app/src/components/Selectors.js b/app/src/components/Selectors.js index fd22affb..8f59d5dd 100644 --- a/app/src/components/Selectors.js +++ b/app/src/components/Selectors.js @@ -17,6 +17,11 @@ const peersValueSelector = createSelector( (peers) => Object.values(peers) ); +export const peersValueSelector = createSelector( + peersSelector, + (peers) => Object.values(peers) +); + export const lobbyPeersKeySelector = createSelector( lobbyPeersSelector, (lobbyPeers) => Object.keys(lobbyPeers) @@ -113,8 +118,31 @@ export const spotlightPeersSelector = createSelector( export const spotlightSortedPeersSelector = createSelector( spotlightsSelector, peersValueSelector, - (spotlights, peers) => peers.filter((peer) => spotlights.includes(peer.id)) - .sort((a, b) => String(a.displayName || '').localeCompare(String(b.displayName || ''))) + (spotlights, peers) => + peers.filter((peer) => spotlights.includes(peer.id) && !peer.raisedHand) + .sort((a, b) => String(a.displayName || '').localeCompare(String(b.displayName || ''))) +); + +const raisedHandSortedPeers = createSelector( + peersValueSelector, + (peers) => peers.filter((peer) => peer.raisedHand) + .sort((a, b) => a.raisedHandTimestamp - b.raisedHandTimestamp) +); + +const peersSortedSelector = createSelector( + spotlightsSelector, + peersValueSelector, + (spotlights, peers) => + peers.filter((peer) => !spotlights.includes(peer.id) && !peer.raisedHand) + .sort((a, b) => String(a.displayName || '').localeCompare(String(b.displayName || ''))) +); + +export const participantListSelector = createSelector( + raisedHandSortedPeers, + spotlightSortedPeersSelector, + peersSortedSelector, + (raisedHands, spotlights, peers) => + [ ...raisedHands, ...spotlights, ...peers ] ); export const peersLengthSelector = createSelector( diff --git a/app/src/reducers/peers.js b/app/src/reducers/peers.js index 4c8bee1a..3e2c6a03 100644 --- a/app/src/reducers/peers.js +++ b/app/src/reducers/peers.js @@ -26,6 +26,12 @@ const peer = (state = {}, action) => raisedHand : action.payload.raisedHand, raisedHandTimestamp : action.payload.raisedHandTimestamp }; + + case 'SET_PEER_RAISED_HAND_IN_PROGRESS': + return { + ...state, + raisedHandInProgress : action.payload.flag + }; case 'ADD_CONSUMER': { @@ -91,6 +97,7 @@ const peers = (state = {}, action) => case 'SET_PEER_AUDIO_IN_PROGRESS': case 'SET_PEER_SCREEN_IN_PROGRESS': case 'SET_PEER_RAISED_HAND': + case 'SET_PEER_RAISED_HAND_IN_PROGRESS': case 'SET_PEER_PICTURE': case 'ADD_CONSUMER': case 'ADD_PEER_ROLE': diff --git a/server/lib/Room.js b/server/lib/Room.js index 0f89b4d9..69e3e318 100644 --- a/server/lib/Room.js +++ b/server/lib/Room.js @@ -117,6 +117,8 @@ class Room extends EventEmitter this._peers = {}; + this._selfDestructTimeout = null; + // Array of mediasoup Router instances. this._mediasoupRouters = mediasoupRouters; @@ -146,6 +148,11 @@ class Room extends EventEmitter this._closed = true; + if (this._selfDestructTimeout) + clearTimeout(this._selfDestructTimeout); + + this._selfDestructTimeout = null; + this._chatHistory = null; this._fileHistory = null; @@ -411,7 +418,10 @@ class Room extends EventEmitter { logger.debug('selfDestructCountdown() started'); - setTimeout(() => + if (this._selfDestructTimeout) + clearTimeout(this._selfDestructTimeout); + + this._selfDestructTimeout = setTimeout(() => { if (this._closed) return; @@ -1430,6 +1440,29 @@ class Room extends EventEmitter break; } + case 'moderator:lowerHand': + { + if ( + !peer.roles.some( + (role) => permissionsFromRoles.MODERATE_ROOM.includes(role) + ) + ) + throw new Error('peer not authorized'); + + const { peerId } = request.data; + + const lowerPeer = this._peers[peerId]; + + if (!lowerPeer) + throw new Error(`peer with id "${peerId}" not found`); + + this._notification(lowerPeer.socket, 'moderator:lowerHand'); + + cb(); + + break; + } + default: { logger.error('unknown request.method "%s"', request.method);