From 7f2f27b858a63706c7996c9024b0ba83ba6083d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Sun, 22 Mar 2020 00:43:47 +0100 Subject: [PATCH] Add support for moderating rooms. Kick user, mute all users, stop all videos. --- app/src/RoomClient.js | 176 +++++++++++++++++- app/src/actions/meActions.js | 12 ++ app/src/actions/peerActions.js | 19 ++ app/src/actions/roomActions.js | 12 ++ .../ParticipantList/ListModerator.js | 101 ++++++++++ .../MeetingDrawer/ParticipantList/ListPeer.js | 58 ++---- .../ParticipantList/ParticipantList.js | 25 ++- app/src/reducers/me.js | 18 ++ app/src/reducers/peers.js | 21 +++ app/src/reducers/room.js | 6 + app/src/reducers/userRoles.js | 4 + server/lib/Lobby.js | 8 +- server/lib/Peer.js | 7 +- server/lib/Room.js | 97 +++++++++- server/userRoles.js | 8 +- 15 files changed, 515 insertions(+), 57 deletions(-) create mode 100644 app/src/components/MeetingDrawer/ParticipantList/ListModerator.js create mode 100644 app/src/reducers/userRoles.js diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index 1efcab2..4edc5c3 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -1126,6 +1126,66 @@ export default class RoomClient lobbyPeerActions.setLobbyPeerPromotionInProgress(peerId, false)); } + async kickPeer(peerId) + { + logger.debug('kickPeer() [peerId:"%s"]', peerId); + + store.dispatch( + peerActions.setPeerKickInProgress(peerId, true)); + + try + { + await this.sendRequest('moderator:kickPeer', { peerId }); + } + catch (error) + { + logger.error('kickPeer() failed: %o', error); + } + + store.dispatch( + peerActions.setPeerKickInProgress(peerId, false)); + } + + async muteAllPeers() + { + logger.debug('muteAllPeers()'); + + store.dispatch( + roomActions.setMuteAllInProgress(true)); + + try + { + await this.sendRequest('moderator:muteAll'); + } + catch (error) + { + logger.error('muteAllPeers() failed: %o', error); + } + + store.dispatch( + roomActions.setMuteAllInProgress(false)); + } + + async stopAllPeerVideo() + { + logger.debug('stopAllPeerVideo()'); + + store.dispatch( + roomActions.setStopAllVideoInProgress(true)); + + try + { + await this.sendRequest('moderator:stopAllVideo'); + } + catch (error) + { + logger.error('stopAllPeerVideo() failed: %o', error); + } + + store.dispatch( + roomActions.setStopAllVideoInProgress(false)); + } + // type: mic/webcam/screen // mute: true/false async modifyPeerConsumer(peerId, type, mute) @@ -1902,10 +1962,10 @@ export default class RoomClient case 'newPeer': { - const { id, displayName, picture } = notification.data; + const { id, displayName, picture, roles } = notification.data; store.dispatch( - peerActions.addPeer({ id, displayName, picture, consumers: [] })); + peerActions.addPeer({ id, displayName, picture, roles, consumers: [] })); store.dispatch(requestActions.notify( { @@ -2004,6 +2064,96 @@ export default class RoomClient break; } + + case 'moderator:mute': + { + // const { peerId } = notification.data; + + if (this._micProducer && !this._micProducer.paused) + { + this.muteMic(); + + store.dispatch(requestActions.notify( + { + text : intl.formatMessage({ + id : 'moderator.mute', + defaultMessage : 'Moderator muted your microphone' + }) + })); + } + + break; + } + + case 'moderator:stopVideo': + { + // const { peerId } = notification.data; + + this.disableWebcam(); + this.disableScreenSharing(); + + store.dispatch(requestActions.notify( + { + text : intl.formatMessage({ + id : 'moderator.mute', + defaultMessage : 'Moderator stopped your video' + }) + })); + + break; + } + + case 'moderator:kick': + { + // Need some feedback + this.close(); + + break; + } + + case 'gotRole': + { + const { peerId, role } = notification.data; + + if (peerId === this._peerId) + { + store.dispatch(meActions.addRole({ role })); + + store.dispatch(requestActions.notify( + { + text : intl.formatMessage({ + id : 'roles.gotRole', + defaultMessage : `You got the role: ${role}` + }) + })); + } + else + store.dispatch(peerActions.addPeerRole({ peerId, role })); + + break; + } + + case 'lostRole': + { + const { peerId, role } = notification.data; + + if (peerId === this._peerId) + { + store.dispatch(meActions.removeRole({ role })); + + store.dispatch(requestActions.notify( + { + text : intl.formatMessage({ + id : 'roles.lostRole', + defaultMessage : `You lost the role: ${role}` + }) + })); + } + else + store.dispatch(peerActions.removePeerRole({ peerId, role })); + + break; + } default: { @@ -2158,7 +2308,7 @@ export default class RoomClient canShareFiles : this._torrentSupport })); - const { peers } = await this.sendRequest( + const { roles, peers } = await this.sendRequest( 'join', { displayName : displayName, @@ -2166,7 +2316,25 @@ export default class RoomClient rtpCapabilities : this._mediasoupDevice.rtpCapabilities }); - logger.debug('_joinRoom() joined, got peers [peers:"%o"]', peers); + logger.debug('_joinRoom() joined [peers:"%o", roles:"%o"]', peers, roles); + + const myRoles = store.getState().me.roles; + + for (const role of roles) + { + if (!myRoles.includes(role)) + { + store.dispatch(meActions.addRole({ role })); + + store.dispatch(requestActions.notify( + { + text : intl.formatMessage({ + id : 'roles.gotRole', + defaultMessage : `You got the role: ${role}` + }) + })); + } + } for (const peer of peers) { diff --git a/app/src/actions/meActions.js b/app/src/actions/meActions.js index b704880..bdff144 100644 --- a/app/src/actions/meActions.js +++ b/app/src/actions/meActions.js @@ -10,6 +10,18 @@ export const loggedIn = (flag) => payload : { flag } }); +export const addRole = ({ role }) => + ({ + type : 'ADD_ROLE', + payload : { role } + }); + +export const removeRole = (role) => + ({ + type : 'REMOVE_ROLE', + payload : { role } + }); + export const setPicture = (picture) => ({ type : 'SET_PICTURE', diff --git a/app/src/actions/peerActions.js b/app/src/actions/peerActions.js index 4caca32..1a87151 100644 --- a/app/src/actions/peerActions.js +++ b/app/src/actions/peerActions.js @@ -45,3 +45,22 @@ export const setPeerPicture = (peerId, picture) => type : 'SET_PEER_PICTURE', payload : { peerId, picture } }); + + +export const addPeerRole = (peerId, role) => + ({ + type : 'ADD_PEER_ROLE', + payload : { peerId, role } + }); + +export const removePeerRole = (peerId, role) => + ({ + type : 'REMOVE_PEER_ROLE', + payload : { peerId, role } + }); + +export const setPeerKickInProgress = (peerId, flag) => + ({ + type : 'SET_PEER_KICK_IN_PROGRESS', + payload : { peerId, flag } + }); diff --git a/app/src/actions/roomActions.js b/app/src/actions/roomActions.js index 560c77d..9ad1e08 100644 --- a/app/src/actions/roomActions.js +++ b/app/src/actions/roomActions.js @@ -109,4 +109,16 @@ export const toggleConsumerFullscreen = (consumerId) => ({ type : 'TOGGLE_FULLSCREEN_CONSUMER', payload : { consumerId } + }); + +export const setMuteAllInProgress = (flag) => + ({ + type : 'MUTE_ALL_IN_PROGRESS', + payload : { flag } + }); + +export const setStopAllVideoInProgress = (flag) => + ({ + type : 'STOP_ALL_VIDEO_IN_PROGRESS', + payload : { flag } }); \ No newline at end of file diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListModerator.js b/app/src/components/MeetingDrawer/ParticipantList/ListModerator.js new file mode 100644 index 0000000..66f64bd --- /dev/null +++ b/app/src/components/MeetingDrawer/ParticipantList/ListModerator.js @@ -0,0 +1,101 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { withStyles } from '@material-ui/core/styles'; +import PropTypes from 'prop-types'; +import { withRoomContext } from '../../../RoomContext'; +import { useIntl, FormattedMessage } from 'react-intl'; +import Button from '@material-ui/core/Button'; + +const styles = (theme) => + ({ + root : + { + padding : theme.spacing(1), + width : '100%', + overflow : 'hidden', + cursor : 'auto', + display : 'flex' + }, + actionButtons : + { + display : 'flex' + }, + divider : + { + marginLeft : theme.spacing(2) + } + }); + +const ListModerator = (props) => +{ + const intl = useIntl(); + + const { + roomClient, + room, + classes + } = props; + + return ( +
+ +
+ +
+ ); +}; + +ListModerator.propTypes = +{ + roomClient : PropTypes.any.isRequired, + room : PropTypes.object.isRequired, + classes : PropTypes.object.isRequired +}; + +const mapStateToProps = (state) => ({ + room : state.room +}); + +export default withRoomContext(connect( + mapStateToProps, + null, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.room === next.room + ); + } + } +)(withStyles(styles)(ListModerator))); \ No newline at end of file diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js index 9f74e65..26a8ab3 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js @@ -12,6 +12,7 @@ import MicIcon from '@material-ui/icons/Mic'; import MicOffIcon from '@material-ui/icons/MicOff'; import ScreenIcon from '@material-ui/icons/ScreenShare'; import ScreenOffIcon from '@material-ui/icons/StopScreenShare'; +import ExitIcon from '@material-ui/icons/ExitToApp'; import EmptyAvatar from '../../../images/avatar-empty.jpeg'; import HandIcon from '../../../images/icon-hand-white.svg'; @@ -91,40 +92,6 @@ const styles = (theme) => flexDirection : 'row', justifyContent : 'flex-start', alignItems : 'center' - }, - button : - { - flex : '0 0 auto', - margin : '0.3rem', - borderRadius : 2, - backgroundColor : 'rgba(0, 0, 0, 0.5)', - cursor : 'pointer', - transitionProperty : 'opacity, background-color', - transitionDuration : '0.15s', - width : 'var(--media-control-button-size)', - height : 'var(--media-control-button-size)', - opacity : 0.85, - '&:hover' : - { - opacity : 1 - }, - '&.unsupported' : - { - pointerEvents : 'none' - }, - '&.disabled' : - { - pointerEvents : 'none', - backgroundColor : 'var(--media-control-botton-disabled)' - }, - '&.on' : - { - backgroundColor : 'var(--media-control-botton-on)' - }, - '&.off' : - { - backgroundColor : 'var(--media-control-botton-off)' - } } }); @@ -134,6 +101,7 @@ const ListPeer = (props) => const { roomClient, + isModerator, peer, micConsumer, screenConsumer, @@ -185,9 +153,8 @@ const ListPeer = (props) => })} color={ screenVisible ? 'primary' : 'secondary'} disabled={ peer.peerScreenInProgress } - onClick={(e) => + onClick={() => { - e.stopPropagation(); screenVisible ? roomClient.modifyPeerConsumer(peer.id, 'screen', true) : roomClient.modifyPeerConsumer(peer.id, 'screen', false); @@ -207,9 +174,8 @@ const ListPeer = (props) => })} color={ micEnabled ? 'primary' : 'secondary'} disabled={ peer.peerAudioInProgress } - onClick={(e) => + onClick={() => { - e.stopPropagation(); micEnabled ? roomClient.modifyPeerConsumer(peer.id, 'mic', true) : roomClient.modifyPeerConsumer(peer.id, 'mic', false); @@ -221,6 +187,21 @@ const ListPeer = (props) => } + { isModerator && + + { + roomClient.kickPeer(peer.id); + }} + > + + + }
); @@ -230,6 +211,7 @@ ListPeer.propTypes = { roomClient : PropTypes.any.isRequired, advancedMode : PropTypes.bool, + isModerator : 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 3313d2b..01dbf9d 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js @@ -11,7 +11,9 @@ import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; import ListPeer from './ListPeer'; import ListMe from './ListMe'; +import ListModerator from './ListModerator'; import Volume from '../../Containers/Volume'; +import * as userRoles from '../../../reducers/userRoles'; const styles = (theme) => ({ @@ -76,6 +78,7 @@ class ParticipantList extends React.PureComponent const { roomClient, advancedMode, + isModerator, passivePeers, selectedPeerId, spotlightPeers, @@ -84,6 +87,17 @@ class ParticipantList extends React.PureComponent return (
{ this.node = node; }}> + { isModerator && + + } @@ -142,6 +156,7 @@ ParticipantList.propTypes = { roomClient : PropTypes.any.isRequired, advancedMode : PropTypes.bool, + isModerator : PropTypes.bool, passivePeers : PropTypes.array, selectedPeerId : PropTypes.string, spotlightPeers : PropTypes.array, @@ -150,7 +165,12 @@ ParticipantList.propTypes = const mapStateToProps = (state) => { + const isModerator = + state.me.roles.includes(userRoles.MODERATOR) || + state.me.roles.includes(userRoles.ADMIN); + return { + isModerator, passivePeers : passivePeersSelector(state), selectedPeerId : state.room.selectedPeerId, spotlightPeers : spotlightPeersSelector(state) @@ -165,6 +185,7 @@ const ParticipantListContainer = withRoomContext(connect( areStatesEqual : (next, prev) => { return ( + prev.me.roles === next.me.roles && prev.peers === next.peers && prev.room.spotlights === next.room.spotlights && prev.room.selectedPeerId === next.room.selectedPeerId diff --git a/app/src/reducers/me.js b/app/src/reducers/me.js index e80375a..4bad22f 100644 --- a/app/src/reducers/me.js +++ b/app/src/reducers/me.js @@ -1,7 +1,10 @@ +import * as userRoles from './userRoles'; + const initialState = { id : null, picture : null, + roles : [ userRoles.ALL ], canSendMic : false, canSendWebcam : false, canShareScreen : false, @@ -43,6 +46,21 @@ const me = (state = initialState, action) => return { ...state, loggedIn: flag }; } + case 'ADD_ROLE': + { + const roles = [ ...state.roles, action.payload.role ]; + + return { ...state, roles }; + } + + case 'REMOVE_ROLE': + { + const roles = state.roles.filter((role) => + role !== action.payload.role); + + return { ...state, roles }; + } + case 'SET_PICTURE': return { ...state, picture: action.payload.picture }; diff --git a/app/src/reducers/peers.js b/app/src/reducers/peers.js index ddd104f..e3d207d 100644 --- a/app/src/reducers/peers.js +++ b/app/src/reducers/peers.js @@ -16,6 +16,9 @@ const peer = (state = {}, action) => case 'SET_PEER_SCREEN_IN_PROGRESS': return { ...state, peerScreenInProgress: action.payload.flag }; + + case 'SET_PEER_KICK_IN_PROGRESS': + return { ...state, peerKickInProgress: action.payload.flag }; case 'SET_PEER_RAISE_HAND_STATE': return { ...state, raiseHandState: action.payload.raiseHandState }; @@ -40,6 +43,21 @@ const peer = (state = {}, action) => return { ...state, picture: action.payload.picture }; } + case 'ADD_PEER_ROLE': + { + const roles = [ ...state.roles, action.payload.role ]; + + return { ...state, roles }; + } + + case 'REMOVE_PEER_ROLE': + { + const roles = state.roles.filter((role) => + role !== action.payload.role); + + return { ...state, roles }; + } + default: return state; } @@ -71,6 +89,8 @@ const peers = (state = {}, action) => case 'SET_PEER_RAISE_HAND_STATE': case 'SET_PEER_PICTURE': case 'ADD_CONSUMER': + case 'ADD_PEER_ROLE': + case 'REMOVE_PEER_ROLE': { const oldPeer = state[action.payload.peerId]; @@ -82,6 +102,7 @@ const peers = (state = {}, action) => return { ...state, [oldPeer.id]: peer(oldPeer, action) }; } + case 'SET_PEER_KICK_IN_PROGRESS': case 'REMOVE_CONSUMER': { const oldPeer = state[action.payload.peerId]; diff --git a/app/src/reducers/room.js b/app/src/reducers/room.js index c4b29fb..9d483b8 100644 --- a/app/src/reducers/room.js +++ b/app/src/reducers/room.js @@ -163,6 +163,12 @@ const room = (state = initialState, action) => return { ...state, spotlights }; } + case 'MUTE_ALL_IN_PROGRESS': + return { ...state, muteAllInProgress: action.payload.flag }; + + case 'STOP_ALL_VIDEO_IN_PROGRESS': + return { ...state, stopAllVideoInProgress: action.payload.flag }; + default: return state; } diff --git a/app/src/reducers/userRoles.js b/app/src/reducers/userRoles.js new file mode 100644 index 0000000..217a760 --- /dev/null +++ b/app/src/reducers/userRoles.js @@ -0,0 +1,4 @@ +export const ADMIN = 'admin'; +export const MODERATOR = 'moderator'; +export const AUTHENTICATED = 'authenticated'; +export const ALL = 'normal'; \ No newline at end of file diff --git a/server/lib/Lobby.js b/server/lib/Lobby.js index 7372455..b5d9fb6 100644 --- a/server/lib/Lobby.js +++ b/server/lib/Lobby.js @@ -75,13 +75,13 @@ class Lobby extends EventEmitter if (peer) { peer.socket.removeListener('request', peer.socketRequestHandler); - peer.removeListener('rolesChange', peer.roleChangeHandler); + peer.removeListener('gotRole', peer.gotRoleHandler); peer.removeListener('displayNameChanged', peer.displayNameChangeHandler); peer.removeListener('pictureChanged', peer.pictureChangeHandler); peer.removeListener('close', peer.closeHandler); peer.socketRequestHandler = null; - peer.roleChangeHandler = null; + peer.gotRoleHandler = null; peer.displayNameChangeHandler = null; peer.pictureChangeHandler = null; peer.closeHandler = null; @@ -116,7 +116,7 @@ class Lobby extends EventEmitter }); }; - peer.roleChangeHandler = () => + peer.gotRoleHandler = () => { logger.info('parkPeer() | rolesChange [peer:"%s"]', peer.id); @@ -156,7 +156,7 @@ class Lobby extends EventEmitter this._peers.set(peer.id, peer); - peer.on('rolesChange', peer.roleChangeHandler); + peer.on('gotRole', peer.gotRoleHandler); peer.on('displayNameChanged', peer.displayNameChangeHandler); peer.on('pictureChanged', peer.pictureChangeHandler); diff --git a/server/lib/Peer.js b/server/lib/Peer.js index 93ee9b8..2b63c87 100644 --- a/server/lib/Peer.js +++ b/server/lib/Peer.js @@ -224,7 +224,7 @@ class Peer extends EventEmitter logger.info('addRole() | [newRole:"%s]"', newRole); - this.emit('rolesChange', { newRole }); + this.emit('gotRole', { newRole }); } } @@ -236,7 +236,7 @@ class Peer extends EventEmitter logger.info('removeRole() | [oldRole:"%s]"', oldRole); - this.emit('rolesChange', { oldRole }); + this.emit('lostRole', { oldRole }); } } @@ -302,7 +302,8 @@ class Peer extends EventEmitter { id : this.id, displayName : this.displayName, - picture : this.picture + picture : this.picture, + roles : this.roles }; return peerInfo; diff --git a/server/lib/Room.js b/server/lib/Room.js index 140d4ee..b397ae1 100644 --- a/server/lib/Room.js +++ b/server/lib/Room.js @@ -420,6 +420,32 @@ class Room extends EventEmitter picture : peer.picture }, true); }); + + peer.on('gotRole', ({ newRole }) => + { + // Ensure the Peer is joined. + if (!peer.joined) + return; + + // Spread to others + this._notification(peer.socket, 'gotRole', { + peerId : peer.id, + role : newRole + }, true); + }); + + peer.on('lostRole', ({ oldRole }) => + { + // Ensure the Peer is joined. + if (!peer.joined) + return; + + // Spread to others + this._notification(peer.socket, 'lostRole', { + peerId : peer.id, + role : oldRole + }, true); + }); } async _handleSocketRequest(peer, request, cb) @@ -483,7 +509,10 @@ class Room extends EventEmitter .filter((joinedPeer) => joinedPeer.id !== peer.id) .map((joinedPeer) => (joinedPeer.peerInfo)); - cb(null, { peers: peerInfos }); + cb(null, { + roles : peer.roles, + peers : peerInfos + }); // Mark the new Peer as joined. peer.joined = true; @@ -511,7 +540,8 @@ class Room extends EventEmitter { id : peer.id, displayName : displayName, - picture : picture + picture : picture, + roles : peer.roles } ); } @@ -1077,6 +1107,69 @@ class Room extends EventEmitter break; } + case 'moderator:muteAll': + { + if ( + !peer.hasRole(userRoles.MODERATOR) && + !peer.hasRole(userRoles.ADMIN) + ) + throw new Error('peer does not have moderator priveleges'); + + // Spread to others + this._notification(peer.socket, 'moderator:mute', { + peerId : peer.id + }, true); + + cb(); + + break; + } + + case 'moderator:stopAllVideo': + { + if ( + !peer.hasRole(userRoles.MODERATOR) && + !peer.hasRole(userRoles.ADMIN) + ) + throw new Error('peer does not have moderator priveleges'); + + // Spread to others + this._notification(peer.socket, 'moderator:stopVideo', { + peerId : peer.id + }, true); + + cb(); + + break; + } + + case 'moderator:kickPeer': + { + if ( + !peer.hasRole(userRoles.MODERATOR) && + !peer.hasRole(userRoles.ADMIN) + ) + throw new Error('peer does not have moderator priveleges'); + + const { peerId } = request.data; + + const kickPeer = this._peers[peerId]; + + if (!kickPeer) + throw new Error(`peer with id "${peerId}" not found`); + + this._notification( + kickPeer.socket, + 'moderator:kick' + ); + + kickPeer.close(); + + cb(); + + break; + } + default: { logger.error('unknown request.method "%s"', request.method); diff --git a/server/userRoles.js b/server/userRoles.js index ba5fd59..c8cf886 100644 --- a/server/userRoles.js +++ b/server/userRoles.js @@ -1,12 +1,12 @@ module.exports = { // Allowed to enter locked rooms + all other priveleges - ADMIN : 0, + ADMIN : 'admin', // Allowed to enter restricted rooms if configured. // Allowed to moderate users in a room (mute all, // spotlight video, kick users) - MODERATOR : 1, + MODERATOR : 'moderator', // Same as MODERATOR, but can't moderate users - AUTHENTICATED : 2, + AUTHENTICATED : 'authenticated', // No priveleges - ALL : 3 + ALL : 'normal' }; \ No newline at end of file