diff --git a/app/.env b/app/.env new file mode 100644 index 0000000..86c714e --- /dev/null +++ b/app/.env @@ -0,0 +1,2 @@ +REACT_APP_VERSION=$npm_package_version +REACT_APP_NAME=$npm_package_name \ No newline at end of file diff --git a/app/public/config/config.example.js b/app/public/config/config.example.js index 4c7f249..bbd9a03 100644 --- a/app/public/config/config.example.js +++ b/app/public/config/config.example.js @@ -37,20 +37,22 @@ var config = 'opera' ], // Socket.io request timeout - requestTimeout : 10000, + requestTimeout : 20000, + requestRetries : 3, transportOptions : { tcp : true }, defaultAudio : { - sampleRate : 48000, - channelCount : 1, - volume : 1.0, - autoGainControl : true, - echoCancellation : true, - noiseSuppression : true, - sampleSize : 16 + sampleRate : 48000, + channelCount : 1, + volume : 1.0, + autoGainControl : true, + echoCancellation : true, + noiseSuppression : true, + voiceActivityMute : false, + sampleSize : 16 }, /** diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index a936a14..7d7336c 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -1,6 +1,7 @@ import Logger from './Logger'; import hark from 'hark'; import { getSignalingUrl } from './urlFactory'; +import { SocketTimeoutError } from './utils'; import * as requestActions from './actions/requestActions'; import * as meActions from './actions/meActions'; import * as roomActions from './actions/roomActions'; @@ -314,7 +315,7 @@ export default class RoomClient { const newPeerId = this._spotlights.getNextAsSelected( store.getState().room.selectedPeerId); - + if (newPeerId) this.setSelectedPeer(newPeerId); break; } @@ -574,7 +575,7 @@ export default class RoomClient if (called) return; called = true; - callback(new Error('Request timeout.')); + callback(new SocketTimeoutError('Request timed out')); }, ROOM_OPTIONS.requestTimeout ); @@ -590,13 +591,13 @@ export default class RoomClient }; } - sendRequest(method, data) + _sendRequest(method, data) { return new Promise((resolve, reject) => { if (!this._signalingSocket) { - reject('No socket connection.'); + reject('No socket connection'); } else { @@ -606,19 +607,42 @@ export default class RoomClient this.timeoutCallback((err, response) => { if (err) - { reject(err); - } else - { resolve(response); - } }) ); } }); } + async sendRequest(method, data) + { + logger.debug('sendRequest() [method:"%s", data:"%o"]', method, data); + + const { + requestRetries = 3 + } = window.config; + + for (let tries = 0; tries < requestRetries; tries++) + { + try + { + return await this._sendRequest(method, data); + } + catch (error) + { + if ( + error instanceof SocketTimeoutError && + tries < requestRetries + ) + logger.warn('sendRequest() | timeout, retrying [attempt:"%s"]', tries); + else + throw error; + } + } + } + async changeDisplayName(displayName) { logger.debug('changeDisplayName() [displayName:"%s"]', displayName); @@ -774,7 +798,7 @@ export default class RoomClient } }); - torrent.on('done', () => + torrent.on('done', () => { store.dispatch( fileActions.setFileDone( @@ -924,7 +948,7 @@ export default class RoomClient { await this.sendRequest( 'resumeProducer', { producerId: this._micProducer.id }); - + store.dispatch( producerActions.setProducerResumed(this._micProducer.id)); } @@ -976,23 +1000,23 @@ export default class RoomClient } } - disconnectLocalHark() + disconnectLocalHark() { logger.debug('disconnectLocalHark() | Stopping harkStream.'); - if (this._harkStream != null) + if (this._harkStream != null) { this._harkStream.getAudioTracks()[0].stop(); this._harkStream = null; } - if (this._hark != null) + if (this._hark != null) { logger.debug('disconnectLocalHark() Stopping hark.'); this._hark.stop(); } } - connectLocalHark(track) + connectLocalHark(track) { logger.debug('connectLocalHark() | Track:%o', track); this._harkStream = new MediaStream(); @@ -1003,37 +1027,50 @@ export default class RoomClient if (!this._harkStream.getAudioTracks()[0]) throw new Error('getMicStream():something went wrong with hark'); - this._hark = hark(this._harkStream, { play: false }); - - // eslint-disable-next-line no-unused-vars - this._hark.on('volume_change', (dBs, threshold) => - { - // The exact formula to convert from dBs (-100..0) to linear (0..1) is: - // Math.pow(10, dBs / 20) - // However it does not produce a visually useful output, so let exagerate - // it a bit. Also, let convert it from 0..1 to 0..10 and avoid value 1 to - // minimize component renderings. - let volume = Math.round(Math.pow(10, dBs / 85) * 10); - - if (volume === 1) - volume = 0; - - volume = Math.round(volume); - - if (this._micProducer && volume !== this._micProducer.volume) + this._hark = hark(this._harkStream, { - this._micProducer.volume = volume; + play : false, + interval : 5, + threshold : store.getState().settings.noiseThreshold, + history : 300 + }); + this._hark.lastVolume = -100; + this._hark.on('volume_change', (volume) => + { + volume = Math.round(volume); + if (this._micProducer && volume !== Math.round(this._hark.lastVolume)) + { + if (volume < this._hark.lastVolume * 1.02) + { + volume = this._hark.lastVolume * 1.02; + } + this._hark.lastVolume = volume; store.dispatch(peerVolumeActions.setPeerVolume(this._peerId, volume)); } }); - this._hark.on('speaking', function() + this._hark.on('speaking', () => { store.dispatch(meActions.setIsSpeaking(true)); + if ((store.getState().settings.voiceActivatedUnmute || + store.getState().me.isAutoMuted) && + this._micProducer && + this._micProducer.paused) + { + this._micProducer.resume(); + } + store.dispatch(meActions.setAutoMuted(false)); // sanity action }); - this._hark.on('stopped_speaking', function() + this._hark.on('stopped_speaking', () => { store.dispatch(meActions.setIsSpeaking(false)); + if (store.getState().settings.voiceActivatedUnmute && + this._micProducer && + !this._micProducer.paused) + { + this._micProducer.pause(); + store.dispatch(meActions.setAutoMuted(true)); + } }); } @@ -1045,7 +1082,7 @@ export default class RoomClient meActions.setAudioInProgress(true)); try - { + { const device = this._audioDevices[deviceId]; if (!device) @@ -1164,7 +1201,7 @@ export default class RoomClient ...VIDEO_CONSTRAINS[resolution] } }); - + if (stream) { const track = stream.getVideoTracks()[0]; @@ -1175,15 +1212,15 @@ export default class RoomClient { await this._webcamProducer.replaceTrack({ track }); } - else + else { this._webcamProducer = await this._sendTransport.produce({ track, - appData : + appData : { source : 'webcam' } - }); + }); } store.dispatch( @@ -1193,7 +1230,7 @@ export default class RoomClient { logger.warn('getVideoTracks Error: First Video Track is null'); } - + } else { @@ -1227,7 +1264,7 @@ export default class RoomClient if (!device) throw new Error('no webcam devices'); - + logger.debug( 'changeWebcam() | new selected webcam [device:%o]', device); @@ -1255,17 +1292,17 @@ export default class RoomClient { await this._webcamProducer.replaceTrack({ track }); } - else + else { this._webcamProducer = await this._sendTransport.produce({ track, - appData : + appData : { source : 'webcam' } - }); + }); } - + store.dispatch( producerActions.setProducerTrack(this._webcamProducer.id, track)); @@ -1274,7 +1311,7 @@ export default class RoomClient { logger.warn('getVideoTracks Error: First Video Track is null'); } - + } else { @@ -1962,23 +1999,12 @@ export default class RoomClient producerPaused } = request.data; - let codecOptions; - - if (kind === 'audio') - { - codecOptions = - { - opusStereo : 1 - }; - } - const consumer = await this._recvTransport.consume( { id, producerId, kind, rtpParameters, - codecOptions, appData : { ...appData, peerId } // Trick. }); @@ -2031,19 +2057,8 @@ export default class RoomClient consumer.hark = hark(stream, { play: false }); - // eslint-disable-next-line no-unused-vars - consumer.hark.on('volume_change', (dBs, threshold) => + consumer.hark.on('volume_change', (volume) => { - // The exact formula to convert from dBs (-100..0) to linear (0..1) is: - // Math.pow(10, dBs / 20) - // However it does not produce a visually useful output, so let exaggerate - // it a bit. Also, let convert it from 0..1 to 0..10 and avoid value 1 to - // minimize component renderings. - let volume = Math.round(Math.pow(10, dBs / 85) * 10); - - if (volume === 1) - volume = 0; - volume = Math.round(volume); if (consumer && volume !== consumer.volume) @@ -2083,7 +2098,7 @@ export default class RoomClient const { displayName } = store.getState().settings; const { picture } = store.getState().me; - + await this.sendRequest('changeDisplayName', { displayName }); await this.sendRequest('changePicture', { picture }); break; @@ -2092,10 +2107,10 @@ export default class RoomClient case 'signInRequired': { store.dispatch(roomActions.setSignInRequired(true)); - + break; } - + case 'overRoomLimit': { store.dispatch(roomActions.setOverRoomLimit(true)); @@ -2111,24 +2126,24 @@ export default class RoomClient store.dispatch(roomActions.toggleJoined()); store.dispatch(roomActions.setInLobby(false)); - + await this._joinRoom({ joinVideo }); - + break; } case 'roomBack': { await this._joinRoom({ joinVideo }); - + break; } - + case 'lockRoom': { store.dispatch( roomActions.setRoomLocked()); - + store.dispatch(requestActions.notify( { text : intl.formatMessage({ @@ -2136,15 +2151,15 @@ export default class RoomClient defaultMessage : 'Room is now locked' }) })); - + break; } - + case 'unlockRoom': { store.dispatch( roomActions.setRoomUnLocked()); - + store.dispatch(requestActions.notify( { text : intl.formatMessage({ @@ -2152,21 +2167,21 @@ export default class RoomClient defaultMessage : 'Room is now unlocked' }) })); - + break; } - + case 'parkedPeer': { const { peerId } = notification.data; - + store.dispatch( lobbyPeerActions.addLobbyPeer(peerId)); store.dispatch( roomActions.setToolbarsVisible(true)); this._soundNotification(); - + store.dispatch(requestActions.notify( { text : intl.formatMessage({ @@ -2174,7 +2189,7 @@ export default class RoomClient defaultMessage : 'New participant entered the lobby' }) })); - + break; } @@ -2203,7 +2218,7 @@ export default class RoomClient ) ); }); - + store.dispatch( roomActions.setToolbarsVisible(true)); @@ -2220,14 +2235,14 @@ export default class RoomClient break; } - + case 'lobby:peerClosed': { const { peerId } = notification.data; - + store.dispatch( lobbyPeerActions.removeLobbyPeer(peerId)); - + store.dispatch(requestActions.notify( { text : intl.formatMessage({ @@ -2235,10 +2250,10 @@ export default class RoomClient defaultMessage : 'Participant in lobby left' }) })); - + break; } - + case 'lobby:promotedPeer': { const { peerId } = notification.data; @@ -2248,7 +2263,7 @@ export default class RoomClient break; } - + case 'lobby:changeDisplayName': { const { peerId, displayName } = notification.data; @@ -2268,11 +2283,11 @@ export default class RoomClient break; } - + case 'lobby:changePicture': { const { peerId, picture } = notification.data; - + store.dispatch( lobbyPeerActions.setLobbyPeerPicture(picture, peerId)); @@ -2290,7 +2305,7 @@ export default class RoomClient case 'setAccessCode': { const { accessCode } = notification.data; - + store.dispatch( roomActions.setAccessCode(accessCode)); @@ -2304,14 +2319,14 @@ export default class RoomClient break; } - + case 'setJoinByAccessCode': { const { joinByAccessCode } = notification.data; - + store.dispatch( roomActions.setJoinByAccessCode(joinByAccessCode)); - + if (joinByAccessCode) { store.dispatch(requestActions.notify( @@ -2322,7 +2337,7 @@ export default class RoomClient }) })); } - else + else { store.dispatch(requestActions.notify( { @@ -2335,20 +2350,20 @@ export default class RoomClient break; } - + case 'activeSpeaker': { const { peerId } = notification.data; - + store.dispatch( roomActions.setRoomActiveSpeaker(peerId)); if (peerId && peerId !== this._peerId) this._spotlights.handleActiveSpeaker(peerId); - + break; } - + case 'changeDisplayName': { const { peerId, displayName, oldDisplayName } = notification.data; @@ -2556,74 +2571,74 @@ export default class RoomClient { const { consumerId } = notification.data; const consumer = this._consumers.get(consumerId); - + if (!consumer) break; - + consumer.close(); - + if (consumer.hark != null) consumer.hark.stop(); - + this._consumers.delete(consumerId); - + const { peerId } = consumer.appData; - + store.dispatch( consumerActions.removeConsumer(consumerId, peerId)); - + break; } - + case 'consumerPaused': { const { consumerId } = notification.data; const consumer = this._consumers.get(consumerId); - + if (!consumer) break; - + store.dispatch( consumerActions.setConsumerPaused(consumerId, 'remote')); break; } - + case 'consumerResumed': { const { consumerId } = notification.data; const consumer = this._consumers.get(consumerId); - + if (!consumer) break; - + store.dispatch( consumerActions.setConsumerResumed(consumerId, 'remote')); - + break; } - + case 'consumerLayersChanged': { const { consumerId, spatialLayer, temporalLayer } = notification.data; const consumer = this._consumers.get(consumerId); - + if (!consumer) break; - + store.dispatch(consumerActions.setConsumerCurrentLayers( consumerId, spatialLayer, temporalLayer)); - + break; } - + case 'consumerScore': { const { consumerId, score } = notification.data; - + store.dispatch( consumerActions.setConsumerScore(consumerId, score)); - + break; } @@ -2667,7 +2682,7 @@ export default class RoomClient store.dispatch(requestActions.notify( { text : intl.formatMessage({ - id : 'moderator.muteScreenSharingModerator', + id : 'moderator.stopScreenSharing', defaultMessage : 'Moderator stopped your screen sharing' }) })); @@ -2737,7 +2752,7 @@ export default class RoomClient break; } - + default: { logger.error( @@ -2784,7 +2799,7 @@ export default class RoomClient this._webTorrent.on('error', (error) => { logger.error('Filesharing [error:"%o"]', error); - + store.dispatch(requestActions.notify( { type : 'error', @@ -3006,7 +3021,7 @@ export default class RoomClient ); } - locked ? + locked ? store.dispatch(roomActions.setRoomLocked()) : store.dispatch(roomActions.setRoomUnLocked()); @@ -3032,14 +3047,14 @@ export default class RoomClient await this.enableMic(); const { autoMuteThreshold } = store.getState().settings; - if (autoMuteThreshold && peers.length > autoMuteThreshold) + if (autoMuteThreshold && peers.length > autoMuteThreshold) this.muteMic(); } if (joinVideo && this._mediasoupDevice.canProduce('video')) this.enableWebcam(); } - + await this._updateAudioOutputDevices(); const { selectedAudioOutputDevice } = store.getState().settings; @@ -3052,7 +3067,7 @@ export default class RoomClient ) ); } - + store.dispatch(roomActions.setRoomState('connected')); // Clean all the existing notifications. @@ -3236,7 +3251,7 @@ export default class RoomClient if (!device) throw new Error('no webcam devices'); - + logger.debug( 'addExtraVideo() | new selected webcam [device:%o]', device); @@ -3281,7 +3296,7 @@ export default class RoomClient { videoGoogleStartBitrate : 1000 }, - appData : + appData : { source : 'extravideo' } @@ -3291,7 +3306,7 @@ export default class RoomClient { producer = await this._sendTransport.produce({ track, - appData : + appData : { source : 'extravideo' } @@ -3385,7 +3400,7 @@ export default class RoomClient if (!device) throw new Error('no audio devices'); - + logger.debug( 'enableMic() | new selected audio device [device:%o]', device); @@ -3422,7 +3437,7 @@ export default class RoomClient opusPtime : '3', opusMaxPlaybackRate : 48000 }, - appData : + appData : { source: 'mic' } }); @@ -3582,7 +3597,7 @@ export default class RoomClient { videoGoogleStartBitrate : 1000 }, - appData : + appData : { source : 'screen' } @@ -3592,7 +3607,7 @@ export default class RoomClient { this._screenSharingProducer = await this._sendTransport.produce({ track, - appData : + appData : { source : 'screen' } @@ -3710,7 +3725,7 @@ export default class RoomClient if (!device) throw new Error('no webcam devices'); - + logger.debug( '_setWebcamProducer() | new selected webcam [device:%o]', device); @@ -3753,7 +3768,7 @@ export default class RoomClient { videoGoogleStartBitrate : 1000 }, - appData : + appData : { source : 'webcam' } @@ -3763,7 +3778,7 @@ export default class RoomClient { this._webcamProducer = await this._sendTransport.produce({ track, - appData : + appData : { source : 'webcam' } @@ -3888,6 +3903,14 @@ export default class RoomClient store.dispatch(meActions.setWebcamInProgress(false)); } + async _setNoiseThreshold(threshold) + { + logger.debug('_setNoiseThreshold:%s', threshold); + this._hark.setThreshold(threshold); + store.dispatch( + settingsActions.setNoiseThreshold(threshold)); + } + async _updateAudioDevices() { logger.debug('_updateAudioDevices()'); diff --git a/app/src/actions/meActions.js b/app/src/actions/meActions.js index 7fb34ea..be7c1ee 100644 --- a/app/src/actions/meActions.js +++ b/app/src/actions/meActions.js @@ -110,3 +110,9 @@ export const setIsSpeaking = (flag) => type : 'SET_IS_SPEAKING', payload : { flag } }); + +export const setAutoMuted = (flag) => + ({ + type : 'SET_AUTO_MUTED', + payload : { flag } + }); diff --git a/app/src/actions/settingsActions.js b/app/src/actions/settingsActions.js index 21ff2fd..90b019a 100644 --- a/app/src/actions/settingsActions.js +++ b/app/src/actions/settingsActions.js @@ -71,6 +71,18 @@ export const setNoiseSuppression = (noiseSuppression) => payload : { noiseSuppression } }); +export const setVoiceActivatedUnmute = (voiceActivatedUnmute) => + ({ + type: 'SET_VOICE_ACTIVATED_UNMUTE', + payload: { voiceActivatedUnmute } + }); + +export const setNoiseThreshold = (noiseThreshold) => + ({ + type: 'SET_NOISE_THRESHOLD', + payload: { noiseThreshold } + }); + export const setDefaultAudio = (audio) => ({ type : 'SET_DEFAULT_AUDIO', diff --git a/app/src/components/Containers/Me.js b/app/src/components/Containers/Me.js index 8e0eb8e..85b2ca9 100644 --- a/app/src/components/Containers/Me.js +++ b/app/src/components/Containers/Me.js @@ -137,10 +137,10 @@ const styles = (theme) => transform : 'translate(-50%, 0%)', color : 'rgba(255, 255, 255, 0.7)', fontSize : '1.3em', - backgroundColor : 'rgba(255, 0, 0, 0.9)', + backgroundColor : 'rgba(245, 0, 87, 0.70)', margin : '4px', padding : theme.spacing(2), - zIndex : 31, + zIndex : 1200, borderRadius : '20px', textAlign : 'center', opacity : 0, @@ -176,6 +176,7 @@ const Me = (props) => screenProducer, extraVideoProducers, canShareScreen, + noiseVolume, classes } = props; @@ -440,7 +441,12 @@ const Me = (props) => })} className={classes.smallContainer} disabled={!me.canSendMic || me.audioInProgress} - color={micState === 'on' ? 'primary' : 'secondary'} + color={ + micState === 'on' ? + settings.voiceActivatedUnmute && !me.isAutoMuted ? + 'primary' + : 'default' + : 'secondary'} size='small' onClick={() => { @@ -453,7 +459,10 @@ const Me = (props) => }} > { micState === 'on' ? - + : } @@ -468,7 +477,10 @@ const Me = (props) => })} className={classes.fab} disabled={!me.canSendMic || me.audioInProgress} - color={micState === 'on' ? 'default' : 'secondary'} + color={micState === 'on' ? + settings.voiceActivatedUnmute && !me.isAutoMuted? 'primary' + : 'default' + : 'secondary'} size='large' onClick={() => { @@ -481,7 +493,11 @@ const Me = (props) => }} > { micState === 'on' ? - + : } @@ -868,6 +884,7 @@ Me.propTypes = style : PropTypes.object, smallContainer : PropTypes.bool, canShareScreen : PropTypes.bool.isRequired, + noiseVolume : PropTypes.number, classes : PropTypes.object.isRequired, theme : PropTypes.object.isRequired }; @@ -878,12 +895,26 @@ const makeMapStateToProps = () => const mapStateToProps = (state) => { + let volume; + + // noiseVolume under threshold + if (state.peerVolumes[state.me.id] < state.settings.noiseThreshold) + { + // noiseVolume mapped to range 0.5 ... 1 (threshold switch) + volume = 1 + ((Math.abs(state.peerVolumes[state.me.id] - + state.settings.noiseThreshold) / (-120 - + state.settings.noiseThreshold))); + } + // noiseVolume over threshold: no noise but voice + else { volume = 0; } + return { me : state.me, ...meProducersSelector(state), settings : state.settings, activeSpeaker : state.me.id === state.room.activeSpeakerId, - canShareScreen : hasPermission(state) + canShareScreen : hasPermission(state), + noiseVolume : volume }; }; @@ -900,6 +931,8 @@ export default withRoomContext(connect( return ( prev.room === next.room && prev.me === next.me && + Math.round(prev.peerVolumes[prev.me.id]) === + Math.round(next.peerVolumes[next.me.id]) && prev.peers === next.peers && prev.producers === next.producers && prev.settings === next.settings diff --git a/app/src/components/Containers/Volume.js b/app/src/components/Containers/Volume.js index 3c13a39..81a050b 100644 --- a/app/src/components/Containers/Volume.js +++ b/app/src/components/Containers/Volume.js @@ -94,17 +94,17 @@ const styles = () => smallBar : { flex : '0 0 auto', - margin : '0.3rem', backgroundSize : '75%', backgroundRepeat : 'no-repeat', backgroundColor : 'rgba(0, 0, 0, 1)', cursor : 'pointer', transitionProperty : 'opacity, background-color', width : 3, - borderRadius : 6, + borderRadius : 2, transitionDuration : '0.25s', position : 'absolute', - bottom : 0, + top : '50%', + transform : 'translateY(-50%)', '&.level0' : { height: 0 }, '&.level1' : { height: '0.2vh' }, '&.level2' : { height: '0.4vh' }, @@ -149,9 +149,16 @@ const makeMapStateToProps = (initialState, props) => { const mapStateToProps = (state) => { - return { - volume : state.peerVolumes[props.id] - }; + if (state.peerVolumes[props.id]>state.settings.noiseThreshold) + { + return { + volume : Math.round((state.peerVolumes[props.id]+100) / 10) + }; + } + else + { + return { volume: 0 }; + } }; return mapStateToProps; diff --git a/app/src/components/Controls/About.js b/app/src/components/Controls/About.js index d361a8c..c462549 100644 --- a/app/src/components/Controls/About.js +++ b/app/src/components/Controls/About.js @@ -42,8 +42,9 @@ const styles = (theme) => }, link : { - display : 'block', - textAlign : 'center' + display : 'block', + textAlign : 'center', + marginBottom : theme.spacing(1) } }); @@ -68,15 +69,16 @@ const About = ({ /> - + Contributions to this work were made on behalf of the GÉANT project, a project that has received funding from the European Union’s Horizon 2020 research and innovation programme under Grant Agreement No. 731122 (GN4-2). On behalf of GÉANT project, GÉANT Association is the sole owner of the copyright in all material which was developed - by a member of the GÉANT project.
-
+ by a member of the GÉANT project. +
+ GÉANT Vereniging (Association) is registered with the Chamber of Commerce in Amsterdam with registration number 40535155 and operates in the UK as a branch of GÉANT @@ -87,6 +89,13 @@ const About = ({ https://edumeet.org + + + :{` ${process.env.REACT_APP_VERSION}`} +
{ window.config.logo && Logo } diff --git a/app/src/components/Controls/TopBar.js b/app/src/components/Controls/TopBar.js index 148f4a7..55bb849 100644 --- a/app/src/components/Controls/TopBar.js +++ b/app/src/components/Controls/TopBar.js @@ -312,7 +312,7 @@ const TopBar = (props) =>
- } - { lobbyPeers.length > 0 && - })} className={classes.actionButton} color='inherit' - onClick={() => + onClick={() => { loggedIn ? roomClient.logout() : roomClient.login(); }} @@ -472,6 +472,34 @@ const TopBar = (props) => }
+ { lobbyPeers.length > 0 && + + + setLockDialogOpen(!room.lockDialogOpen)} + > + + + + + + + }
- { lobbyPeers.length > 0 && - - - setLockDialogOpen(!room.lockDialogOpen)} - > - - - - - - - }