From d446b33695678e441a5761f99e482d139fad3305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Sun, 22 Mar 2020 22:41:48 +0100 Subject: [PATCH 01/88] Room now scales up to total server capacity --- server/lib/Peer.js | 12 +++++++ server/lib/Room.js | 79 +++++++++++++++++++++++++++++++++++++--------- server/server.js | 4 +-- 3 files changed, 78 insertions(+), 17 deletions(-) diff --git a/server/lib/Peer.js b/server/lib/Peer.js index cce62aa..6f886a6 100644 --- a/server/lib/Peer.js +++ b/server/lib/Peer.js @@ -30,6 +30,8 @@ class Peer extends EventEmitter this._email = null; + this._routerId = null; + this._rtpCapabilities = null; this._raisedHand = false; @@ -227,6 +229,16 @@ class Peer extends EventEmitter this._email = email; } + get routerId() + { + return this._routerId; + } + + set routerId(routerId) + { + this._routerId = routerId; + } + get rtpCapabilities() { return this._rtpCapabilities; diff --git a/server/lib/Room.js b/server/lib/Room.js index f25f31d..516af2a 100644 --- a/server/lib/Room.js +++ b/server/lib/Room.js @@ -12,32 +12,38 @@ class Room extends EventEmitter * * @async * - * @param {mediasoup.Worker} mediasoupWorker - The mediasoup Worker in which a new + * @param {mediasoup.Worker} mediasoupWorkers - The mediasoup Worker in which a new * mediasoup Router must be created. * @param {String} roomId - Id of the Room instance. */ - static async create({ mediasoupWorker, roomId }) + static async create({ mediasoupWorkers, roomId }) { logger.info('create() [roomId:"%s"]', roomId); // Router media codecs. const mediaCodecs = config.mediasoup.router.mediaCodecs; - // Create a mediasoup Router. - const mediasoupRouter = await mediasoupWorker.createRouter({ mediaCodecs }); + const mediasoupRouters = []; - // Create a mediasoup AudioLevelObserver. - const audioLevelObserver = await mediasoupRouter.createAudioLevelObserver( + for (const worker of mediasoupWorkers) + { + const router = await worker.createRouter({ mediaCodecs }); + + mediasoupRouters.push(router); + } + + // Create a mediasoup AudioLevelObserver on first router + const audioLevelObserver = await mediasoupRouters[0].createAudioLevelObserver( { maxEntries : 1, threshold : -80, interval : 800 }); - return new Room({ roomId, mediasoupRouter, audioLevelObserver }); + return new Room({ roomId, mediasoupRouters, audioLevelObserver }); } - constructor({ roomId, mediasoupRouter, audioLevelObserver }) + constructor({ roomId, mediasoupRouters, audioLevelObserver }) { logger.info('constructor() [roomId:"%s"]', roomId); @@ -70,8 +76,8 @@ class Room extends EventEmitter this._peers = {}; - // mediasoup Router instance. - this._mediasoupRouter = mediasoupRouter; + // Array of mediasoup Router instances. + this._mediasoupRouters = mediasoupRouters; // mediasoup AudioLevelObserver. this._audioLevelObserver = audioLevelObserver; @@ -108,8 +114,11 @@ class Room extends EventEmitter this._peers = null; - // Close the mediasoup Router. - this._mediasoupRouter.close(); + // Close the mediasoup Routers. + for (const router of this._mediasoupRouters) + { + router.close(); + } // Emit 'close' event. this.emit('close'); @@ -332,6 +341,9 @@ class Room extends EventEmitter this._peers[peer.id] = peer; + // Assign least loaded router + peer.routerId = this._getLeastLoadedRouter(); + this._handlePeer(peer); this._notification(peer.socket, 'roomReady'); } @@ -413,11 +425,14 @@ class Room extends EventEmitter async _handleSocketRequest(peer, request, cb) { + const router = + this._mediasoupRouters.find((peerRouter) => peerRouter.id === peer.routerId); + switch (request.method) { case 'getRouterRtpCapabilities': { - cb(null, this._mediasoupRouter.rtpCapabilities); + cb(null, router.rtpCapabilities); break; } @@ -531,7 +546,7 @@ class Room extends EventEmitter webRtcTransportOptions.enableTcp = true; } - const transport = await this._mediasoupRouter.createWebRtcTransport( + const transport = await router.createWebRtcTransport( webRtcTransportOptions ); @@ -615,6 +630,17 @@ class Room extends EventEmitter const producer = await transport.produce({ kind, rtpParameters, appData }); + for (const destinationRouter of this._mediasoupRouters) + { + if (destinationRouter !== router) + { + await router.pipeToRouter({ + producerId : producer.id, + router : destinationRouter + }); + } + } + // Store the Producer into the Peer data Object. peer.addProducer(producer.id, producer); @@ -1089,6 +1115,9 @@ class Room extends EventEmitter producer.id ); + const router = this._mediasoupRouters.find((producerRouter) => + producerRouter.id === producerPeer.routerId); + // Optimization: // - Create the server-side Consumer. If video, do it paused. // - Tell its Peer about it and wait for its response. @@ -1099,7 +1128,7 @@ class Room extends EventEmitter // NOTE: Don't create the Consumer if the remote Peer cannot consume it. if ( !consumerPeer.rtpCapabilities || - !this._mediasoupRouter.canConsume( + !router.canConsume( { producerId : producer.id, rtpCapabilities : consumerPeer.rtpCapabilities @@ -1292,6 +1321,26 @@ class Room extends EventEmitter socket.emit('notification', { method, data }); } } + + _getLeastLoadedRouter() + { + let load = Infinity; + let id; + + for (const router of this._mediasoupRouters) + { + const routerLoad = + Object.values(this._peers).filter((peer) => peer.routerId === router.id).length; + + if (routerLoad < load) + { + id = router.id; + load = routerLoad; + } + } + + return id; + } } module.exports = Room; diff --git a/server/server.js b/server/server.js index 8faa837..e4f868a 100755 --- a/server/server.js +++ b/server/server.js @@ -570,9 +570,9 @@ async function getOrCreateRoom({ roomId }) { logger.info('creating a new Room [roomId:"%s"]', roomId); - const mediasoupWorker = getMediasoupWorker(); + // const mediasoupWorker = getMediasoupWorker(); - room = await Room.create({ mediasoupWorker, roomId }); + room = await Room.create({ mediasoupWorkers, roomId }); rooms.set(roomId, room); From 698a57cb3eacd2d929a067682fda94c75ef20a4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Mon, 23 Mar 2020 14:59:25 +0100 Subject: [PATCH 02/88] Scaling up to new router after this many users connect --- server/lib/Room.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/lib/Room.js b/server/lib/Room.js index 516af2a..23f6d53 100644 --- a/server/lib/Room.js +++ b/server/lib/Room.js @@ -5,6 +5,8 @@ const config = require('../config/config'); const logger = new Logger('Room'); +const ROUTER_SCALE_SIZE = 40; + class Room extends EventEmitter { /** From 9442f529f4d999262e9e0b0c6a9873c262877c33 Mon Sep 17 00:00:00 2001 From: Stefan Otto Date: Mon, 30 Mar 2020 22:42:40 +0200 Subject: [PATCH 03/88] clean up; first testing --- app/src/RoomClient.js | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index fac58da..6e8d08e 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -959,22 +959,6 @@ export default class RoomClient } } - async getAudioTrack() - { - await navigator.mediaDevices.getUserMedia( - { - audio : true, video : false - }); - } - - async getVideoTrack() - { - await navigator.mediaDevices.getUserMedia( - { - audio : false, video : true - }); - } - async changeAudioDevice(deviceId) { logger.debug('changeAudioDevice() [deviceId: %s]', deviceId); @@ -1012,10 +996,18 @@ export default class RoomClient { audio : { - deviceId : { exact: device.deviceId } + deviceId : { exact: device.deviceId }, + sampleRate : 48000, + channelCount : 1, + volume : 1.0, + autoGainControl : true, + echoCancellation : false, + noiseSuppression : false, + sampleSize : 16 } - }); - + } + ); + logger.debug('Constraints: %o', stream.getAudioTracks()[0].getConstraints()); const track = stream.getAudioTracks()[0]; if (this._micProducer) @@ -2687,10 +2679,18 @@ export default class RoomClient const stream = await navigator.mediaDevices.getUserMedia( { audio : { - deviceId : { exact: deviceId } + deviceId : { exact: deviceId }, + sampleRate : 48000, + channelCount : 1, + volume : 1.0, + autoGainControl : true, + echoCancellation : false, + noiseSuppression : false, + sampleSize : 16 } } ); + logger.debug('Constraints: %o', stream.getAudioTracks()[0].getConstraints()); track = stream.getAudioTracks()[0]; From 1469f6c5fbbfe8b922ab715539124a400f361b8a Mon Sep 17 00:00:00 2001 From: Stefan Otto Date: Thu, 9 Apr 2020 17:59:29 +0200 Subject: [PATCH 04/88] cleanup calling hark --- app/src/RoomClient.js | 152 ++++++++++++++++-------------------------- 1 file changed, 58 insertions(+), 94 deletions(-) diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index 6e8d08e..0d0bd3a 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -959,6 +959,60 @@ export default class RoomClient } } + disconnectLocalHark() { + logger.debug('disconnectLocalHark() | Stopping harkStream.'); + if (this._harkStream != null) { + this._harkStream.getAudioTracks()[0].stop(); + this._harkStream = null; + } + + if (this._hark != null) { + logger.debug('disconnectLocalHark() Stopping hark.'); + this._hark.stop(); + } + } + + connectLocalHark(track) { + logger.debug('connectLocalHark() | Track:%o', track); + this._harkStream = new MediaStream(); + + this._harkStream.addTrack(track.clone()); + this._harkStream.getAudioTracks()[0].enabled = true; + + 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._micProducer.volume = volume; + + store.dispatch(peerVolumeActions.setPeerVolume(this._peerId, volume)); + } + }); + this._hark.on('speaking', function () { + store.dispatch(meActions.setIsSpeaking(true)); + }); + this._hark.on('stopped_speaking', function () { + store.dispatch(meActions.setIsSpeaking(false)); + }); + } + + async changeAudioDevice(deviceId) { logger.debug('changeAudioDevice() [deviceId: %s]', deviceId); @@ -967,7 +1021,7 @@ export default class RoomClient meActions.setAudioInProgress(true)); try - { + { const device = this._audioDevices[deviceId]; if (!device) @@ -977,15 +1031,7 @@ export default class RoomClient 'changeAudioDevice() | new selected webcam [device:%o]', device); - if (this._hark != null) - this._hark.stop(); - - if (this._harkStream != null) - { - logger.debug('Stopping hark.'); - this._harkStream.getAudioTracks()[0].stop(); - this._harkStream = null; - } + this.disconnectLocalHark(); if (this._micProducer && this._micProducer.track) this._micProducer.track.stop(); @@ -1015,47 +1061,8 @@ export default class RoomClient if (this._micProducer) this._micProducer.volume = 0; + this.connectLocalHark(track); - this._harkStream = new MediaStream(); - - this._harkStream.addTrack(track.clone()); - this._harkStream.getAudioTracks()[0].enabled = true; - - if (!this._harkStream.getAudioTracks()[0]) - throw new Error('changeAudioDevice(): given stream has no audio track'); - - 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._micProducer.volume = volume; - - store.dispatch(peerVolumeActions.setPeerVolume(this._peerId, volume)); - } - }); - this._hark.on('speaking', function() - { - store.dispatch(meActions.setIsSpeaking(true)); - }); - this._hark.on('stopped_speaking', function() - { - store.dispatch(meActions.setIsSpeaking(false)); - }); if (this._micProducer && this._micProducer.id) store.dispatch( producerActions.setProducerTrack(this._micProducer.id, track)); @@ -2745,51 +2752,8 @@ export default class RoomClient this._micProducer.volume = 0; - if (this._hark != null) - this._hark.stop(); + this.connectLocalHark(track); - if (this._harkStream != null) - this._harkStream.getAudioTracks()[0].stop(); - - this._harkStream = new MediaStream(); - - this._harkStream.addTrack(track.clone()); - - if (!this._harkStream.getAudioTracks()[0]) - throw new Error('enableMic(): given stream has no audio track'); - - 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._micProducer.volume = volume; - - store.dispatch(peerVolumeActions.setPeerVolume(this._peerId, volume)); - } - }); - this._hark.on('speaking', function() - { - store.dispatch(meActions.setIsSpeaking(true)); - }); - this._hark.on('stopped_speaking', function() - { - store.dispatch(meActions.setIsSpeaking(false)); - }); } catch (error) { From 207b92cfb23083bec1795c105f85ee640516928f Mon Sep 17 00:00:00 2001 From: Oskars G Date: Mon, 4 May 2020 17:03:00 +0300 Subject: [PATCH 05/88] Add Latvian (LV) localization, by Oskars Galanders Gift to Latvian people on the Day of the Restoration of Latvian Independence /from proprietary software/ :) --- app/src/translations/lv.json | 170 +++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 app/src/translations/lv.json diff --git a/app/src/translations/lv.json b/app/src/translations/lv.json new file mode 100644 index 0000000..dce2204 --- /dev/null +++ b/app/src/translations/lv.json @@ -0,0 +1,170 @@ +{ + "socket.disconnected": "Esat bezsaistē", + "socket.reconnecting": "Esat bezsaistē, tiek mēģināts pievienoties", + "socket.reconnected": "Esat atkārtoti pievienojies", + "socket.requestError": "Kļūme servera pieprasījumā", + + "room.chooseRoom": "Ievadiet sapulces telpas nosaukumu (ID), kurai vēlaties pievienoties", + "room.cookieConsent": "Lai uzlabotu lietotāja pieredzi, šī vietne izmanto sīkfailus", + "room.consentUnderstand": "Es saprotu un piekrītu", + "room.joined": "Jūs esiet pievienojies sapulces telpai", + "room.cantJoin": "Nav iespējams pievienoties sapulces telpai", + "room.youLocked": "Jūs aizslēdzāt sapulces telpu", + "room.cantLock": "Nav iespējams aizslēgt sapulces telpu", + "room.youUnLocked": "Jūs atslēdzāt sapulces telpu", + "room.cantUnLock": "Nav iespējams atslēgt sapulces telpu", + "room.locked": "Sapulces telpa tagad ir AIZSLĒGTA", + "room.unlocked": "Sapulces telpa tagad ir ATSLĒGTA", + "room.newLobbyPeer": "Jauns dalībnieks ienācis uzgaidāmajā telpā", + "room.lobbyPeerLeft": "Dalībnieks uzgaidāmo telpu pameta", + "room.lobbyPeerChangedDisplayName": "Dalībnieks uzgaidāmajā telpā nomainīja vārdu uz {displayName}", + "room.lobbyPeerChangedPicture": "Dalībnieks uzgaidāmajā telpā nomainīja pašattēlu", + "room.setAccessCode": "Pieejas kods sapulces telpai aktualizēts", + "room.accessCodeOn": "Pieejas kods sapulces telpai tagad ir aktivēts", + "room.accessCodeOff": "Pieejas kods sapulces telpai tagad ir deaktivēts (atslēgts)", + "room.peerChangedDisplayName": "{oldDisplayName} pārsaucās par {displayName}", + "room.newPeer": "{displayName} pievienojās sapulces telpai", + "room.newFile": "Pieejams jauns fails", + "room.toggleAdvancedMode": "Pārslēgt uz advancēto režīmu", + "room.setDemocraticView": "Nomainīts izkārtojums uz demokrātisko skatu", + "room.setFilmStripView": "Nomainīts izkārtojums uz diapozitīvu (filmstrip) skatu", + "room.loggedIn": "Jūs esat ierakstījies (sistēmā)", + "room.loggedOut": "Jūs esat izrakstījies (no sistēmas)", + "room.changedDisplayName": "Jūsu vārds mainīts uz {displayName}", + "room.changeDisplayNameError": "Gadījās ķibele ar Jūsu vārda nomaiņu", + "room.chatError": "Nav iespējams nosūtīt tērziņa ziņu", + "room.aboutToJoin": "Jūs grasāties pievienoties sapulcei", + "room.roomId": "Sapulces telpas nosaukums (ID): {roomName}", + "room.setYourName": "Norādiet savu dalības vārdu un izvēlieties kā vēlaties pievienoties sapulcei:", + "room.audioOnly": "Vienīgi audio", + "room.audioVideo": "Audio & video", + "room.youAreReady": "Ok, Jūs esiet gatavi!", + "room.emptyRequireLogin": "Sapulces telpa ir tukša! Jūs varat Ierakstīties sistēmā, lai uzsāktu vadīt sapulci vai pagaidīt kamēr pievienojas sapulces rīkotājs/vadītājs", + "room.locketWait": "Sapulce telpa ir slēgta. Jūs atrodaties tās uzgaidāmajā telpā. Uzkavējieties, kamēr kāds Jūs sapulcē ielaiž ...", + "room.lobbyAdministration": "Uzgaidāmās telpas administrēšana", + "room.peersInLobby": "Dalībnieki uzgaidāmajā telpā", + "room.lobbyEmpty": "Pašreiz uzgaidāmajā telpā neviena nav", + "room.hiddenPeers": "{hiddenPeersCount, plural, one {participant} other {participants}}", + "room.me": "Es", + "room.spotlights": "Aktīvie (referējošie) dalībnieki", + "room.passive": "Pasīvie dalībnieki", + "room.videoPaused": "Šis video ir pauzēts", + "room.muteAll": "Noklusināt visus dalībnieku mikrofonus", + "room.stopAllVideo": "Izslēgt visu dalībnieku kameras", + "room.closeMeeting": "Beigt sapulci", + "room.clearChat": "Nodzēst visus tērziņus", + "room.clearFileSharing": "Notīrīt visus kopīgotos failus", + "room.speechUnsupported": "Jūsu pārlūks neatbalsta balss atpazīšanu", + "room.moderatoractions": "Moderatora rīcība", + "room.raisedHand": "{displayName} pacēla roku", + "room.loweredHand": "{displayName} nolaida roku", + "room.extraVideo": "Papildus video", + + "me.mutedPTT": "Jūs esat noklusināts. Turiet taustiņu SPACE-BAR, lai runātu", + + "roles.gotRole": "Jūs ieguvāt lomu: {role}", + "roles.lostRole": "Jūs zaudējāt lomu: {role}", + + "tooltip.login": "Ierakstīties", + "tooltip.logout": "Izrakstīties", + "tooltip.admitFromLobby": "Ielaist no uzgaidāmās telpas", + "tooltip.lockRoom": "Aizslēgt sapulces telpu", + "tooltip.unLockRoom": "Atlēgt sapulces telpu", + "tooltip.enterFullscreen": "Aktivēt pilnekrāna režīmu", + "tooltip.leaveFullscreen": "Pamest pilnekrānu", + "tooltip.lobby": "Parādīt uzgaidāmo telpu", + "tooltip.settings": "Parādīt iestatījumus", + "tooltip.participants": "Parādīt dalībniekus", + "tooltip.kickParticipant": "Izvadīt (izspert) dalībnieku", + "tooltip.muteParticipant": "Noklusināt dalībnieku", + "tooltip.muteParticipantVideo": "Atslēgt dalībnieka video", + "tooltip.raisedHand": "Pacelt roku", + + "label.roomName": "Sapulces telpas nosaukums (ID)", + "label.chooseRoomButton": "Turpināt", + "label.yourName": "Jūu vārds", + "label.newWindow": "Jauns logs", + "label.fullscreen": "Pilnekrāns", + "label.openDrawer": "Atvērt atvilkni", + "label.leave": "Pamest", + "label.chatInput": "Rakstiet tērziņa ziņu...", + "label.chat": "Tērzētava", + "label.filesharing": "Failu koplietošana", + "label.participants": "Dalībnieki", + "label.shareFile": "Koplietot failu", + "label.fileSharingUnsupported": "Failu koplietošana netiek atbalstīta", + "label.unknown": "Nezināms", + "label.democratic": "Demokrātisks skats", + "label.filmstrip": "Diapozitīvu (filmstrip) skats", + "label.low": "Zema", + "label.medium": "Vidēja", + "label.high": "Augsta (HD)", + "label.veryHigh": "Ļoti augsta (FHD)", + "label.ultra": "Ultra (UHD)", + "label.close": "Aizvērt", + "label.media": "Mediji", + "label.appearence": "Izskats", + "label.advanced": "Advancēts", + "label.addVideo": "Pievienot video", + + "settings.settings": "Iestatījumi", + "settings.camera": "Kamera", + "settings.selectCamera": "Izvēlieties kameru (video ierīci)", + "settings.cantSelectCamera": "Nav iespējams lietot šo kameru (video ierīci)", + "settings.audio": "Skaņas ierīce", + "settings.selectAudio": "Izvēlieties skaņas ierīci", + "settings.cantSelectAudio": "Nav iespējams lietot šo skaņas (audio) ierīci", + "settings.resolution": "Iestatiet jūsu video izšķirtspēju", + "settings.layout": "Sapulces telpas izkārtojums", + "settings.selectRoomLayout": "Iestatiet sapulces telpas izkārtojumu", + "settings.advancedMode": "Advancētais režīms", + "settings.permanentTopBar": "Pastāvīga augšējā (ekrānaugšas) josla", + "settings.lastn": "Jums redzamo video/kameru skaits", + "settings.hiddenControls": "Slēpto mediju vadība", + "settings.notificationSounds": "Paziņojumu skaņas", + + "filesharing.saveFileError": "Nav iespējams saglabāt failu", + "filesharing.startingFileShare": "Tiek mēģināts kopīgot failu", + "filesharing.successfulFileShare": "Fails sekmīgi kopīgots", + "filesharing.unableToShare": "Nav iespējams kopīgot failu", + "filesharing.error": "Atgadījās faila kopīgošanas kļūme", + "filesharing.finished": "Fails ir lejupielādēts", + "filesharing.save": "Saglabāt", + "filesharing.sharedFile": "{displayName} kopīgoja failu", + "filesharing.download": "Lejuplādēt", + "filesharing.missingSeeds": "Ja šis process aizņem ilgu laiku, iespējams nav neviena, kas sēklo (seed) šo torentu. Mēģiniet palūgt kādu atkārtoti augšuplādēt Jūsu gribēto failu.", + + "devices.devicesChanged": "Jūsu ierīces pamainījās. Iestatījumu izvēlnē (dialogā) iestatiet jaunās ierīces.", + + "device.audioUnsupported": "Skaņa (audio) netiek atbalstīta", + "device.activateAudio": "Iespējot/aktivēt mikrofonu (izejošo skaņu)", + "device.muteAudio": "Atslēgt/noklusināt mikrofonu (izejošo skaņu) ", + "device.unMuteAudio": "Ieslēgt mikrofonu (izejošo skaņu)", + + "device.videoUnsupported": "Kamera (izejošais video) netiek atbalstīta", + "device.startVideo": "Ieslēgt kameru (izejošo video)", + "device.stopVideo": "Izslēgt kameru (izejošo video)", + + "device.screenSharingUnsupported": "Ekrāna kopīgošana netiek atbalstīta", + "device.startScreenSharing": "Sākt ekrāna kopīgošanu", + "device.stopScreenSharing": "Beigt ekrāna kopīgošanu", + + "devices.microphoneDisconnected": "Mikrofons atvienots", + "devices.microphoneError": "Atgadījās kļūme, piekļūstot jūsu mikrofonam", + "devices.microPhoneMute": "Mikrofons izslēgts/noklusināts", + "devices.micophoneUnMute": "Mikrofons ieslēgts", + "devices.microphoneEnable": "Mikrofons iespējots", + "devices.microphoneMuteError": "Nav iespējams izslēgt Jūsu mikrofonu", + "devices.microphoneUnMuteError": "Nav iespējams ieslēgt Jūsu mikrofonu", + + "devices.screenSharingDisconnected" : "Ekrāna kopīgošana nenotiek (atvienota)", + "devices.screenSharingError": "Atgadījās kļūme, piekļūstot Jūsu ekrānam", + + "devices.cameraDisconnected": "Kamera atvienota", + "devices.cameraError": "Atgadījās kļūme, piekļūstot Jūsu kamerai", + + "moderator.clearChat": "Moderators nodzēsa tērziņus", + "moderator.clearFiles": "Moderators notīrīja failus", + "moderator.muteAudio": "Moderators noklusināja jūsu mikrofonu", + "moderator.muteVideo": "Moderators atslēdza jūsu kameru" +} From f0b9d3be6a5f12bc4520b3803ce3b22a787141d1 Mon Sep 17 00:00:00 2001 From: Astagor Date: Mon, 4 May 2020 18:59:06 +0200 Subject: [PATCH 06/88] Added share photo from gallery on mobile --- .../MeetingDrawer/FileSharing/FileSharing.js | 36 ++++++++++++++++++- app/src/translations/cn.json | 1 + app/src/translations/cs.json | 1 + app/src/translations/de.json | 1 + app/src/translations/dk.json | 1 + app/src/translations/el.json | 1 + app/src/translations/en.json | 1 + app/src/translations/es.json | 1 + app/src/translations/fr.json | 1 + app/src/translations/hr.json | 1 + app/src/translations/hu.json | 1 + app/src/translations/it.json | 1 + app/src/translations/nb.json | 1 + app/src/translations/pl.json | 1 + app/src/translations/pt.json | 1 + app/src/translations/ro.json | 1 + app/src/translations/tr.json | 1 + app/src/translations/uk.json | 1 + 18 files changed, 52 insertions(+), 1 deletion(-) diff --git a/app/src/components/MeetingDrawer/FileSharing/FileSharing.js b/app/src/components/MeetingDrawer/FileSharing/FileSharing.js index 6278e46..66a0d15 100644 --- a/app/src/components/MeetingDrawer/FileSharing/FileSharing.js +++ b/app/src/components/MeetingDrawer/FileSharing/FileSharing.js @@ -42,6 +42,7 @@ const FileSharing = (props) => const { canShareFiles, + browser, canShare, classes } = props; @@ -57,6 +58,17 @@ const FileSharing = (props) => defaultMessage : 'File sharing not supported' }); + const buttonGalleryDescription = canShareFiles ? + intl.formatMessage({ + id : 'label.shareGalleryFile', + defaultMessage : 'Share from gallery' + }) + : + intl.formatMessage({ + id : 'label.fileSharingUnsupported', + defaultMessage : 'File sharing not supported' + }); + return ( @@ -69,6 +81,14 @@ const FileSharing = (props) => onClick={(e) => (e.target.value = null)} id='share-files-button' /> + - + { + (browser.platform === 'mobile') && canShareFiles && canShare && + } ); @@ -87,6 +118,7 @@ const FileSharing = (props) => FileSharing.propTypes = { roomClient : PropTypes.any.isRequired, + browser : PropTypes.object.isRequired, canShareFiles : PropTypes.bool.isRequired, tabOpen : PropTypes.bool.isRequired, canShare : PropTypes.bool.isRequired, @@ -97,6 +129,7 @@ const mapStateToProps = (state) => { return { canShareFiles : state.me.canShareFiles, + browser : state.me.browser, tabOpen : state.toolarea.currentToolTab === 'files', canShare : state.me.roles.some((role) => @@ -113,6 +146,7 @@ export default withRoomContext(connect( { return ( prev.room.permissionsFromRoles === next.room.permissionsFromRoles && + prev.me.browser === next.me.browser && prev.me.roles === next.me.roles && prev.me.canShareFiles === next.me.canShareFiles && prev.toolarea.currentToolTab === next.toolarea.currentToolTab diff --git a/app/src/translations/cn.json b/app/src/translations/cn.json index e82e9d5..f26d3c5 100644 --- a/app/src/translations/cn.json +++ b/app/src/translations/cn.json @@ -92,6 +92,7 @@ "label.filesharing": "文件共享", "label.participants": "参与者", "label.shareFile": "共享文件", + "label.shareGalleryFile": null, "label.fileSharingUnsupported": "不支持文件共享", "label.unknown": "未知", "label.democratic": "民主视图", diff --git a/app/src/translations/cs.json b/app/src/translations/cs.json index 1a2b3c5..4c6eda8 100644 --- a/app/src/translations/cs.json +++ b/app/src/translations/cs.json @@ -91,6 +91,7 @@ "label.filesharing": "Sdílení souborů", "label.participants": "Účastníci", "label.shareFile": "Sdílet soubor", + "label.shareGalleryFile": null, "label.fileSharingUnsupported": "Sdílení souborů není podporováno", "label.unknown": "Neznámý", "label.democratic": "Rozvržení: Demokratické", diff --git a/app/src/translations/de.json b/app/src/translations/de.json index a6f6ac2..63bece8 100644 --- a/app/src/translations/de.json +++ b/app/src/translations/de.json @@ -92,6 +92,7 @@ "label.filesharing": "Dateien", "label.participants": "Teilnehmer", "label.shareFile": "Datei hochladen", + "label.shareGalleryFile": null, "label.fileSharingUnsupported": "Dateifreigabe nicht unterstützt", "label.unknown": "Unbekannt", "label.democratic": "Demokratisch", diff --git a/app/src/translations/dk.json b/app/src/translations/dk.json index 4027363..7228963 100644 --- a/app/src/translations/dk.json +++ b/app/src/translations/dk.json @@ -92,6 +92,7 @@ "label.filesharing": "Fildeling", "label.participants": "Deltagere", "label.shareFile": "Del fil", + "label.shareGalleryFile": null, "label.fileSharingUnsupported": "Fildeling er ikke understøttet", "label.unknown": "Ukendt", "label.democracy": "Galleri visning", diff --git a/app/src/translations/el.json b/app/src/translations/el.json index 5f93f42..dfc1f86 100644 --- a/app/src/translations/el.json +++ b/app/src/translations/el.json @@ -92,6 +92,7 @@ "label.filesharing": "Διαμοιρασμοός αρχείου", "label.participants": "Συμμετέχοντες", "label.shareFile": "Διαμοιραστείτε ένα αρχείο", + "label.shareGalleryFile": null, "label.fileSharingUnsupported": "Ο διαμοιρασμός αρχείων δεν υποστηρίζεται", "label.unknown": "Άγνωστο", "label.democratic": null, diff --git a/app/src/translations/en.json b/app/src/translations/en.json index 8fe4fa1..0f38032 100644 --- a/app/src/translations/en.json +++ b/app/src/translations/en.json @@ -92,6 +92,7 @@ "label.filesharing": "File sharing", "label.participants": "Participants", "label.shareFile": "Share file", + "label.shareGalleryFile": null, "label.fileSharingUnsupported": "File sharing not supported", "label.unknown": "Unknown", "label.democratic": "Democratic view", diff --git a/app/src/translations/es.json b/app/src/translations/es.json index d73506c..2d42660 100644 --- a/app/src/translations/es.json +++ b/app/src/translations/es.json @@ -92,6 +92,7 @@ "label.filesharing": "Compartir ficheros", "label.participants": "Participantes", "label.shareFile": "Compartir fichero", + "label.shareGalleryFile": null, "label.fileSharingUnsupported": "Compartir ficheros no está disponible", "label.unknown": "Desconocido", "label.democratic": "Vista democrática", diff --git a/app/src/translations/fr.json b/app/src/translations/fr.json index f884c36..753e8f3 100644 --- a/app/src/translations/fr.json +++ b/app/src/translations/fr.json @@ -92,6 +92,7 @@ "label.filesharing": "Partage de fichier", "label.participants": "Participants", "label.shareFile": "Partager un fichier", + "label.shareGalleryFile": null, "label.fileSharingUnsupported": "Partage de fichier non supporté", "label.unknown": "Inconnu", "label.democratic": "Vue démocratique", diff --git a/app/src/translations/hr.json b/app/src/translations/hr.json index eb9d08e..ca803be 100644 --- a/app/src/translations/hr.json +++ b/app/src/translations/hr.json @@ -92,6 +92,7 @@ "label.filesharing": "Dijeljenje datoteka", "label.participants": "Sudionici", "label.shareFile": "Dijeli datoteku", + "label.shareGalleryFile": null, "label.fileSharingUnsupported": "Dijeljenje datoteka nije podržano", "label.unknown": "Nepoznato", "label.democratic":"Demokratski prikaz", diff --git a/app/src/translations/hu.json b/app/src/translations/hu.json index f25b06a..43228fa 100644 --- a/app/src/translations/hu.json +++ b/app/src/translations/hu.json @@ -92,6 +92,7 @@ "label.filesharing": "Fájl megosztás", "label.participants": "Résztvevők", "label.shareFile": "Fájl megosztása", + "label.shareGalleryFile": null, "label.fileSharingUnsupported": "Fájl megosztás nem támogatott", "label.unknown": "Ismeretlen", "label.democratic": "Egyforma képméretű képkiosztás", diff --git a/app/src/translations/it.json b/app/src/translations/it.json index d027e7a..8126d6f 100644 --- a/app/src/translations/it.json +++ b/app/src/translations/it.json @@ -91,6 +91,7 @@ "label.filesharing": "Condivisione file", "label.participants": "Partecipanti", "label.shareFile": "Condividi file", + "label.shareGalleryFile": null, "label.fileSharingUnsupported": "Condivisione file non supportata", "label.unknown": "Sconosciuto", "label.democratic": "Vista Democratica", diff --git a/app/src/translations/nb.json b/app/src/translations/nb.json index e803bda..d95a866 100644 --- a/app/src/translations/nb.json +++ b/app/src/translations/nb.json @@ -92,6 +92,7 @@ "label.filesharing": "Fildeling", "label.participants": "Deltakere", "label.shareFile": "Del fil", + "label.shareGalleryFile": null, "label.fileSharingUnsupported": "Fildeling ikke støttet", "label.unknown": "Ukjent", "label.democratic": "Demokratisk", diff --git a/app/src/translations/pl.json b/app/src/translations/pl.json index 399f788..d366cf3 100644 --- a/app/src/translations/pl.json +++ b/app/src/translations/pl.json @@ -92,6 +92,7 @@ "label.filesharing": "Udostępnianie plików", "label.participants": "Uczestnicy", "label.shareFile": "Udostępnij plik", + "label.shareGalleryFile": null, "label.fileSharingUnsupported": "Udostępnianie plików nie jest obsługiwane", "label.unknown": "Nieznane", "label.democratic": "Układ demokratyczny", diff --git a/app/src/translations/pt.json b/app/src/translations/pt.json index d66d8da..867a3e5 100644 --- a/app/src/translations/pt.json +++ b/app/src/translations/pt.json @@ -92,6 +92,7 @@ "label.filesharing": "Partilha de ficheiro", "label.participants": "Participantes", "label.shareFile": "Partilhar ficheiro", + "label.shareGalleryFile": null, "label.fileSharingUnsupported": "Partilha de ficheiro não disponível", "label.unknown": "Desconhecido", "label.democratic": "Vista democrática", diff --git a/app/src/translations/ro.json b/app/src/translations/ro.json index b37b36a..ed8b24f 100644 --- a/app/src/translations/ro.json +++ b/app/src/translations/ro.json @@ -92,6 +92,7 @@ "label.filesharing": "Partajarea fișierelor", "label.participants": "Participanți", "label.shareFile": "Partajează fișierul", + "label.shareGalleryFile": null, "label.fileSharingUnsupported": "Partajarea fișierelor nu este acceptată", "label.unknown": "Necunoscut", "label.democratic": "Distribuție egală a dimensiunii imaginii", diff --git a/app/src/translations/tr.json b/app/src/translations/tr.json index d3d20e3..3d1b83b 100644 --- a/app/src/translations/tr.json +++ b/app/src/translations/tr.json @@ -92,6 +92,7 @@ "label.filesharing": "Dosya paylaşım", "label.participants": "Katılımcı", "label.shareFile": "Dosya paylaş", + "label.shareGalleryFile": null, "label.fileSharingUnsupported": "Dosya paylaşımı desteklenmiyor", "label.unknown": "Bilinmeyen", "label.democratic": "Demokratik görünüm", diff --git a/app/src/translations/uk.json b/app/src/translations/uk.json index dcb3c7d..9b4c4b5 100644 --- a/app/src/translations/uk.json +++ b/app/src/translations/uk.json @@ -92,6 +92,7 @@ "label.filesharing": "Обмін файлами", "label.participants": "Учасники", "label.shareFile": "Надіслати файл", + "label.shareGalleryFile": null, "label.fileSharingUnsupported": "Обмін файлами не підтримується", "label.unknown": "Невідомо", "label.democrat": "Демократичний вигляд", From ac6ee1bfa3b8f482d5d70740939ca502076f3531 Mon Sep 17 00:00:00 2001 From: Astagor Date: Mon, 4 May 2020 19:31:50 +0200 Subject: [PATCH 07/88] Added limit for maximum number of users in a single room --- app/src/RoomClient.js | 7 +++++++ app/src/actions/roomActions.js | 6 ++++++ app/src/components/JoinDialog.js | 15 +++++++++++++++ app/src/reducers/room.js | 6 ++++++ app/src/translations/cn.json | 1 + app/src/translations/cs.json | 1 + app/src/translations/de.json | 1 + app/src/translations/dk.json | 1 + app/src/translations/el.json | 1 + app/src/translations/en.json | 1 + app/src/translations/es.json | 1 + app/src/translations/fr.json | 1 + app/src/translations/hr.json | 1 + app/src/translations/hu.json | 1 + app/src/translations/it.json | 1 + app/src/translations/nb.json | 1 + app/src/translations/pl.json | 1 + app/src/translations/pt.json | 1 + app/src/translations/ro.json | 1 + app/src/translations/tr.json | 1 + app/src/translations/uk.json | 1 + server/config/config.example.js | 2 ++ server/lib/Room.js | 8 ++++++++ 23 files changed, 61 insertions(+) diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index 8dbc9d1..8a502be 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -1963,6 +1963,13 @@ export default class RoomClient break; } + case 'overRoomLimit': + { + store.dispatch(roomActions.setOverRoomLimit(true)); + + break; + } + case 'roomReady': { const { turnServers } = notification.data; diff --git a/app/src/actions/roomActions.js b/app/src/actions/roomActions.js index 30ce37c..b90bf1b 100644 --- a/app/src/actions/roomActions.js +++ b/app/src/actions/roomActions.js @@ -40,6 +40,12 @@ export const setSignInRequired = (signInRequired) => payload : { signInRequired } }); +export const setOverRoomLimit = (overRoomLimit) => + ({ + type : 'SET_OVER_ROOM_LIMIT', + payload : { overRoomLimit } + }); + export const setAccessCode = (accessCode) => ({ type : 'SET_ACCESS_CODE', diff --git a/app/src/components/JoinDialog.js b/app/src/components/JoinDialog.js index a8493db..8a2bc23 100644 --- a/app/src/components/JoinDialog.js +++ b/app/src/components/JoinDialog.js @@ -83,6 +83,10 @@ const styles = (theme) => green : { color : 'rgba(0, 153, 0, 1)' + }, + red : + { + color : 'rgba(153, 0, 0, 1)' } }); @@ -281,6 +285,16 @@ const JoinDialog = ({ }} fullWidth /> + {!room.inLobby && room.overRoomLimit && + + + + } @@ -419,6 +433,7 @@ export default withRoomContext(connect( return ( prev.room.inLobby === next.room.inLobby && prev.room.signInRequired === next.room.signInRequired && + prev.room.overRoomLimit === next.room.overRoomLimit && prev.settings.displayName === next.settings.displayName && prev.me.displayNameInProgress === next.me.displayNameInProgress && prev.me.loginEnabled === next.me.loginEnabled && diff --git a/app/src/reducers/room.js b/app/src/reducers/room.js index f4bc6ab..a4bbfb4 100644 --- a/app/src/reducers/room.js +++ b/app/src/reducers/room.js @@ -6,6 +6,7 @@ const initialState = locked : false, inLobby : false, signInRequired : false, + overRoomLimit : false, // access code to the room if locked and joinByAccessCode == true accessCode : '', // if true: accessCode is a possibility to open the room @@ -88,7 +89,12 @@ const room = (state = initialState, action) => return { ...state, signInRequired }; } + case 'SET_OVER_ROOM_LIMIT': + { + const { overRoomLimit } = action.payload; + return { ...state, overRoomLimit }; + } case 'SET_ACCESS_CODE': { const { accessCode } = action.payload; diff --git a/app/src/translations/cn.json b/app/src/translations/cn.json index e82e9d5..a5ac918 100644 --- a/app/src/translations/cn.json +++ b/app/src/translations/cn.json @@ -59,6 +59,7 @@ "room.raisedHand": null, "room.loweredHand": null, "room.extraVideo": null, + "room.overRoomLimit": null, "me.mutedPTT": null, diff --git a/app/src/translations/cs.json b/app/src/translations/cs.json index 1a2b3c5..0acac2a 100644 --- a/app/src/translations/cs.json +++ b/app/src/translations/cs.json @@ -58,6 +58,7 @@ "room.raisedHand": null, "room.loweredHand": null, "room.extraVideo": null, + "room.overRoomLimit": null, "me.mutedPTT": null, diff --git a/app/src/translations/de.json b/app/src/translations/de.json index a6f6ac2..eec3d7c 100644 --- a/app/src/translations/de.json +++ b/app/src/translations/de.json @@ -59,6 +59,7 @@ "room.raisedHand": null, "room.loweredHand": null, "room.extraVideo": null, + "room.overRoomLimit": null, "me.mutedPTT": "Du bist stummgeschalted, Halte die SPACE-Taste um zu sprechen", diff --git a/app/src/translations/dk.json b/app/src/translations/dk.json index 4027363..58bd0fc 100644 --- a/app/src/translations/dk.json +++ b/app/src/translations/dk.json @@ -59,6 +59,7 @@ "room.raisedHand": null, "room.loweredHand": null, "room.extraVideo": null, + "room.overRoomLimit": null, "me.mutedPTT": null, diff --git a/app/src/translations/el.json b/app/src/translations/el.json index 5f93f42..4db8158 100644 --- a/app/src/translations/el.json +++ b/app/src/translations/el.json @@ -59,6 +59,7 @@ "room.raisedHand": null, "room.loweredHand": null, "room.extraVideo": null, + "room.overRoomLimit": null, "me.mutedPTT": null, diff --git a/app/src/translations/en.json b/app/src/translations/en.json index 8fe4fa1..a40ba8f 100644 --- a/app/src/translations/en.json +++ b/app/src/translations/en.json @@ -59,6 +59,7 @@ "room.raisedHand": "{displayName} raised their hand", "room.loweredHand": "{displayName} put their hand down", "room.extraVideo": "Extra video", + "room.overRoomLimit": null, "me.mutedPTT": "You are muted, hold down SPACE-BAR to talk", diff --git a/app/src/translations/es.json b/app/src/translations/es.json index d73506c..85899ae 100644 --- a/app/src/translations/es.json +++ b/app/src/translations/es.json @@ -59,6 +59,7 @@ "room.raisedHand": null, "room.loweredHand": null, "room.extraVideo": null, + "room.overRoomLimit": null, "me.mutedPTT": null, diff --git a/app/src/translations/fr.json b/app/src/translations/fr.json index f884c36..0126ede 100644 --- a/app/src/translations/fr.json +++ b/app/src/translations/fr.json @@ -59,6 +59,7 @@ "room.raisedHand": null, "room.loweredHand": null, "room.extraVideo": null, + "room.overRoomLimit": null, "me.mutedPTT": null, diff --git a/app/src/translations/hr.json b/app/src/translations/hr.json index eb9d08e..14baba4 100644 --- a/app/src/translations/hr.json +++ b/app/src/translations/hr.json @@ -59,6 +59,7 @@ "room.raisedHand": null, "room.loweredHand": null, "room.extraVideo": null, + "room.overRoomLimit": null, "me.mutedPTT": "Utišani ste, pritisnite i držite SPACE tipku za razgovor", diff --git a/app/src/translations/hu.json b/app/src/translations/hu.json index f25b06a..2648c60 100644 --- a/app/src/translations/hu.json +++ b/app/src/translations/hu.json @@ -59,6 +59,7 @@ "room.raisedHand": null, "room.loweredHand": null, "room.extraVideo": null, + "room.overRoomLimit": null, "me.mutedPTT": null, diff --git a/app/src/translations/it.json b/app/src/translations/it.json index d027e7a..d8f990c 100644 --- a/app/src/translations/it.json +++ b/app/src/translations/it.json @@ -59,6 +59,7 @@ "room.raisedHand": null, "room.loweredHand": null, "room.extraVideo": null, + "room.overRoomLimit": null, "me.mutedPTT": null, diff --git a/app/src/translations/nb.json b/app/src/translations/nb.json index e803bda..935841b 100644 --- a/app/src/translations/nb.json +++ b/app/src/translations/nb.json @@ -59,6 +59,7 @@ "room.raisedHand": "{displayName} rakk opp hånden", "room.loweredHand": "{displayName} tok ned hånden", "room.extraVideo": "Ekstra video", + "room.overRoomLimit": null, "me.mutedPTT": "Du er dempet, hold nede SPACE for å snakke", diff --git a/app/src/translations/pl.json b/app/src/translations/pl.json index 399f788..abe6fb4 100644 --- a/app/src/translations/pl.json +++ b/app/src/translations/pl.json @@ -59,6 +59,7 @@ "room.raisedHand": null, "room.loweredHand": null, "room.extraVideo": null, + "room.overRoomLimit": null, "me.mutedPTT": null, diff --git a/app/src/translations/pt.json b/app/src/translations/pt.json index d66d8da..214b620 100644 --- a/app/src/translations/pt.json +++ b/app/src/translations/pt.json @@ -59,6 +59,7 @@ "room.raisedHand": null, "room.loweredHand": null, "room.extraVideo": null, + "room.overRoomLimit": null, "me.mutedPTT": null, diff --git a/app/src/translations/ro.json b/app/src/translations/ro.json index b37b36a..95c7952 100644 --- a/app/src/translations/ro.json +++ b/app/src/translations/ro.json @@ -59,6 +59,7 @@ "room.raisedHand": null, "room.loweredHand": null, "room.extraVideo": null, + "room.overRoomLimit": null, "me.mutedPTT": null, diff --git a/app/src/translations/tr.json b/app/src/translations/tr.json index d3d20e3..4cbad15 100644 --- a/app/src/translations/tr.json +++ b/app/src/translations/tr.json @@ -59,6 +59,7 @@ "room.raisedHand": null, "room.loweredHand": null, "room.extraVideo": null, + "room.overRoomLimit": null, "me.mutedPTT": null, diff --git a/app/src/translations/uk.json b/app/src/translations/uk.json index dcb3c7d..933fda0 100644 --- a/app/src/translations/uk.json +++ b/app/src/translations/uk.json @@ -59,6 +59,7 @@ "room.raisedHand": null, "room.loweredHand": null, "room.extraVideo": null, + "room.overRoomLimit": null, "me.mutedPTT": null, diff --git a/server/config/config.example.js b/server/config/config.example.js index 6ff279a..8af3615 100644 --- a/server/config/config.example.js +++ b/server/config/config.example.js @@ -247,6 +247,8 @@ module.exports = // When truthy, the room will be open to all users when as long as there // are allready users in the room activateOnHostJoin : true, + // When set, maxUsersPerRoom defines how many users can join a single room. If not set, there is not limit. + // maxUsersPerRoom : 20, // Mediasoup settings mediasoup : { diff --git a/server/lib/Room.js b/server/lib/Room.js index 9200be9..bbd0cba 100644 --- a/server/lib/Room.js +++ b/server/lib/Room.js @@ -177,6 +177,9 @@ class Room extends EventEmitter peer.roles.some((role) => accessFromRoles.BYPASS_ROOM_LOCK.includes(role)) ) this._peerJoining(peer); + else if ('maxUsersPerRoom' in config &&(this._getJoinedPeers().length + this._lobby.peerList().length) >= config.maxUsersPerRoom) { + this._handleOverRoomLimit(peer); + } else if (this._locked) this._parkPeer(peer); else @@ -188,6 +191,11 @@ class Room extends EventEmitter } } + _handleOverRoomLimit(peer) + { + this._notification(peer.socket, 'overRoomLimit'); + } + _handleGuest(peer) { if (config.activateOnHostJoin && !this.checkEmpty()) From 004fac3d1306e4decfca35b586c9d028b1faf2e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9sz=C3=A1ros=20Mih=C3=A1ly?= Date: Mon, 4 May 2020 21:48:14 +0200 Subject: [PATCH 08/88] Import Latvian translation --- app/src/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/index.js b/app/src/index.js index 90bfa4d..cdefb40 100644 --- a/app/src/index.js +++ b/app/src/index.js @@ -38,6 +38,7 @@ import messagesCzech from './translations/cs'; import messagesItalian from './translations/it'; import messagesUkrainian from './translations/uk'; import messagesTurkish from './translations/tr'; +import messagesLatvian from './translations/lv'; import './index.css'; @@ -63,7 +64,8 @@ const messages = 'cs' : messagesCzech, 'it' : messagesItalian, 'uk' : messagesUkrainian, - 'tr' : messagesTurkish + 'tr' : messagesTurkish, + 'lv' : messagesLatvian }; const locale = navigator.language.split(/[-_]/)[0]; // language without region code From 381f9cd7330f1051c56404ef76387987afc745e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Mon, 4 May 2020 23:33:51 +0200 Subject: [PATCH 09/88] All peers enter the same router up to config.routerScaleSize. Then go to the next one, and keep going until all routers are filled up to config.routerScaleSize. After that simple put peers into routers with least peers. --- server/config/config.example.js | 2 + server/lib/Room.js | 117 +++++++++++++++++++++++++++----- 2 files changed, 103 insertions(+), 16 deletions(-) diff --git a/server/config/config.example.js b/server/config/config.example.js index 740a9ae..3541327 100644 --- a/server/config/config.example.js +++ b/server/config/config.example.js @@ -60,6 +60,8 @@ module.exports = // When truthy, the room will be open to all users when the first // authenticated user has already joined the room. activateOnHostJoin : true, + // Room size before spreading to new router + routerScaleSize : 20, // Mediasoup settings mediasoup : { diff --git a/server/lib/Room.js b/server/lib/Room.js index 23f6d53..2dc2368 100644 --- a/server/lib/Room.js +++ b/server/lib/Room.js @@ -5,7 +5,7 @@ const config = require('../config/config'); const logger = new Logger('Room'); -const ROUTER_SCALE_SIZE = 40; +const ROUTER_SCALE_SIZE = config.routerScaleSize || 20; class Room extends EventEmitter { @@ -25,23 +25,30 @@ class Room extends EventEmitter // Router media codecs. const mediaCodecs = config.mediasoup.router.mediaCodecs; - const mediasoupRouters = []; + const mediasoupRouters = new Map(); + + let firstRouter = null; for (const worker of mediasoupWorkers) { const router = await worker.createRouter({ mediaCodecs }); - mediasoupRouters.push(router); + if (!firstRouter) + firstRouter = router; + + mediasoupRouters.set(router.id, router); } // Create a mediasoup AudioLevelObserver on first router - const audioLevelObserver = await mediasoupRouters[0].createAudioLevelObserver( + const audioLevelObserver = await firstRouter.createAudioLevelObserver( { maxEntries : 1, threshold : -80, interval : 800 }); + firstRouter = null; + return new Room({ roomId, mediasoupRouters, audioLevelObserver }); } @@ -81,6 +88,11 @@ class Room extends EventEmitter // Array of mediasoup Router instances. this._mediasoupRouters = mediasoupRouters; + // The router we are currently putting peers in + this._routerIterator = this._mediasoupRouters.values(); + + this._currentRouter = this._routerIterator.next().value; + // mediasoup AudioLevelObserver. this._audioLevelObserver = audioLevelObserver; @@ -102,8 +114,14 @@ class Room extends EventEmitter this._closed = true; + this._chatHistory = null; + + this._fileHistory = null; + this._lobby.close(); + this._lobby = null; + // Close the peers. for (const peer in this._peers) { @@ -117,11 +135,19 @@ class Room extends EventEmitter this._peers = null; // Close the mediasoup Routers. - for (const router of this._mediasoupRouters) + for (const router of this._mediasoupRouters.values()) { router.close(); } + this._routerIterator = null; + + this._currentRouter = null; + + this._mediasoupRouters.clear(); + + this._audioLevelObserver = null; + // Emit 'close' event. this.emit('close'); } @@ -330,7 +356,7 @@ class Room extends EventEmitter } } - _peerJoining(peer) + async _peerJoining(peer) { peer.socket.join(this._roomId); @@ -343,8 +369,8 @@ class Room extends EventEmitter this._peers[peer.id] = peer; - // Assign least loaded router - peer.routerId = this._getLeastLoadedRouter(); + // Assign routerId + peer.routerId = await this._getRouterId(); this._handlePeer(peer); this._notification(peer.socket, 'roomReady'); @@ -428,7 +454,7 @@ class Room extends EventEmitter async _handleSocketRequest(peer, request, cb) { const router = - this._mediasoupRouters.find((peerRouter) => peerRouter.id === peer.routerId); + this._mediasoupRouters.get(peer.routerId); switch (request.method) { @@ -632,9 +658,11 @@ class Room extends EventEmitter const producer = await transport.produce({ kind, rtpParameters, appData }); - for (const destinationRouter of this._mediasoupRouters) + const pipeRouters = this._getRoutersToPipeTo(peer.routerId); + + for (const [ routerId, destinationRouter ] of this._mediasoupRouters) { - if (destinationRouter !== router) + if (pipeRouters.includes(routerId)) { await router.pipeToRouter({ producerId : producer.id, @@ -1117,8 +1145,7 @@ class Room extends EventEmitter producer.id ); - const router = this._mediasoupRouters.find((producerRouter) => - producerRouter.id === producerPeer.routerId); + const router = this._mediasoupRouters.get(producerPeer.routerId); // Optimization: // - Create the server-side Consumer. If video, do it paused. @@ -1324,19 +1351,77 @@ class Room extends EventEmitter } } + async _pipeProducersToNewRouter() + { + const peersToPipe = + Object.values(this._peers) + .filter((peer) => peer.routerId !== this._currentRouter.id); + + for (const peer of peersToPipe) + { + const srcRouter = this._mediasoupRouters.get(peer.routerId); + + for (const producerId of peer.producers.keys()) + { + await srcRouter.pipeToRouter({ + producerId, + router : this._currentRouter + }); + } + } + } + + async _getRouterId() + { + if (this._currentRouter) + { + const routerLoad = + Object.values(this._peers) + .filter((peer) => peer.routerId === this._currentRouter.id).length; + + if (routerLoad >= ROUTER_SCALE_SIZE) + { + this._currentRouter = this._routerIterator.next().value; + + if (this._currentRouter) + { + await this._pipeProducersToNewRouter(); + + return this._currentRouter.id; + } + } + else + { + return this._currentRouter.id; + } + } + + return this._getLeastLoadedRouter(); + } + + // Returns an array of router ids we need to pipe to + _getRoutersToPipeTo(originRouterId) + { + return Object.values(this._peers) + .map((peer) => peer.routerId) + .filter((routerId, index, self) => + routerId !== originRouterId && self.indexOf(routerId) === index + ); + } + _getLeastLoadedRouter() { let load = Infinity; let id; - for (const router of this._mediasoupRouters) + for (const routerId of this._mediasoupRouters.keys()) { const routerLoad = - Object.values(this._peers).filter((peer) => peer.routerId === router.id).length; + Object.values(this._peers).filter((peer) => peer.routerId === routerId).length; if (routerLoad < load) { - id = router.id; + id = routerId; load = routerLoad; } } From 8c8a00f126cdf062ccb20f0c3cef932c9cce6ad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Mon, 4 May 2020 23:53:38 +0200 Subject: [PATCH 10/88] Remove config option that is not used anymore --- server/config/config.example.js | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/server/config/config.example.js b/server/config/config.example.js index ee41c3d..e8960f7 100644 --- a/server/config/config.example.js +++ b/server/config/config.example.js @@ -246,17 +246,11 @@ module.exports = }, // When truthy, the room will be open to all users when as long as there // are allready users in the room - activateOnHostJoin : true, - // If this is set to true, only signed-in users will be able - // to join a room directly. Non-signed-in users (guests) will - // always be put in the lobby regardless of room lock status. - // If false, there is no difference between guests and signed-in - // users when joining. - requireSignInToAccess : true, + activateOnHostJoin : true, // Room size before spreading to new router - routerScaleSize : 20, + routerScaleSize : 20, // Mediasoup settings - mediasoup : + mediasoup : { numWorkers : Object.keys(os.cpus()).length, // mediasoup Worker settings. From e039423dd5476d605ce7d452a072d71e10039ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9sz=C3=A1ros=20Mih=C3=A1ly?= Date: Tue, 5 May 2020 00:53:31 +0200 Subject: [PATCH 11/88] Fix lint --- app/public/config/config.example.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/public/config/config.example.js b/app/public/config/config.example.js index cf2703d..3f0ce49 100644 --- a/app/public/config/config.example.js +++ b/app/public/config/config.example.js @@ -33,7 +33,7 @@ var config = */ audioOutputSupportedBrowsers : [ - 'chrome', + 'chrome', 'opera' ], // Socket.io request timeout From ab5893dbdf6bb9e26f689bc98c31fd78bf15628d Mon Sep 17 00:00:00 2001 From: Astagor Date: Tue, 5 May 2020 08:02:11 +0200 Subject: [PATCH 12/88] Fixed lint --- app/src/components/JoinDialog.js | 16 ++++++++-------- app/src/reducers/room.js | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/components/JoinDialog.js b/app/src/components/JoinDialog.js index 8a2bc23..3308622 100644 --- a/app/src/components/JoinDialog.js +++ b/app/src/components/JoinDialog.js @@ -286,14 +286,14 @@ const JoinDialog = ({ fullWidth /> {!room.inLobby && room.overRoomLimit && - - - + + + } diff --git a/app/src/reducers/room.js b/app/src/reducers/room.js index a4bbfb4..6d47d42 100644 --- a/app/src/reducers/room.js +++ b/app/src/reducers/room.js @@ -6,7 +6,7 @@ const initialState = locked : false, inLobby : false, signInRequired : false, - overRoomLimit : false, + overRoomLimit : false, // access code to the room if locked and joinByAccessCode == true accessCode : '', // if true: accessCode is a possibility to open the room From 7e6795986efe166bd9e1c2a085178a8f6bbe763c Mon Sep 17 00:00:00 2001 From: Astagor Date: Tue, 5 May 2020 08:08:35 +0200 Subject: [PATCH 13/88] Fixed lint server --- server/lib/Room.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/lib/Room.js b/server/lib/Room.js index d43bf24..ec8febf 100644 --- a/server/lib/Room.js +++ b/server/lib/Room.js @@ -214,7 +214,8 @@ class Room extends EventEmitter peer.roles.some((role) => accessFromRoles.BYPASS_ROOM_LOCK.includes(role)) ) this._peerJoining(peer); - else if ('maxUsersPerRoom' in config &&(this._getJoinedPeers().length + this._lobby.peerList().length) >= config.maxUsersPerRoom) { + else if ('maxUsersPerRoom' in config &&(this._getJoinedPeers().length + this._lobby.peerList().length) >= config.maxUsersPerRoom) + { this._handleOverRoomLimit(peer); } else if (this._locked) From 5b8f2d83a9059713057be777bf7f66b4a8a7b69c Mon Sep 17 00:00:00 2001 From: Astagor Date: Tue, 5 May 2020 08:25:33 +0200 Subject: [PATCH 14/88] Added a div to wrapp buttons in FileSharing --- .../MeetingDrawer/FileSharing/FileSharing.js | 84 ++++++++++--------- 1 file changed, 45 insertions(+), 39 deletions(-) diff --git a/app/src/components/MeetingDrawer/FileSharing/FileSharing.js b/app/src/components/MeetingDrawer/FileSharing/FileSharing.js index 66a0d15..e74f0be 100644 --- a/app/src/components/MeetingDrawer/FileSharing/FileSharing.js +++ b/app/src/components/MeetingDrawer/FileSharing/FileSharing.js @@ -25,6 +25,10 @@ const styles = (theme) => button : { margin : theme.spacing(1) + }, + shareButtonsWrapper : + { + display : 'flex' } }); @@ -72,45 +76,47 @@ const FileSharing = (props) => return ( - (e.target.value = null)} - id='share-files-button' - /> - - - { - (browser.platform === 'mobile') && canShareFiles && canShare && - } +
+ (e.target.value = null)} + id='share-files-button' + /> + + + { + (browser.platform === 'mobile') && canShareFiles && canShare && + } +
); From 897b99cdbe0b1f81dd97cae050faa2791e684ab3 Mon Sep 17 00:00:00 2001 From: Astagor Date: Tue, 5 May 2020 08:31:45 +0200 Subject: [PATCH 15/88] Fixed lint --- .../MeetingDrawer/FileSharing/FileSharing.js | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/app/src/components/MeetingDrawer/FileSharing/FileSharing.js b/app/src/components/MeetingDrawer/FileSharing/FileSharing.js index e74f0be..010aa1b 100644 --- a/app/src/components/MeetingDrawer/FileSharing/FileSharing.js +++ b/app/src/components/MeetingDrawer/FileSharing/FileSharing.js @@ -28,7 +28,7 @@ const styles = (theme) => }, shareButtonsWrapper : { - display : 'flex' + display : 'flex' } }); @@ -91,7 +91,7 @@ const FileSharing = (props) => type='file' disabled={!canShare} onChange={handleFileChange} - accept="image/*" + accept='image/*' id='share-files-gallery-button' /> { - (browser.platform === 'mobile') && canShareFiles && canShare && + (browser.platform === 'mobile') && canShareFiles && canShare && } @@ -124,7 +124,7 @@ const FileSharing = (props) => FileSharing.propTypes = { roomClient : PropTypes.any.isRequired, - browser : PropTypes.object.isRequired, + browser : PropTypes.object.isRequired, canShareFiles : PropTypes.bool.isRequired, tabOpen : PropTypes.bool.isRequired, canShare : PropTypes.bool.isRequired, @@ -135,7 +135,7 @@ const mapStateToProps = (state) => { return { canShareFiles : state.me.canShareFiles, - browser : state.me.browser, + browser : state.me.browser, tabOpen : state.toolarea.currentToolTab === 'files', canShare : state.me.roles.some((role) => From e950ec9dbe16ac5990393a660f7222d721794813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9sz=C3=A1ros=20Mih=C3=A1ly?= Date: Fri, 24 Apr 2020 23:42:59 +0200 Subject: [PATCH 16/88] Add an error handler to Express to dump OIDC errors with uuid --- server/server.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/server/server.js b/server/server.js index c3aec39..5188fd1 100755 --- a/server/server.js +++ b/server/server.js @@ -35,6 +35,7 @@ const RedisStore = require('connect-redis')(expressSession); const sharedSession = require('express-socket.io-session'); const interactiveServer = require('./lib/interactiveServer'); const promExporter = require('./lib/promExporter'); +const { v4: uuidv4 } = require('uuid'); /* eslint-disable no-console */ console.log('- process.env.DEBUG:', process.env.DEBUG); @@ -157,6 +158,24 @@ async function run() // Run WebSocketServer. await runWebSocketServer(); + function errorHandler(err, req, res, next) + { + const trackingId = uuidv4(); + + res.status(500).send( + `

Internal Server Error

+

If you report this error, please also report this + tracking ID which makes it possible to locate your session + in the logs which are available to the system administrator: + ${trackingId}

` + ); + logger.error( + 'Express error handler dump with tracking ID: %s, error dump: %o', + trackingId, err); + } + + app.use(errorHandler); + // Log rooms status every 30 seconds. setInterval(() => { From a46de5ff54c6642300d06d834881d086a08d45ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9sz=C3=A1ros=20Mih=C3=A1ly?= Date: Mon, 4 May 2020 02:32:06 +0200 Subject: [PATCH 17/88] eslint disable unused vars for next --- server/server.js | 1 + 1 file changed, 1 insertion(+) diff --git a/server/server.js b/server/server.js index 5188fd1..673c6e5 100755 --- a/server/server.js +++ b/server/server.js @@ -158,6 +158,7 @@ async function run() // Run WebSocketServer. await runWebSocketServer(); + // eslint-disable-next-line no-unused-vars function errorHandler(err, req, res, next) { const trackingId = uuidv4(); From e2421f094fa0083f78d1dcff99736169e4103695 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Tue, 5 May 2020 09:48:26 +0200 Subject: [PATCH 18/88] Shuffle workers to get routers on random cores. Increase routerScaleSize default to 40. --- server/config/config.example.js | 5 +++-- server/lib/Room.js | 8 ++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/server/config/config.example.js b/server/config/config.example.js index 5b47582..9d27f11 100644 --- a/server/config/config.example.js +++ b/server/config/config.example.js @@ -247,10 +247,11 @@ module.exports = // When truthy, the room will be open to all users when as long as there // are allready users in the room activateOnHostJoin : true, - // When set, maxUsersPerRoom defines how many users can join a single room. If not set, there is not limit. + // When set, maxUsersPerRoom defines how many users can join + // a single room. If not set, there is no limit. // maxUsersPerRoom : 20, // Room size before spreading to new router - routerScaleSize : 20, + routerScaleSize : 40, // Mediasoup settings mediasoup : { diff --git a/server/lib/Room.js b/server/lib/Room.js index ec8febf..1d30944 100644 --- a/server/lib/Room.js +++ b/server/lib/Room.js @@ -31,7 +31,7 @@ const permissionsFromRoles = ...config.permissionsFromRoles }; -const ROUTER_SCALE_SIZE = config.routerScaleSize || 20; +const ROUTER_SCALE_SIZE = config.routerScaleSize || 40; class Room extends EventEmitter { @@ -48,6 +48,9 @@ class Room extends EventEmitter { logger.info('create() [roomId:"%s"]', roomId); + // Shuffle workers to get random cores + let shuffledWorkers = mediasoupWorkers.sort(() => Math.random() - 0.5); + // Router media codecs. const mediaCodecs = config.mediasoup.router.mediaCodecs; @@ -55,7 +58,7 @@ class Room extends EventEmitter let firstRouter = null; - for (const worker of mediasoupWorkers) + for (const worker of shuffledWorkers) { const router = await worker.createRouter({ mediaCodecs }); @@ -74,6 +77,7 @@ class Room extends EventEmitter }); firstRouter = null; + shuffledWorkers = null; return new Room({ roomId, mediasoupRouters, audioLevelObserver }); } From 7adcef19a3815fc71ae1a5116a18e9039580cce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Tue, 5 May 2020 09:56:21 +0200 Subject: [PATCH 19/88] Change to total number of peers, not just joined. Styling. --- server/lib/Room.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/lib/Room.js b/server/lib/Room.js index 1d30944..0f89b4d 100644 --- a/server/lib/Room.js +++ b/server/lib/Room.js @@ -218,7 +218,12 @@ class Room extends EventEmitter peer.roles.some((role) => accessFromRoles.BYPASS_ROOM_LOCK.includes(role)) ) this._peerJoining(peer); - else if ('maxUsersPerRoom' in config &&(this._getJoinedPeers().length + this._lobby.peerList().length) >= config.maxUsersPerRoom) + else if ( + 'maxUsersPerRoom' in config && + ( + Object.keys(this._peers).length + + this._lobby.peerList().length + ) >= config.maxUsersPerRoom) { this._handleOverRoomLimit(peer); } From 65f47d4b8dfd9f5ae4ba3dce3dd26e6b5fb97b53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Tue, 5 May 2020 10:31:41 +0200 Subject: [PATCH 20/88] Fix screensharing on Edge --- app/src/ScreenShare.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/ScreenShare.js b/app/src/ScreenShare.js index 180fe2a..2ff1bcc 100644 --- a/app/src/ScreenShare.js +++ b/app/src/ScreenShare.js @@ -225,10 +225,7 @@ export default class ScreenShare return new DisplayMediaScreenShare(); } case 'chrome': - { - return new DisplayMediaScreenShare(); - } - case 'msedge': + case 'edge': { return new DisplayMediaScreenShare(); } From dd6016e855a10b45aa81c69dfd816402aacd47d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Tue, 5 May 2020 13:06:16 +0200 Subject: [PATCH 21/88] Remove unused function --- server/server.js | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/server/server.js b/server/server.js index 673c6e5..42112fe 100755 --- a/server/server.js +++ b/server/server.js @@ -56,10 +56,6 @@ if ('StatusLogger' in config) // @type {Array} const mediasoupWorkers = []; -// Index of next mediasoup Worker to use. -// @type {Number} -let nextMediasoupWorkerIdx = 0; - // Map of Room instances indexed by roomId. const rooms = new Map(); @@ -639,19 +635,6 @@ async function runMediasoupWorkers() } } -/** - * Get next mediasoup Worker. - */ -function getMediasoupWorker() -{ - const worker = mediasoupWorkers[nextMediasoupWorkerIdx]; - - if (++nextMediasoupWorkerIdx === mediasoupWorkers.length) - nextMediasoupWorkerIdx = 0; - - return worker; -} - /** * Get a Room instance (or create one if it does not exist). */ From dd9c0bb8971041754ad337dee1eb4f91495ee2b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9sz=C3=A1ros=20Mih=C3=A1ly?= Date: Tue, 5 May 2020 13:36:32 +0200 Subject: [PATCH 22/88] Update hungarian translation with the missing ones --- app/src/translations/hu.json | 66 ++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/app/src/translations/hu.json b/app/src/translations/hu.json index e21ebf3..e185cfb 100644 --- a/app/src/translations/hu.json +++ b/app/src/translations/hu.json @@ -4,9 +4,9 @@ "socket.reconnected": "Sikeres újarkapcsolódás", "socket.requestError": "Sikertelen szerver lekérés", - "room.chooseRoom": null, + "room.chooseRoom": "Válaszd ki a konferenciaszobát", "room.cookieConsent": "Ez a weblap a felhasználói élmény fokozása miatt sütiket használ", - "room.consentUnderstand": "I understand", + "room.consentUnderstand": "Megértettem", "room.joined": "Csatlakozátál a konferenciához", "room.cantJoin": "Sikertelen csatlakozás a konferenciához", "room.youLocked": "A konferenciába való belépés letiltva", @@ -40,7 +40,7 @@ "room.audioVideo": "Hang és Videó", "room.youAreReady": "Ok, kész vagy", "room.emptyRequireLogin": "A konferencia üres! Be kell lépned a konferecnia elkezdéséhez, vagy várnod kell amíg a házigazda becsatlakozik.", - "room.locketWait": "A konferencia szobába a a belépés tilos - Várj amíg valaki be nem enged ...", + "room.locketWait": "Az automatikus belépés tiltva van - Várj amíg valaki beenged ...", "room.lobbyAdministration": "Előszoba adminisztráció", "room.peersInLobby": "Résztvevők az előszobában", "room.lobbyEmpty": "Épp senki sincs a konferencia előszobájában", @@ -49,22 +49,22 @@ "room.spotlights": "Látható résztvevők", "room.passive": "Passzív résztvevők", "room.videoPaused": "Ez a videóstream szünetel", - "room.muteAll": null, - "room.stopAllVideo": null, - "room.closeMeeting": null, - "room.clearChat": null, - "room.clearFileSharing": null, - "room.speechUnsupported": null, - "room.moderatoractions": null, - "room.raisedHand": null, - "room.loweredHand": null, - "room.extraVideo": null, - "room.overRoomLimit": null, + "room.muteAll": "Mindenki némítása", + "room.stopAllVideo": "Mindenki video némítása", + "room.closeMeeting": "Konferencia lebontása", + "room.clearChat": "Chat történelem kiürítése", + "room.clearFileSharing": "File megosztás kiürítése", + "room.speechUnsupported": "A böngésződ nem támogatja a hangszint", + "room.moderatoractions": "Moderátor funkciók", + "room.raisedHand": "{displayName} jelentkezik", + "room.loweredHand": "{displayName} leeresztette a kezét", + "room.extraVideo": "Kiegészítő videó", + "room.overRoomLimit": "A konferenciaszoba betelt..", - "me.mutedPTT": null, + "me.mutedPTT": "Némítva vagy, ha beszélnél nyomd le a szóköz billentyűt", - "roles.gotRole": null, - "roles.lostRole": null, + "roles.gotRole": "{role} szerepet kaptál", + "roles.lostRole": "Elvesztetted a {role} szerepet", "tooltip.login": "Belépés", "tooltip.logout": "Kilépés", @@ -76,10 +76,10 @@ "tooltip.lobby": "Az előszobában várakozók listája", "tooltip.settings": "Beállítások", "tooltip.participants": "Résztvevők", - "tooltip.kickParticipant": null, - "tooltip.muteParticipant": null, - "tooltip.muteParticipantVideo": null, - "tooltip.raisedHand": null, + "tooltip.kickParticipant": "Résztvevő kirúgása", + "tooltip.muteParticipant": "Résztvevő némítása", + "tooltip.muteParticipantVideo": "Résztvevő video némítása", + "tooltip.raisedHand": "Jelentkezés", "label.roomName": "Konferencia", "label.chooseRoomButton": "Tovább", @@ -93,7 +93,7 @@ "label.filesharing": "Fájl megosztás", "label.participants": "Résztvevők", "label.shareFile": "Fájl megosztása", - "label.shareGalleryFile": null, + "label.shareGalleryFile": "Fájl megosztás galériából", "label.fileSharingUnsupported": "Fájl megosztás nem támogatott", "label.unknown": "Ismeretlen", "label.democratic": "Egyforma képméretű képkiosztás", @@ -104,11 +104,11 @@ "label.veryHigh": "Nagyon magas (FHD)", "label.ultra": "Ultra magas (UHD)", "label.close": "Bezár", - "label.media": null, - "label.appearence": null, - "label.advanced": null, - "label.addVideo": null, - "label.promoteAllPeers": null, + "label.media": "Média", + "label.appearence": "Megjelenés", + "label.advanced": "Részletek", + "label.addVideo": "Videó hozzáadása", + "label.promoteAllPeers": "Mindenkit beengedek", "settings.settings": "Beállítások", "settings.camera": "Kamera", @@ -126,8 +126,8 @@ "settings.advancedMode": "Részletes információk", "settings.permanentTopBar": "Állandó felső sáv", "settings.lastn": "A látható videók száma", - "settings.hiddenControls": null, - "settings.notificationSounds": null, + "settings.hiddenControls": "Média Gombok automatikus elrejtése", + "settings.notificationSounds": "Értesítések hangjelzjéssel", "filesharing.saveFileError": "A file-t nem sikerült elmenteni", "filesharing.startingFileShare": "Fájl megosztása", @@ -169,8 +169,8 @@ "devices.cameraDisconnected": "A kamera kapcsolata lebomlott", "devices.cameraError": "Hiba történt a kamera elérése során", - "moderator.clearChat": null, - "moderator.clearFiles": null, - "moderator.muteAudio": null, - "moderator.muteVideo": null + "moderator.clearChat": "A moderátor kiürítette a chat történelmet", + "moderator.clearFiles": "A moderátor kiürítette a file megosztás történelmet", + "moderator.muteAudio": "A moderátor elnémította a hangod", + "moderator.muteVideo": "A moderátor elnémította a videód" } From 3f75a6c50638aa1e855e5d77e2f30a998ea2c5d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Tue, 5 May 2020 13:43:32 +0200 Subject: [PATCH 23/88] Fix icon colors --- app/src/components/AccessControl/LockDialog/ListLobbyPeer.js | 1 + app/src/components/MeetingDrawer/ParticipantList/ListMe.js | 1 + 2 files changed, 2 insertions(+) diff --git a/app/src/components/AccessControl/LockDialog/ListLobbyPeer.js b/app/src/components/AccessControl/LockDialog/ListLobbyPeer.js index 8f9843a..9e73e82 100644 --- a/app/src/components/AccessControl/LockDialog/ListLobbyPeer.js +++ b/app/src/components/AccessControl/LockDialog/ListLobbyPeer.js @@ -61,6 +61,7 @@ const ListLobbyPeer = (props) => peer.promotionInProgress || promotionInProgress } + color='primary' onClick={(e) => { e.stopPropagation(); diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListMe.js b/app/src/components/MeetingDrawer/ParticipantList/ListMe.js index 762af00..a32412f 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ListMe.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ListMe.js @@ -65,6 +65,7 @@ const ListMe = (props) => })} className={me.raisedHand ? classes.green : null} disabled={me.raisedHandInProgress} + color='primary' onClick={(e) => { e.stopPropagation(); From 2bb64596b0193ca22731db16263b60a483d63246 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Tue, 5 May 2020 14:29:54 +0200 Subject: [PATCH 24/88] Fix button sizes and take care not to overflow container in filmstrip, ref #115 --- app/src/components/Containers/Me.js | 373 +++++++++++++------ app/src/components/Containers/Peer.js | 305 ++++++++++----- app/src/components/MeetingViews/Filmstrip.js | 6 +- 3 files changed, 474 insertions(+), 210 deletions(-) diff --git a/app/src/components/Containers/Me.js b/app/src/components/Containers/Me.js index 5e8e0dc..68ca46d 100644 --- a/app/src/components/Containers/Me.js +++ b/app/src/components/Containers/Me.js @@ -10,6 +10,7 @@ import { useIntl, FormattedMessage } from 'react-intl'; import VideoView from '../VideoContainers/VideoView'; import Volume from './Volume'; import Fab from '@material-ui/core/Fab'; +import IconButton from '@material-ui/core/IconButton'; import Tooltip from '@material-ui/core/Tooltip'; import MicIcon from '@material-ui/icons/Mic'; import MicOffIcon from '@material-ui/icons/MicOff'; @@ -59,6 +60,19 @@ const styles = (theme) => margin : theme.spacing(1), pointerEvents : 'auto' }, + smallContainer : + { + backgroundColor : 'rgba(255, 255, 255, 0.9)', + margin : theme.spacing(0.25), + padding : theme.spacing(0.75), + boxShadow : '0px 3px 5px -1px rgba(0, 0, 0, 0.2), 0px 6px 10px 0px rgba(0, 0, 0, 0.14), 0px 1px 18px 0px rgba(0, 0, 0, 0.12)', + pointerEvents : 'auto', + transition : 'background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms', + '&:hover' : + { + backgroundColor : 'rgba(213, 213, 213, 1)' + } + }, viewContainer : { position : 'relative', @@ -102,6 +116,10 @@ const styles = (theme) => '&.hover' : { opacity : 1 + }, + '&.smallContainer' : + { + fontSize : '3em' } } }, @@ -145,7 +163,7 @@ const Me = (props) => activeSpeaker, spacing, style, - smallButtons, + smallContainer, advancedMode, micProducer, webcamProducer, @@ -300,16 +318,18 @@ const Me = (props) => style={spacingStyle} >
-
- -
+ { !smallContainer && +
+ +
+ }
}, 2000); }} > -

+

- - { - if (micState === 'off') - roomClient.enableMic(); - else if (micState === 'on') - roomClient.muteMic(); - else - roomClient.unmuteMic(); - }} - > - { micState === 'on' ? - - : - - } - + { smallContainer ? + + { + if (micState === 'off') + roomClient.enableMic(); + else if (micState === 'on') + roomClient.muteMic(); + else + roomClient.unmuteMic(); + }} + > + { micState === 'on' ? + + : + + } + + : + + { + if (micState === 'off') + roomClient.enableMic(); + else if (micState === 'on') + roomClient.muteMic(); + else + roomClient.unmuteMic(); + }} + > + { micState === 'on' ? + + : + + } + + }
- - { - webcamState === 'on' ? - roomClient.disableWebcam() : - roomClient.enableWebcam(); - }} - > - { webcamState === 'on' ? - - : - - } - + { smallContainer ? + + { + webcamState === 'on' ? + roomClient.disableWebcam() : + roomClient.enableWebcam(); + }} + > + { webcamState === 'on' ? + + : + + } + + : + + { + webcamState === 'on' ? + roomClient.disableWebcam() : + roomClient.enableWebcam(); + }} + > + { webcamState === 'on' ? + + : + + } + + }
{ me.browser.platform !== 'mobile' &&
- - { - switch (screenState) - { - case 'on': - { - roomClient.disableScreenSharing(); - break; - } - case 'off': - { - roomClient.enableScreenSharing(); - break; - } - default: - { - break; - } + { smallContainer ? + - { (screenState === 'on' || screenState === 'unsupported') && - - } - { screenState === 'off' && - - } - + color='primary' + size='small' + onClick={() => + { + switch (screenState) + { + case 'on': + { + roomClient.disableScreenSharing(); + break; + } + case 'off': + { + roomClient.enableScreenSharing(); + break; + } + default: + { + break; + } + } + }} + > + { (screenState === 'on' || screenState === 'unsupported') && + + } + { screenState === 'off' && + + } + + + : + + { + switch (screenState) + { + case 'on': + { + roomClient.disableScreenSharing(); + break; + } + case 'off': + { + roomClient.enableScreenSharing(); + break; + } + default: + { + break; + } + } + }} + > + { (screenState === 'on' || screenState === 'unsupported') && + + } + { screenState === 'off' && + + } + + }
} @@ -537,21 +660,41 @@ const Me = (props) =>
- - { - roomClient.disableExtraVideo(producer.id); - }} - > - - + { smallContainer ? + + { + roomClient.disableExtraVideo(producer.id); + }} + > + + + + : + + { + roomClient.disableExtraVideo(producer.id); + }} + > + + + }
@@ -663,7 +806,7 @@ Me.propTypes = extraVideoProducers : PropTypes.arrayOf(appPropTypes.Producer), spacing : PropTypes.number, style : PropTypes.object, - smallButtons : PropTypes.bool, + smallContainer : PropTypes.bool, canShareScreen : PropTypes.bool.isRequired, classes : PropTypes.object.isRequired, theme : PropTypes.object.isRequired diff --git a/app/src/components/Containers/Peer.js b/app/src/components/Containers/Peer.js index 3e6e776..2715c64 100644 --- a/app/src/components/Containers/Peer.js +++ b/app/src/components/Containers/Peer.js @@ -12,6 +12,7 @@ import { useIntl, FormattedMessage } from 'react-intl'; import VideoView from '../VideoContainers/VideoView'; import Tooltip from '@material-ui/core/Tooltip'; import Fab from '@material-ui/core/Fab'; +import IconButton from '@material-ui/core/IconButton'; import VolumeUpIcon from '@material-ui/icons/VolumeUp'; import VolumeOffIcon from '@material-ui/icons/VolumeOff'; import NewWindowIcon from '@material-ui/icons/OpenInNew'; @@ -59,6 +60,19 @@ const styles = (theme) => { margin : theme.spacing(1) }, + smallContainer : + { + backgroundColor : 'rgba(255, 255, 255, 0.9)', + margin : theme.spacing(0.25), + padding : theme.spacing(0.75), + boxShadow : '0px 3px 5px -1px rgba(0, 0, 0, 0.2), 0px 6px 10px 0px rgba(0, 0, 0, 0.14), 0px 1px 18px 0px rgba(0, 0, 0, 0.12)', + pointerEvents : 'auto', + transition : 'background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms', + '&:hover' : + { + backgroundColor : 'rgba(213, 213, 213, 1)' + } + }, viewContainer : { position : 'relative', @@ -130,7 +144,7 @@ const Peer = (props) => toggleConsumerWindow, spacing, style, - smallButtons, + smallContainer, windowConsumer, classes, theme @@ -236,28 +250,53 @@ const Peer = (props) => placement={smallScreen ? 'top' : 'left'} >
- - { - micEnabled ? - roomClient.modifyPeerConsumer(peer.id, 'mic', true) : - roomClient.modifyPeerConsumer(peer.id, 'mic', false); - }} - > - { micEnabled ? - - : - - } - + { smallContainer ? + + { + micEnabled ? + roomClient.modifyPeerConsumer(peer.id, 'mic', true) : + roomClient.modifyPeerConsumer(peer.id, 'mic', false); + }} + > + { micEnabled ? + + : + + } + + : + + { + micEnabled ? + roomClient.modifyPeerConsumer(peer.id, 'mic', true) : + roomClient.modifyPeerConsumer(peer.id, 'mic', false); + }} + > + { micEnabled ? + + : + + } + + }
@@ -270,24 +309,46 @@ const Peer = (props) => placement={smallScreen ? 'top' : 'left'} >
- - { - toggleConsumerWindow(webcamConsumer); - }} - > - - + { smallContainer ? + + { + toggleConsumerWindow(webcamConsumer); + }} + > + + + : + + { + toggleConsumerWindow(webcamConsumer); + }} + > + + + }
} @@ -300,21 +361,40 @@ const Peer = (props) => placement={smallScreen ? 'top' : 'left'} >
- - { - toggleConsumerFullscreen(webcamConsumer); - }} - > - - + { smallContainer ? + + { + toggleConsumerFullscreen(webcamConsumer); + }} + > + + + : + + { + toggleConsumerFullscreen(webcamConsumer); + }} + > + + + }
@@ -428,24 +508,46 @@ const Peer = (props) => placement={smallScreen ? 'top' : 'left'} >
- - { - toggleConsumerWindow(consumer); - }} - > - - + { smallContainer ? + + { + toggleConsumerWindow(consumer); + }} + > + + + : + + { + toggleConsumerWindow(consumer); + }} + > + + + }
} @@ -458,21 +560,40 @@ const Peer = (props) => placement={smallScreen ? 'top' : 'left'} >
- - { - toggleConsumerFullscreen(consumer); - }} - > - - + { smallContainer ? + + { + toggleConsumerFullscreen(consumer); + }} + > + + + : + + { + toggleConsumerFullscreen(consumer); + }} + > + + + }
@@ -584,7 +705,7 @@ const Peer = (props) => !screenVisible || (windowConsumer === screenConsumer.id) } - size={smallButtons ? 'small' : 'large'} + size={smallContainer ? 'small' : 'large'} onClick={() => { toggleConsumerWindow(screenConsumer); @@ -611,7 +732,7 @@ const Peer = (props) => })} className={classes.fab} disabled={!screenVisible} - size={smallButtons ? 'small' : 'large'} + size={smallContainer ? 'small' : 'large'} onClick={() => { toggleConsumerFullscreen(screenConsumer); @@ -670,7 +791,7 @@ Peer.propTypes = browser : PropTypes.object.isRequired, spacing : PropTypes.number, style : PropTypes.object, - smallButtons : PropTypes.bool, + smallContainer : PropTypes.bool, toggleConsumerFullscreen : PropTypes.func.isRequired, toggleConsumerWindow : PropTypes.func.isRequired, classes : PropTypes.object.isRequired, diff --git a/app/src/components/MeetingViews/Filmstrip.js b/app/src/components/MeetingViews/Filmstrip.js index f78e5b5..a1a3cbe 100644 --- a/app/src/components/MeetingViews/Filmstrip.js +++ b/app/src/components/MeetingViews/Filmstrip.js @@ -49,7 +49,7 @@ const styles = () => }, '&.active' : { - opacity : '0.6' + borderColor : 'var(--selected-peer-border-color)' } }, hiddenToolBar : @@ -279,7 +279,7 @@ class Filmstrip extends React.PureComponent @@ -302,7 +302,7 @@ class Filmstrip extends React.PureComponent advancedMode={advancedMode} id={peerId} style={peerStyle} - smallButtons + smallContainer /> From bf837b3398857b996273a150f5e01b4e5ab669d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Tue, 5 May 2020 14:31:02 +0200 Subject: [PATCH 25/88] Fix color on kick button --- app/src/components/MeetingDrawer/ParticipantList/ListPeer.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js index d4b1409..914ae04 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js @@ -173,6 +173,7 @@ const ListPeer = (props) => defaultMessage : 'Kick out participant' })} disabled={peer.peerKickInProgress} + color='secondary' onClick={(e) => { e.stopPropagation(); From 678abe05d0078a0af481ff529e043805957fc1ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Tue, 5 May 2020 15:00:58 +0200 Subject: [PATCH 26/88] Update translations --- app/src/components/JoinDialog.js | 2 +- app/src/components/MeetingDrawer/FileSharing/FileSharing.js | 2 +- app/src/translations/en.json | 4 ++-- app/src/translations/nb.json | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/components/JoinDialog.js b/app/src/components/JoinDialog.js index 3308622..3d2596f 100644 --- a/app/src/components/JoinDialog.js +++ b/app/src/components/JoinDialog.js @@ -290,7 +290,7 @@ const JoinDialog = ({ diff --git a/app/src/components/MeetingDrawer/FileSharing/FileSharing.js b/app/src/components/MeetingDrawer/FileSharing/FileSharing.js index 010aa1b..78ba569 100644 --- a/app/src/components/MeetingDrawer/FileSharing/FileSharing.js +++ b/app/src/components/MeetingDrawer/FileSharing/FileSharing.js @@ -65,7 +65,7 @@ const FileSharing = (props) => const buttonGalleryDescription = canShareFiles ? intl.formatMessage({ id : 'label.shareGalleryFile', - defaultMessage : 'Share from gallery' + defaultMessage : 'Share image' }) : intl.formatMessage({ diff --git a/app/src/translations/en.json b/app/src/translations/en.json index 758e9f0..19c46b4 100644 --- a/app/src/translations/en.json +++ b/app/src/translations/en.json @@ -59,7 +59,7 @@ "room.raisedHand": "{displayName} raised their hand", "room.loweredHand": "{displayName} put their hand down", "room.extraVideo": "Extra video", - "room.overRoomLimit": null, + "room.overRoomLimit": "The room is full, retry after some time.", "me.mutedPTT": "You are muted, hold down SPACE-BAR to talk", @@ -93,7 +93,7 @@ "label.filesharing": "File sharing", "label.participants": "Participants", "label.shareFile": "Share file", - "label.shareGalleryFile": null, + "label.shareGalleryFile": "Share image", "label.fileSharingUnsupported": "File sharing not supported", "label.unknown": "Unknown", "label.democratic": "Democratic view", diff --git a/app/src/translations/nb.json b/app/src/translations/nb.json index 38ccd74..2965f59 100644 --- a/app/src/translations/nb.json +++ b/app/src/translations/nb.json @@ -59,7 +59,7 @@ "room.raisedHand": "{displayName} rakk opp hånden", "room.loweredHand": "{displayName} tok ned hånden", "room.extraVideo": "Ekstra video", - "room.overRoomLimit": null, + "room.overRoomLimit": "Rommet er fullt, prøv igjen om litt.", "me.mutedPTT": "Du er dempet, hold nede SPACE for å snakke", @@ -93,7 +93,7 @@ "label.filesharing": "Fildeling", "label.participants": "Deltakere", "label.shareFile": "Del fil", - "label.shareGalleryFile": null, + "label.shareGalleryFile": "Del bilde", "label.fileSharingUnsupported": "Fildeling ikke støttet", "label.unknown": "Ukjent", "label.democratic": "Demokratisk", From e2211b000c1f61366b23092d6f37441accc7d187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Tue, 5 May 2020 15:06:18 +0200 Subject: [PATCH 27/88] Fix button scaling in filmstrip --- app/src/components/Containers/Me.js | 4 ++-- app/src/components/Containers/Peer.js | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/components/Containers/Me.js b/app/src/components/Containers/Me.js index 68ca46d..f368089 100644 --- a/app/src/components/Containers/Me.js +++ b/app/src/components/Containers/Me.js @@ -63,8 +63,8 @@ const styles = (theme) => smallContainer : { backgroundColor : 'rgba(255, 255, 255, 0.9)', - margin : theme.spacing(0.25), - padding : theme.spacing(0.75), + margin : '0.5vmin', + padding : '0.5vmin', boxShadow : '0px 3px 5px -1px rgba(0, 0, 0, 0.2), 0px 6px 10px 0px rgba(0, 0, 0, 0.14), 0px 1px 18px 0px rgba(0, 0, 0, 0.12)', pointerEvents : 'auto', transition : 'background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms', diff --git a/app/src/components/Containers/Peer.js b/app/src/components/Containers/Peer.js index 2715c64..4fc495e 100644 --- a/app/src/components/Containers/Peer.js +++ b/app/src/components/Containers/Peer.js @@ -63,8 +63,8 @@ const styles = (theme) => smallContainer : { backgroundColor : 'rgba(255, 255, 255, 0.9)', - margin : theme.spacing(0.25), - padding : theme.spacing(0.75), + margin : '0.5vmin', + padding : '0.5vmin', boxShadow : '0px 3px 5px -1px rgba(0, 0, 0, 0.2), 0px 6px 10px 0px rgba(0, 0, 0, 0.14), 0px 1px 18px 0px rgba(0, 0, 0, 0.12)', pointerEvents : 'auto', transition : 'background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,border 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms', From 15fe4521b7fb6c4274e4271715170d7cffb55d81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Tue, 5 May 2020 15:15:26 +0200 Subject: [PATCH 28/88] Fix bug on resizing filmstrip --- app/src/components/MeetingViews/Filmstrip.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/components/MeetingViews/Filmstrip.js b/app/src/components/MeetingViews/Filmstrip.js index a1a3cbe..d1cfba6 100644 --- a/app/src/components/MeetingViews/Filmstrip.js +++ b/app/src/components/MeetingViews/Filmstrip.js @@ -123,6 +123,9 @@ class Filmstrip extends React.PureComponent const root = this.rootContainer.current; + if (!root) + return; + const availableWidth = root.clientWidth; // Grid is: // 4/5 speaker From 22019434cc9cffca6ce44f6e56c84437ca9d6982 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sa=C5=A1a=20Davidovi=C4=87?= <62179680+sd4v1d@users.noreply.github.com> Date: Tue, 5 May 2020 18:02:23 +0200 Subject: [PATCH 29/88] Croatian translation update --- app/src/translations/hr.json | 54 ++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/app/src/translations/hr.json b/app/src/translations/hr.json index 88596a6..1f5ce55 100644 --- a/app/src/translations/hr.json +++ b/app/src/translations/hr.json @@ -52,19 +52,19 @@ "room.muteAll": "Utišaj sve", "room.stopAllVideo": "Ugasi sve kamere", "room.closeMeeting": "Završi sastanak", - "room.clearChat": null, - "room.clearFileSharing": null, + "room.clearChat": "Izbriši razgovor", + "room.clearFileSharing": "Izbriši dijeljene datoteke", "room.speechUnsupported": "Vaš preglednik ne podržava prepoznavanje govora", - "room.moderatoractions": null, - "room.raisedHand": null, - "room.loweredHand": null, - "room.extraVideo": null, - "room.overRoomLimit": null, + "room.moderatoractions": "Akcije moderatora", + "room.raisedHand": "{displayName} je podigao ruku", + "room.loweredHand": "{displayName} je spustio ruku", + "room.extraVideo": "Dodatni video", + "room.overRoomLimit": "Soba je popunjena, pokušajte ponovno kasnije.", "me.mutedPTT": "Utišani ste, pritisnite i držite SPACE tipku za razgovor", - "roles.gotRole": null, - "roles.lostRole": null, + "roles.gotRole": "Dodijeljena vam je uloga: {role}", + "roles.lostRole": "Uloga: {role} je povučena", "tooltip.login": "Prijava", "tooltip.logout": "Odjava", @@ -75,11 +75,11 @@ "tooltip.leaveFullscreen": "Izađi iz punog ekrana", "tooltip.lobby": "Prikaži predvorje", "tooltip.settings": "Prikaži postavke", - "tooltip.participants": "Pokažite sudionike", + "tooltip.participants": "Prikaži sudionike", "tooltip.kickParticipant": "Izbaci sudionika", - "tooltip.muteParticipant": null, - "tooltip.muteParticipantVideo": null, - "tooltip.raisedHand": null, + "tooltip.muteParticipant": "Utišaj sudionika", + "tooltip.muteParticipantVideo": "Ne primaj video sudionika", + "tooltip.raisedHand": "Podigni ruku", "label.roomName": "Naziv sobe", "label.chooseRoomButton": "Nastavi", @@ -93,7 +93,7 @@ "label.filesharing": "Dijeljenje datoteka", "label.participants": "Sudionici", "label.shareFile": "Dijeli datoteku", - "label.shareGalleryFile": null, + "label.shareGalleryFile": "Dijeli sliku", "label.fileSharingUnsupported": "Dijeljenje datoteka nije podržano", "label.unknown": "Nepoznato", "label.democratic":"Demokratski prikaz", @@ -104,11 +104,11 @@ "label.veryHigh": "Vrlo visoka (FHD)", "label.ultra": "Ultra visoka (UHD)", "label.close": "Zatvori", - "label.media": null, - "label.appearence": null, - "label.advanced": null, - "label.addVideo": null, - "label.promoteAllPeers": null, + "label.media": "Medij", + "label.appearence": "Prikaz", + "label.advanced": "Napredno", + "label.addVideo": "Dodaj video", + "label.promoteAllPeers": "Promoviraj sve", "settings.settings": "Postavke", "settings.camera": "Kamera", @@ -118,16 +118,16 @@ "settings.selectAudio": "Odaberi uređaj za zvuk", "settings.cantSelectAudio": "Nije moguće odabrati uređaj za zvuk", "settings.audioOutput": "Uređaj za izlaz zvuka", - "settings.selectAudioOutput": "Odaberite audio izlazni uređaj", - "settings.cantSelectAudioOutput": "Nije moguće odabrati audio izlazni uređaj", + "settings.selectAudioOutput": "Odaberite izlazni uređaj za zvuk", + "settings.cantSelectAudioOutput": "Nije moguće odabrati izlazni uređaj za zvuk", "settings.resolution": "Odaberi video rezoluciju", "settings.layout": "Način prikaza", "settings.selectRoomLayout": "Odaberi način prikaza", "settings.advancedMode": "Napredne mogućnosti", "settings.permanentTopBar": "Stalna gornja šipka", "settings.lastn": "Broj vidljivih videozapisa", - "settings.hiddenControls": null, - "settings.notificationSounds": null, + "settings.hiddenControls": "Skrivene kontrole medija", + "settings.notificationSounds": "Zvuk obavijesti", "filesharing.saveFileError": "Nije moguće spremiti datoteku", "filesharing.startingFileShare": "Pokušaj dijeljenja datoteke", @@ -169,8 +169,8 @@ "devices.cameraDisconnected": "Kamera odspojena", "devices.cameraError": "Greška prilikom pristupa kameri", - "moderator.clearChat": null, - "moderator.clearFiles": null, - "moderator.muteAudio": null, - "moderator.muteVideo": null + "moderator.clearChat": "Moderator je izbrisao razgovor", + "moderator.clearFiles": "Moderator je izbrisao datoteke", + "moderator.muteAudio": "Moderator je utišao tvoj zvuk", + "moderator.muteVideo": "Moderator je zaustavio tvoj video" } From 417788325b3f0ec8aa3245397e25e9ddf97fe911 Mon Sep 17 00:00:00 2001 From: Luca Date: Tue, 5 May 2020 18:21:18 +0200 Subject: [PATCH 30/88] Update italian translation --- app/src/translations/it.json | 54 ++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/app/src/translations/it.json b/app/src/translations/it.json index 2c2e01a..284c40e 100644 --- a/app/src/translations/it.json +++ b/app/src/translations/it.json @@ -49,22 +49,22 @@ "room.spotlights": "Partecipanti in Evidenza", "room.passive": "Participanti Passivi", "room.videoPaused": "Il video è in pausa", - "room.muteAll": null, - "room.stopAllVideo": null, - "room.closeMeeting": null, - "room.clearChat": null, - "room.clearFileSharing": null, - "room.speechUnsupported": null, - "room.moderatoractions": null, - "room.raisedHand": null, - "room.loweredHand": null, - "room.extraVideo": null, + "room.muteAll": "Muta tutti", + "room.stopAllVideo": "Ferma tutti i video", + "room.closeMeeting": "Termina meeting", + "room.clearChat": "Pulisci chat", + "room.clearFileSharing": "Pulisci file sharing", + "room.speechUnsupported": "Il tuo browser non supporta il riconoscimento vocale", + "room.moderatoractions": "Azioni moderatore", + "room.raisedHand": "{displayName} ha alzato la mano", + "room.loweredHand": "{displayName} ha abbassato la mano", + "room.extraVideo": "Video extra", "room.overRoomLimit": null, - "me.mutedPTT": null, + "me.mutedPTT": "Sei mutato, tieni premuto SPAZIO per parlare", - "roles.gotRole": null, - "roles.lostRole": null, + "roles.gotRole": "Hai ottenuto il ruolo: {role}", + "roles.lostRole": "Hai perso il ruolo: {role}", "tooltip.login": "Log in", "tooltip.logout": "Log out", @@ -76,9 +76,9 @@ "tooltip.lobby": "Mostra lobby", "tooltip.settings": "Mostra impostazioni", "tooltip.participants": "Mostra partecipanti", - "tooltip.muteParticipant": null, - "tooltip.muteParticipantVideo": null, - "tooltip.raisedHand": null, + "tooltip.muteParticipant": "Muta partecipante", + "tooltip.muteParticipantVideo": "Ferma video partecipante", + "tooltip.raisedHand": "Mano alzata", "label.roomName": "Nome della stanza", "label.chooseRoomButton": "Continua", @@ -103,11 +103,11 @@ "label.veryHigh": "Molto alta (FHD)", "label.ultra": "Ultra (UHD)", "label.close": "Chiudi", - "label.media": null, - "label.appearence": null, - "label.advanced": null, - "label.addVideo": null, - "label.promoteAllPeers": null, + "label.media": "Media", + "label.appearence": "Aspetto", + "label.advanced": "Avanzate", + "label.addVideo": "Aggiungi video", + "label.promoteAllPeers": "Promuovi tutti", "settings.settings": "Impostazioni", "settings.camera": "Videocamera", @@ -125,8 +125,8 @@ "settings.advancedMode": "Modalità avanzata", "settings.permanentTopBar": "Barra superiore permanente", "settings.lastn": "Numero di video visibili", - "settings.hiddenControls": null, - "settings.notificationSounds": null, + "settings.hiddenControls": "Controlli media nascosti", + "settings.notificationSounds": "Suoni di notifica", "filesharing.saveFileError": "Impossibile salvare file", "filesharing.startingFileShare": "Tentativo di condivisione file", @@ -168,8 +168,8 @@ "devices.cameraDisconnected": "Videocamera scollegata", "devices.cameraError": "Errore con l'accesso alla videocamera", - "moderator.clearChat": null, - "moderator.clearFiles": null, - "moderator.muteAudio": null, - "moderator.muteVideo": null + "moderator.clearChat": "Il moderatore ha pulito la chat", + "moderator.clearFiles": "Il moderatore ha pulito i file", + "moderator.muteAudio": "Il moderatore ha mutato il tuo audio", + "moderator.muteVideo": "Il moderatore ha fermato il tuo video" } From 3710c3b3ace21912de4b31ed0d1e0404ac2733b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Tue, 5 May 2020 20:08:11 +0200 Subject: [PATCH 31/88] Add tooltips to participant list, fixes #299 --- .../MeetingDrawer/ParticipantList/ListMe.js | 35 ++-- .../MeetingDrawer/ParticipantList/ListPeer.js | 163 +++++++++++------- app/src/translations/cn.json | 1 + app/src/translations/cs.json | 1 + app/src/translations/de.json | 1 + app/src/translations/dk.json | 1 + app/src/translations/el.json | 1 + app/src/translations/en.json | 1 + app/src/translations/es.json | 1 + app/src/translations/fr.json | 1 + app/src/translations/hr.json | 1 + app/src/translations/hu.json | 1 + app/src/translations/it.json | 1 + app/src/translations/lv.json | 1 + app/src/translations/nb.json | 1 + app/src/translations/pl.json | 1 + app/src/translations/pt.json | 1 + app/src/translations/ro.json | 1 + app/src/translations/tr.json | 1 + app/src/translations/uk.json | 1 + 20 files changed, 138 insertions(+), 78 deletions(-) diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListMe.js b/app/src/components/MeetingDrawer/ParticipantList/ListMe.js index a32412f..d230db2 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ListMe.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ListMe.js @@ -6,6 +6,7 @@ import PropTypes from 'prop-types'; import * as appPropTypes from '../../appPropTypes'; import { useIntl } from 'react-intl'; import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; import PanIcon from '@material-ui/icons/PanTool'; import EmptyAvatar from '../../../images/avatar-empty.jpeg'; @@ -58,23 +59,31 @@ const ListMe = (props) =>
{settings.displayName}
- - { - e.stopPropagation(); - - roomClient.setRaisedHand(!me.raisedHand); - }} + placement='bottom' > - - + + { + e.stopPropagation(); + + roomClient.setRaisedHand(!me.raisedHand); + }} + > + + + ); }; diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js index 914ae04..1aa70a1 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js @@ -7,6 +7,7 @@ import * as appPropTypes from '../../appPropTypes'; import { withRoomContext } from '../../../RoomContext'; import { useIntl } from 'react-intl'; import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; import VideocamIcon from '@material-ui/icons/Videocam'; import VideocamOffIcon from '@material-ui/icons/VideocamOff'; import VolumeUpIcon from '@material-ui/icons/VolumeUp'; @@ -99,90 +100,122 @@ const ListPeer = (props) => } { screenConsumer && - - { - e.stopPropagation(); - - screenVisible ? - roomClient.modifyPeerConsumer(peer.id, 'screen', true) : - roomClient.modifyPeerConsumer(peer.id, 'screen', false); - }} + placement='bottom' > - { screenVisible ? - - : - - } - + + { + e.stopPropagation(); + + screenVisible ? + roomClient.modifyPeerConsumer(peer.id, 'screen', true) : + roomClient.modifyPeerConsumer(peer.id, 'screen', false); + }} + > + { screenVisible ? + + : + + } + + } - - { - e.stopPropagation(); - - webcamEnabled ? - roomClient.modifyPeerConsumer(peer.id, 'webcam', true) : - roomClient.modifyPeerConsumer(peer.id, 'webcam', false); - }} + placement='bottom' > - { webcamEnabled ? - - : - - } - - - { - e.stopPropagation(); - - micEnabled ? - roomClient.modifyPeerConsumer(peer.id, 'mic', true) : - roomClient.modifyPeerConsumer(peer.id, 'mic', false); - }} - > - { micEnabled ? - - : - - } - - { isModerator && { e.stopPropagation(); - roomClient.kickPeer(peer.id); + webcamEnabled ? + roomClient.modifyPeerConsumer(peer.id, 'webcam', true) : + roomClient.modifyPeerConsumer(peer.id, 'webcam', false); }} > - + { webcamEnabled ? + + : + + } + + + + { + e.stopPropagation(); + + micEnabled ? + roomClient.modifyPeerConsumer(peer.id, 'mic', true) : + roomClient.modifyPeerConsumer(peer.id, 'mic', false); + }} + > + { micEnabled ? + + : + + } + + + { isModerator && + + + { + e.stopPropagation(); + + roomClient.kickPeer(peer.id); + }} + > + + + } {children} diff --git a/app/src/translations/cn.json b/app/src/translations/cn.json index e3b5597..3ead544 100644 --- a/app/src/translations/cn.json +++ b/app/src/translations/cn.json @@ -80,6 +80,7 @@ "tooltip.muteParticipant": null, "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, + "tooltip.muteScreenSharing": null, "label.roomName": "房间名称", "label.chooseRoomButton": "继续", diff --git a/app/src/translations/cs.json b/app/src/translations/cs.json index fd3cc3c..cd16197 100644 --- a/app/src/translations/cs.json +++ b/app/src/translations/cs.json @@ -79,6 +79,7 @@ "tooltip.muteParticipant": null, "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, + "tooltip.muteScreenSharing": null, "label.roomName": "Jméno místnosti", "label.chooseRoomButton": "Pokračovat", diff --git a/app/src/translations/de.json b/app/src/translations/de.json index 8a119ad..b5015d7 100644 --- a/app/src/translations/de.json +++ b/app/src/translations/de.json @@ -80,6 +80,7 @@ "tooltip.muteParticipant": null, "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, + "tooltip.muteScreenSharing": null, "label.roomName": "Name des Raums", "label.chooseRoomButton": "Weiter", diff --git a/app/src/translations/dk.json b/app/src/translations/dk.json index 40e2698..9f99a55 100644 --- a/app/src/translations/dk.json +++ b/app/src/translations/dk.json @@ -80,6 +80,7 @@ "tooltip.muteParticipant": null, "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, + "tooltip.muteScreenSharing": null, "label.roomName": "Værelsesnavn", "label.chooseRoomButton": "Fortsæt", diff --git a/app/src/translations/el.json b/app/src/translations/el.json index 2600ab2..47d5fe0 100644 --- a/app/src/translations/el.json +++ b/app/src/translations/el.json @@ -80,6 +80,7 @@ "tooltip.muteParticipant": null, "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, + "tooltip.muteScreenSharing": null, "label.roomName": "Όνομα δωματίου", "label.chooseRoomButton": "Συνέχεια", diff --git a/app/src/translations/en.json b/app/src/translations/en.json index 19c46b4..c7447fe 100644 --- a/app/src/translations/en.json +++ b/app/src/translations/en.json @@ -80,6 +80,7 @@ "tooltip.muteParticipant": "Mute participant", "tooltip.muteParticipantVideo": "Mute participant video", "tooltip.raisedHand": "Raise hand", + "tooltip.muteScreenSharing": "Mute participant share", "label.roomName": "Room name", "label.chooseRoomButton": "Continue", diff --git a/app/src/translations/es.json b/app/src/translations/es.json index 4c8abea..a630e15 100644 --- a/app/src/translations/es.json +++ b/app/src/translations/es.json @@ -80,6 +80,7 @@ "tooltip.muteParticipant": null, "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, + "tooltip.muteScreenSharing": null, "label.roomName": "Nombre de la sala", "label.chooseRoomButton": "Continuar", diff --git a/app/src/translations/fr.json b/app/src/translations/fr.json index 9148655..d36e238 100644 --- a/app/src/translations/fr.json +++ b/app/src/translations/fr.json @@ -80,6 +80,7 @@ "tooltip.muteParticipant": null, "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, + "tooltip.muteScreenSharing": null, "label.roomName": "Nom de la salle", "label.chooseRoomButton": "Continuer", diff --git a/app/src/translations/hr.json b/app/src/translations/hr.json index 1f5ce55..68f8418 100644 --- a/app/src/translations/hr.json +++ b/app/src/translations/hr.json @@ -80,6 +80,7 @@ "tooltip.muteParticipant": "Utišaj sudionika", "tooltip.muteParticipantVideo": "Ne primaj video sudionika", "tooltip.raisedHand": "Podigni ruku", + "tooltip.muteScreenSharing": null, "label.roomName": "Naziv sobe", "label.chooseRoomButton": "Nastavi", diff --git a/app/src/translations/hu.json b/app/src/translations/hu.json index e185cfb..7c2ce89 100644 --- a/app/src/translations/hu.json +++ b/app/src/translations/hu.json @@ -80,6 +80,7 @@ "tooltip.muteParticipant": "Résztvevő némítása", "tooltip.muteParticipantVideo": "Résztvevő video némítása", "tooltip.raisedHand": "Jelentkezés", + "tooltip.muteScreenSharing": null, "label.roomName": "Konferencia", "label.chooseRoomButton": "Tovább", diff --git a/app/src/translations/it.json b/app/src/translations/it.json index 284c40e..b088fa9 100644 --- a/app/src/translations/it.json +++ b/app/src/translations/it.json @@ -79,6 +79,7 @@ "tooltip.muteParticipant": "Muta partecipante", "tooltip.muteParticipantVideo": "Ferma video partecipante", "tooltip.raisedHand": "Mano alzata", + "tooltip.muteScreenSharing": null, "label.roomName": "Nome della stanza", "label.chooseRoomButton": "Continua", diff --git a/app/src/translations/lv.json b/app/src/translations/lv.json index dce2204..e981b35 100644 --- a/app/src/translations/lv.json +++ b/app/src/translations/lv.json @@ -79,6 +79,7 @@ "tooltip.muteParticipant": "Noklusināt dalībnieku", "tooltip.muteParticipantVideo": "Atslēgt dalībnieka video", "tooltip.raisedHand": "Pacelt roku", + "tooltip.muteScreenSharing": null, "label.roomName": "Sapulces telpas nosaukums (ID)", "label.chooseRoomButton": "Turpināt", diff --git a/app/src/translations/nb.json b/app/src/translations/nb.json index 2965f59..77c4ef5 100644 --- a/app/src/translations/nb.json +++ b/app/src/translations/nb.json @@ -80,6 +80,7 @@ "tooltip.muteParticipant": "Demp deltaker", "tooltip.muteParticipantVideo": "Demp deltakervideo", "tooltip.raisedHand": "Rekk opp hånden", + "tooltip.muteScreenSharing": "Demp deltaker skjermdeling", "label.roomName": "Møtenavn", "label.chooseRoomButton": "Fortsett", diff --git a/app/src/translations/pl.json b/app/src/translations/pl.json index 14f5146..ffb41ec 100644 --- a/app/src/translations/pl.json +++ b/app/src/translations/pl.json @@ -80,6 +80,7 @@ "tooltip.muteParticipant": null, "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, + "tooltip.muteScreenSharing": null, "label.roomName": "Nazwa konferencji", "label.chooseRoomButton": "Kontynuuj", diff --git a/app/src/translations/pt.json b/app/src/translations/pt.json index 89a8da1..3861c01 100644 --- a/app/src/translations/pt.json +++ b/app/src/translations/pt.json @@ -80,6 +80,7 @@ "tooltip.muteParticipant": null, "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, + "tooltip.muteScreenSharing": null, "label.roomName": "Nome da sala", "label.chooseRoomButton": "Continuar", diff --git a/app/src/translations/ro.json b/app/src/translations/ro.json index 49936b0..ca906de 100644 --- a/app/src/translations/ro.json +++ b/app/src/translations/ro.json @@ -80,6 +80,7 @@ "tooltip.muteParticipant": null, "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, + "tooltip.muteScreenSharing": null, "label.roomName": "Numele camerei", "label.chooseRoomButton": "Continuare", diff --git a/app/src/translations/tr.json b/app/src/translations/tr.json index 3fb115f..b53d1ea 100644 --- a/app/src/translations/tr.json +++ b/app/src/translations/tr.json @@ -80,6 +80,7 @@ "tooltip.muteParticipant": null, "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, + "tooltip.muteScreenSharing": null, "label.roomName": "Oda adı", "label.chooseRoomButton": "Devam", diff --git a/app/src/translations/uk.json b/app/src/translations/uk.json index 6c15f7a..d9ceaa8 100644 --- a/app/src/translations/uk.json +++ b/app/src/translations/uk.json @@ -80,6 +80,7 @@ "tooltip.muteParticipant": null, "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, + "tooltip.muteScreenSharing": null, "label.roomName": "Назва кімнати", "label.chooseRoomButton": "Продовжити", From c02c8b1d673f18a0cfd23e1de971fe0f874f4386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Tue, 5 May 2020 21:27:45 +0200 Subject: [PATCH 32/88] Missing translation string --- app/src/RoomClient.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index 8a502be..beea13b 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -2811,7 +2811,9 @@ export default class RoomClient { text : intl.formatMessage({ id : 'roles.gotRole', - defaultMessage : `You got the role: ${role}` + defaultMessage : 'You got the role: {role}' + }, { + role }) })); } From 3c7afd20667f88e5ea1799b8049259f089abcccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Tue, 5 May 2020 21:31:21 +0200 Subject: [PATCH 33/88] Make buttons in AppBar fit on narrow screens, fixes #279 --- app/src/components/Controls/TopBar.js | 326 ++++++++++++++++++++++---- app/src/translations/cn.json | 1 + app/src/translations/cs.json | 1 + app/src/translations/de.json | 1 + app/src/translations/dk.json | 1 + app/src/translations/el.json | 1 + app/src/translations/en.json | 1 + app/src/translations/es.json | 1 + app/src/translations/fr.json | 1 + app/src/translations/hr.json | 1 + app/src/translations/hu.json | 1 + app/src/translations/it.json | 1 + app/src/translations/lv.json | 1 + app/src/translations/nb.json | 1 + app/src/translations/pl.json | 1 + app/src/translations/pt.json | 1 + app/src/translations/ro.json | 1 + app/src/translations/tr.json | 1 + app/src/translations/uk.json | 1 + 19 files changed, 300 insertions(+), 44 deletions(-) diff --git a/app/src/components/Controls/TopBar.js b/app/src/components/Controls/TopBar.js index cbe4bee..6422788 100644 --- a/app/src/components/Controls/TopBar.js +++ b/app/src/components/Controls/TopBar.js @@ -16,11 +16,13 @@ import AppBar from '@material-ui/core/AppBar'; import Toolbar from '@material-ui/core/Toolbar'; import MenuItem from '@material-ui/core/MenuItem'; import Menu from '@material-ui/core/Menu'; +import Popover from '@material-ui/core/Popover'; import Typography from '@material-ui/core/Typography'; import IconButton from '@material-ui/core/IconButton'; import MenuIcon from '@material-ui/icons/Menu'; import Avatar from '@material-ui/core/Avatar'; import Badge from '@material-ui/core/Badge'; +import Paper from '@material-ui/core/Paper'; import ExtensionIcon from '@material-ui/icons/Extension'; import AccountCircle from '@material-ui/icons/AccountCircle'; import FullScreenIcon from '@material-ui/icons/Fullscreen'; @@ -33,6 +35,7 @@ import LockOpenIcon from '@material-ui/icons/LockOpen'; import VideoCallIcon from '@material-ui/icons/VideoCall'; import Button from '@material-ui/core/Button'; import Tooltip from '@material-ui/core/Tooltip'; +import MoreIcon from '@material-ui/icons/MoreVert'; const styles = (theme) => ({ @@ -77,9 +80,17 @@ const styles = (theme) => display : 'block' } }, - actionButtons : - { - display : 'flex' + sectionDesktop : { + display : 'none', + [theme.breakpoints.up('md')] : { + display : 'flex' + } + }, + sectionMobile : { + display : 'flex', + [theme.breakpoints.up('md')] : { + display : 'none' + } }, actionButton : { @@ -96,7 +107,7 @@ const styles = (theme) => }, moreAction : { - margin : theme.spacing(0, 0, 0, 1) + margin : theme.spacing(0.5, 0, 0.5, 1.5) } }); @@ -135,16 +146,36 @@ const TopBar = (props) => { const intl = useIntl(); - const [ moreActionsElement, setMoreActionsElement ] = useState(null); + const [ mobileMoreAnchorEl, setMobileMoreAnchorEl ] = useState(null); + const [ anchorEl, setAnchorEl ] = useState(null); + const [ currentMenu, setCurrentMenu ] = useState(null); - const handleMoreActionsOpen = (event) => + const handleExited = () => { - setMoreActionsElement(event.currentTarget); + setCurrentMenu(null); }; - const handleMoreActionsClose = () => + const handleMobileMenuOpen = (event) => { - setMoreActionsElement(null); + setMobileMoreAnchorEl(event.currentTarget); + }; + + const handleMobileMenuClose = () => + { + setMobileMoreAnchorEl(null); + }; + + const handleMenuOpen = (event, menu) => + { + setAnchorEl(event.currentTarget); + setCurrentMenu(menu); + }; + + const handleMenuClose = () => + { + setAnchorEl(null); + + handleMobileMenuClose(); }; const { @@ -171,7 +202,8 @@ const TopBar = (props) => classes } = props; - const isMoreActionsMenuOpen = Boolean(moreActionsElement); + const isMenuOpen = Boolean(anchorEl); + const isMobileMenuOpen = Boolean(mobileMoreAnchorEl); const lockTooltip = room.locked ? intl.formatMessage({ @@ -239,10 +271,15 @@ const TopBar = (props) => { window.config.title ? window.config.title : 'Multiparty meeting' }
-
+
handleMenuOpen(event, 'moreActions')} color='inherit' > @@ -386,52 +423,253 @@ const TopBar = (props) => } -
-
+
+ + + +
+
+ - + { currentMenu === 'moreActions' && + + + { + handleMenuClose(); + setExtraVideoOpen(!room.extraVideoOpen); + }} + > + +

+ +

+
+
+ } + + + { loginEnabled && + + { + handleMenuClose(); + loggedIn ? roomClient.logout() : roomClient.login(); + }} + > + { myPicture ? + + : + + } + { loggedIn ? +

+ +

+ : +

+ +

+ } +
+ } { - handleMoreActionsClose(); - setExtraVideoOpen(!room.extraVideoOpen); + handleMenuClose(); + + if (room.locked) + { + roomClient.unlockRoom(); + } + else + { + roomClient.lockRoom(); + } }} > - + { room.locked ? + + : + + } + { room.locked ? +

+ +

+ : +

+ +

+ } +
+ + { + handleMenuClose(); + setSettingsOpen(!room.settingsOpen); + }} + > +

+

+
+ { lobbyPeers.length > 0 && + + { + handleMenuClose(); + setLockDialogOpen(!room.lockDialogOpen); + }} + > + + + +

+ +

+
+ } + + { + handleMenuClose(); + openUsersTab(); + }} + > + + + +

+ +

+
+ { fullscreenEnabled && + + { + handleMenuClose(); + onFullscreen(); + }} + > + { fullscreen ? + + : + + } +

+ +

+
+ } + handleMenuOpen(event, 'moreActions')} + > + +

+

diff --git a/app/src/translations/cn.json b/app/src/translations/cn.json index 3ead544..097e9b7 100644 --- a/app/src/translations/cn.json +++ b/app/src/translations/cn.json @@ -110,6 +110,7 @@ "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, + "label.moreActions": null, "settings.settings": "设置", "settings.camera": "视频设备", diff --git a/app/src/translations/cs.json b/app/src/translations/cs.json index cd16197..66e72b2 100644 --- a/app/src/translations/cs.json +++ b/app/src/translations/cs.json @@ -109,6 +109,7 @@ "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, + "label.moreActions": null, "settings.settings": "Nastavení", "settings.camera": "Kamera", diff --git a/app/src/translations/de.json b/app/src/translations/de.json index b5015d7..6d72d54 100644 --- a/app/src/translations/de.json +++ b/app/src/translations/de.json @@ -110,6 +110,7 @@ "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, + "label.moreActions": null, "settings.settings": "Einstellungen", "settings.camera": "Kamera", diff --git a/app/src/translations/dk.json b/app/src/translations/dk.json index 9f99a55..351907c 100644 --- a/app/src/translations/dk.json +++ b/app/src/translations/dk.json @@ -110,6 +110,7 @@ "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, + "label.moreActions": null, "settings.settings": "Indstillinger", "settings.camera": "Kamera", diff --git a/app/src/translations/el.json b/app/src/translations/el.json index 47d5fe0..8f34eb1 100644 --- a/app/src/translations/el.json +++ b/app/src/translations/el.json @@ -110,6 +110,7 @@ "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, + "label.moreActions": null, "settings.settings": "Ρυθμίσεις", "settings.camera": "Κάμερα", diff --git a/app/src/translations/en.json b/app/src/translations/en.json index c7447fe..660585d 100644 --- a/app/src/translations/en.json +++ b/app/src/translations/en.json @@ -110,6 +110,7 @@ "label.advanced": "Advanced", "label.addVideo": "Add video", "label.promoteAllPeers": "Promote all", + "label.moreActions": "More actions", "settings.settings": "Settings", "settings.camera": "Camera", diff --git a/app/src/translations/es.json b/app/src/translations/es.json index a630e15..f685b8f 100644 --- a/app/src/translations/es.json +++ b/app/src/translations/es.json @@ -110,6 +110,7 @@ "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, + "label.moreActions": null, "settings.settings": "Ajustes", "settings.camera": "Cámara", diff --git a/app/src/translations/fr.json b/app/src/translations/fr.json index d36e238..d1a67cf 100644 --- a/app/src/translations/fr.json +++ b/app/src/translations/fr.json @@ -110,6 +110,7 @@ "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, + "label.moreActions": null, "settings.settings": "Paramètres", "settings.camera": "Caméra", diff --git a/app/src/translations/hr.json b/app/src/translations/hr.json index 68f8418..f08f516 100644 --- a/app/src/translations/hr.json +++ b/app/src/translations/hr.json @@ -110,6 +110,7 @@ "label.advanced": "Napredno", "label.addVideo": "Dodaj video", "label.promoteAllPeers": "Promoviraj sve", + "label.moreActions": null, "settings.settings": "Postavke", "settings.camera": "Kamera", diff --git a/app/src/translations/hu.json b/app/src/translations/hu.json index 7c2ce89..0d1f419 100644 --- a/app/src/translations/hu.json +++ b/app/src/translations/hu.json @@ -110,6 +110,7 @@ "label.advanced": "Részletek", "label.addVideo": "Videó hozzáadása", "label.promoteAllPeers": "Mindenkit beengedek", + "label.moreActions": null, "settings.settings": "Beállítások", "settings.camera": "Kamera", diff --git a/app/src/translations/it.json b/app/src/translations/it.json index b088fa9..472ce91 100644 --- a/app/src/translations/it.json +++ b/app/src/translations/it.json @@ -109,6 +109,7 @@ "label.advanced": "Avanzate", "label.addVideo": "Aggiungi video", "label.promoteAllPeers": "Promuovi tutti", + "label.moreActions": null, "settings.settings": "Impostazioni", "settings.camera": "Videocamera", diff --git a/app/src/translations/lv.json b/app/src/translations/lv.json index e981b35..dd8fac4 100644 --- a/app/src/translations/lv.json +++ b/app/src/translations/lv.json @@ -107,6 +107,7 @@ "label.appearence": "Izskats", "label.advanced": "Advancēts", "label.addVideo": "Pievienot video", + "label.moreActions": null, "settings.settings": "Iestatījumi", "settings.camera": "Kamera", diff --git a/app/src/translations/nb.json b/app/src/translations/nb.json index 77c4ef5..58e3b08 100644 --- a/app/src/translations/nb.json +++ b/app/src/translations/nb.json @@ -110,6 +110,7 @@ "label.advanced": "Avansert", "label.addVideo": "Legg til video", "label.promoteAllPeers": "Slipp inn alle", + "label.moreActions": "Flere handlinger", "settings.settings": "Innstillinger", "settings.camera": "Kamera", diff --git a/app/src/translations/pl.json b/app/src/translations/pl.json index ffb41ec..923de79 100644 --- a/app/src/translations/pl.json +++ b/app/src/translations/pl.json @@ -110,6 +110,7 @@ "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, + "label.moreActions": null, "settings.settings": "Ustawienia", "settings.camera": "Kamera", diff --git a/app/src/translations/pt.json b/app/src/translations/pt.json index 3861c01..b863098 100644 --- a/app/src/translations/pt.json +++ b/app/src/translations/pt.json @@ -110,6 +110,7 @@ "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, + "label.moreActions": null, "settings.settings": "Definições", "settings.camera": "Camera", diff --git a/app/src/translations/ro.json b/app/src/translations/ro.json index ca906de..624c231 100644 --- a/app/src/translations/ro.json +++ b/app/src/translations/ro.json @@ -110,6 +110,7 @@ "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, + "label.moreActions": null, "settings.settings": "Setări", "settings.camera": "Cameră video", diff --git a/app/src/translations/tr.json b/app/src/translations/tr.json index b53d1ea..8f9c29f 100644 --- a/app/src/translations/tr.json +++ b/app/src/translations/tr.json @@ -110,6 +110,7 @@ "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, + "label.moreActions": null, "settings.settings": "Ayarlar", "settings.camera": "Kamera", diff --git a/app/src/translations/uk.json b/app/src/translations/uk.json index d9ceaa8..db4a082 100644 --- a/app/src/translations/uk.json +++ b/app/src/translations/uk.json @@ -110,6 +110,7 @@ "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, + "label.moreActions": null, "settings.settings": "Налаштування", "settings.camera": "Камера", From 2acd35d32b76f17004d916ea4441fe450a871ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9sz=C3=A1ros=20Mih=C3=A1ly?= Date: Tue, 5 May 2020 21:41:23 +0200 Subject: [PATCH 34/88] Fixes in hungarian translation --- app/src/translations/hu.json | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/app/src/translations/hu.json b/app/src/translations/hu.json index 0d1f419..c3dd780 100644 --- a/app/src/translations/hu.json +++ b/app/src/translations/hu.json @@ -1,24 +1,24 @@ { "socket.disconnected": "A kapcsolat lebomlott", "socket.reconnecting": "A kapcsolat lebomlott, újrapróbálkozás", - "socket.reconnected": "Sikeres újarkapcsolódás", + "socket.reconnected": "Sikeres újrakapcsolódás", "socket.requestError": "Sikertelen szerver lekérés", "room.chooseRoom": "Válaszd ki a konferenciaszobát", "room.cookieConsent": "Ez a weblap a felhasználói élmény fokozása miatt sütiket használ", "room.consentUnderstand": "Megértettem", - "room.joined": "Csatlakozátál a konferenciához", + "room.joined": "Csatlakoztál a konferenciához", "room.cantJoin": "Sikertelen csatlakozás a konferenciához", "room.youLocked": "A konferenciába való belépés letiltva", - "room.cantLock": "Sikertelen a konferenciaba való belépés letiltása", + "room.cantLock": "Sikertelen a konferenciába való belépés letiltása", "room.youUnLocked": "A konferenciába való belépés engedélyezve", "room.cantUnLock": "Sikertelen a konferenciába való belépés engedélyezése", "room.locked": "A konferenciába való belépés letiltva", "room.unlocked": "A konferenciába való belépés engedélyezve", "room.newLobbyPeer": "Új részvevő lépett be a konferencia előszobájába", "room.lobbyPeerLeft": "A konferencia előszobájából a részvevő távozott", - "room.lobbyPeerChangedDisplayName": "Az előszobai résztvevő meváltoztatta a nevét: {displayName}", - "room.lobbyPeerChangedPicture": "Az előszobai résztvevő meváltoztatta a képét", + "room.lobbyPeerChangedDisplayName": "Az előszobai résztvevő megváltoztatta a nevét: {displayName}", + "room.lobbyPeerChangedPicture": "Az előszobai résztvevő megváltoztatta a képét", "room.setAccessCode": "A konferencia hozzáférési kódja megváltozott", "room.accessCodeOn": "A konferencia hozzáférési kódja aktiválva", "room.accessCodeOff": "A konferencia hozzáférési kódka deaktiválva", @@ -39,7 +39,7 @@ "room.audioOnly": "csak Hang", "room.audioVideo": "Hang és Videó", "room.youAreReady": "Ok, kész vagy", - "room.emptyRequireLogin": "A konferencia üres! Be kell lépned a konferecnia elkezdéséhez, vagy várnod kell amíg a házigazda becsatlakozik.", + "room.emptyRequireLogin": "A konferencia üres! Be kell lépned a konferencia elkezdéséhez, vagy várnod kell amíg a házigazda becsatlakozik.", "room.locketWait": "Az automatikus belépés tiltva van - Várj amíg valaki beenged ...", "room.lobbyAdministration": "Előszoba adminisztráció", "room.peersInLobby": "Résztvevők az előszobában", @@ -54,7 +54,7 @@ "room.closeMeeting": "Konferencia lebontása", "room.clearChat": "Chat történelem kiürítése", "room.clearFileSharing": "File megosztás kiürítése", - "room.speechUnsupported": "A böngésződ nem támogatja a hangszint", + "room.speechUnsupported": "A böngésződ nem támogatja a hangfelismerést", "room.moderatoractions": "Moderátor funkciók", "room.raisedHand": "{displayName} jelentkezik", "room.loweredHand": "{displayName} leeresztette a kezét", @@ -68,7 +68,7 @@ "tooltip.login": "Belépés", "tooltip.logout": "Kilépés", - "tooltip.admitFromLobby": "Beenegdem az előszobából", + "tooltip.admitFromLobby": "Beengedem az előszobából", "tooltip.lockRoom": "A konferenciába való belépés letiltása", "tooltip.unLockRoom": "konferenciába való belépés engedélyezése", "tooltip.enterFullscreen": "Teljes képernyős mód", @@ -78,9 +78,9 @@ "tooltip.participants": "Résztvevők", "tooltip.kickParticipant": "Résztvevő kirúgása", "tooltip.muteParticipant": "Résztvevő némítása", - "tooltip.muteParticipantVideo": "Résztvevő video némítása", + "tooltip.muteParticipantVideo": "Résztvevő videóstreamének némítása", "tooltip.raisedHand": "Jelentkezés", - "tooltip.muteScreenSharing": null, + "tooltip.muteScreenSharing": "Képernyőmegosztás szüneteltetése", "label.roomName": "Konferencia", "label.chooseRoomButton": "Tovább", @@ -110,7 +110,7 @@ "label.advanced": "Részletek", "label.addVideo": "Videó hozzáadása", "label.promoteAllPeers": "Mindenkit beengedek", - "label.moreActions": null, + "label.moreActions": "További műveletek", "settings.settings": "Beállítások", "settings.camera": "Kamera", @@ -129,12 +129,12 @@ "settings.permanentTopBar": "Állandó felső sáv", "settings.lastn": "A látható videók száma", "settings.hiddenControls": "Média Gombok automatikus elrejtése", - "settings.notificationSounds": "Értesítések hangjelzjéssel", + "settings.notificationSounds": "Értesítések hangjelzéssel", "filesharing.saveFileError": "A file-t nem sikerült elmenteni", "filesharing.startingFileShare": "Fájl megosztása", "filesharing.successfulFileShare": "A fájl sikeresen megosztva", - "filesharing.unableToShare": "Sikereteln fájl megosztás", + "filesharing.unableToShare": "Sikertelen fájl megosztás", "filesharing.error": "Hiba a fájlmegosztás során", "filesharing.finished": "A fájl letöltés befejeződött", "filesharing.save": "Mentés", @@ -144,7 +144,7 @@ "devices.devicesChanged": "Az eszközei megváltoztak, konfiguráld őket be a beállítások menüben", - "device.audioUnsupported": "A hnag nem támogatott", + "device.audioUnsupported": "A hang nem támogatott", "device.activateAudio": "Hang aktiválása", "device.muteAudio": "Hang némítása", "device.unMuteAudio": "Hang némítás kikapcsolása", @@ -155,9 +155,9 @@ "device.screenSharingUnsupported": "A képernyő megosztás nem támogatott", "device.startScreenSharing": "Képernyőmegosztás indítása", - "device.stopScreenSharing": "Képernyőmegosztás leáłłítása", + "device.stopScreenSharing": "Képernyőmegosztás leállítása", - "devices.microphoneDisconnected": "Microphone kapcsolat bontva", + "devices.microphoneDisconnected": "Mikrofon kapcsolat bontva", "devices.microphoneError": "Hiba történt a mikrofon hangeszköz elérése közben", "devices.microphoneMute": "A mikrofon némítva lett", "devices.microphoneUnMute": "A mikrofon némítása ki lett kapocsolva", From ee6409b2b3cc979d7d34d93aff18c7d719193a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Tue, 5 May 2020 22:20:21 +0200 Subject: [PATCH 35/88] Hide quality indicator if the quality has top score. --- app/src/components/VideoContainers/VideoView.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/components/VideoContainers/VideoView.js b/app/src/components/VideoContainers/VideoView.js index fd84bae..9afcd3b 100644 --- a/app/src/components/VideoContainers/VideoView.js +++ b/app/src/components/VideoContainers/VideoView.js @@ -215,16 +215,16 @@ class VideoView extends React.PureComponent case 7: case 8: + case 9: { quality = ; break; } - case 9: case 10: { - quality = ; + quality = null; // ; break; } @@ -261,7 +261,7 @@ class VideoView extends React.PureComponent

{videoWidth}x{videoHeight}

}
- { !isMe && + { !isMe &&
{ quality From a2d11121d3593ee08722be50b8381cf1ccedef83 Mon Sep 17 00:00:00 2001 From: Stefan Otto Date: Tue, 5 May 2020 22:28:46 +0200 Subject: [PATCH 36/88] next iteration state + audio settings --- app/package.json | 1 + app/public/config/config.example.js | 16 ++++- app/src/RoomClient.js | 25 +++++--- app/src/actions/settingsActions.js | 39 +++++++++++++ app/src/components/Settings/Settings.js | 52 +++++++++++++++++ app/src/reducers/settings.js | 77 +++++++++++++++++++++++++ 6 files changed, 198 insertions(+), 12 deletions(-) diff --git a/app/package.json b/app/package.json index b58d66a..d7cff17 100644 --- a/app/package.json +++ b/app/package.json @@ -11,6 +11,7 @@ "@material-ui/core": "^4.5.1", "@material-ui/icons": "^4.5.1", "bowser": "^2.7.0", + "classnames": "^2.2.6", "dompurify": "^2.0.7", "domready": "^1.0.8", "end-of-stream": "1.4.0", diff --git a/app/public/config/config.example.js b/app/public/config/config.example.js index 9aaff4b..f64b139 100644 --- a/app/public/config/config.example.js +++ b/app/public/config/config.example.js @@ -31,9 +31,19 @@ var config = { tcp : true }, - lastN : 4, - mobileLastN : 1, - background : 'images/background.jpg', + lastN : 4, + mobileLastN : 1, + defaultAudio : + { + sampleRate : 48000, + channelCount : 1, + volume : 1.0, + autoGainControl : true, + echoCancellation : true, + noiseSuppression : true, + sampleSize : 16 + }, + background : 'images/background.jpg', // Add file and uncomment for adding logo to appbar // logo : 'images/logo.svg', title : 'Multiparty meeting', diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index 0d0bd3a..de3e177 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -30,6 +30,7 @@ let requestTimeout, transportOptions, lastN, mobileLastN, + defaultAudio, defaultResolution; if (process.env.NODE_ENV !== 'test') @@ -39,6 +40,7 @@ if (process.env.NODE_ENV !== 'test') transportOptions, lastN, mobileLastN, + defaultAudio, defaultResolution } = window.config); } @@ -203,6 +205,9 @@ export default class RoomClient if (defaultResolution) store.dispatch(settingsActions.setVideoResolution(defaultResolution)); + if (defaultAudio) + store.dispatch(settingsActions.setDefaultAudio(defaultAudio)); + // Max spotlights if (device.bowser.getPlatformType() === 'desktop') this._maxSpotlights = lastN; @@ -1036,23 +1041,24 @@ export default class RoomClient if (this._micProducer && this._micProducer.track) this._micProducer.track.stop(); - logger.debug('changeAudioDevice() | calling getUserMedia()'); + logger.debug('changeAudioDevice() | calling getUserMedia() %o', store.getState().settings); const stream = await navigator.mediaDevices.getUserMedia( { audio : { - deviceId : { exact: device.deviceId }, - sampleRate : 48000, - channelCount : 1, - volume : 1.0, - autoGainControl : true, - echoCancellation : false, - noiseSuppression : false, - sampleSize : 16 + deviceId : { exact: device.deviceId }, + sampleRate : store.getState().settings.sampleRate, + channelCount : store.getState().settings.channelCount, + volume : store.getState().settings.volume, + autoGainControl : store.getState().settings.autoGainControl, + echoCancellation : store.getState().settings.echoCancellation, + noiseSuppression : store.getState().settings.noiseSuppression, + sampleSize : store.getState().settings.sampleSize } } ); + logger.debug('Constraints: %o', stream.getAudioTracks()[0].getConstraints()); const track = stream.getAudioTracks()[0]; @@ -2697,6 +2703,7 @@ export default class RoomClient } } ); + logger.debug('Constraints: %o', stream.getAudioTracks()[0].getConstraints()); track = stream.getAudioTracks()[0]; diff --git a/app/src/actions/settingsActions.js b/app/src/actions/settingsActions.js index 79b5ef2..68b4257 100644 --- a/app/src/actions/settingsActions.js +++ b/app/src/actions/settingsActions.js @@ -32,6 +32,45 @@ export const togglePermanentTopBar = () => type : 'TOGGLE_PERMANENT_TOPBAR' }); +export const setEchoCancellation = (echoCancellation) => + ({ + type : 'SET_ECHO_CANCELLATION', + payload : { echoCancellation } + }); + +export const setAutoGainControl = (autoGainControl) => + ({ + type : 'SET_AUTO_GAIN_CONTROL', + payload : { autoGainControl } + }); + +export const setNoiseSuppression = (noiseSuppression) => + ({ + type : 'SET_NOISE_SUPPRESSION', + payload : { noiseSuppression } + }); + +export const setDefaultAudio = (defaultAudio) => + ({ + type : 'SET_DEFAULT_AUDIO', + payload : { defaultAudio } + }); + +export const toggleEchoCancellation = () => + ({ + type : 'TOGGLE_ECHO_CANCELLATION' + }); + +export const toggleAutoGainControl = () => + ({ + type : 'TOGGLE_AUTO_GAIN_CONTROL' + }); + +export const toggleNoiseSuppression = () => + ({ + type : 'TOGGLE_NOISE_SUPPRESSION' + }); + export const setLastN = (lastN) => ({ type : 'SET_LAST_N', diff --git a/app/src/components/Settings/Settings.js b/app/src/components/Settings/Settings.js index 91ba0db..3c56be4 100644 --- a/app/src/components/Settings/Settings.js +++ b/app/src/components/Settings/Settings.js @@ -60,6 +60,9 @@ const Settings = ({ settings, onToggleAdvancedMode, onTogglePermanentTopBar, + setEchoCancellation, + setAutoGainControl, + setNoiseSuppression, handleCloseSettings, handleChangeMode, classes @@ -329,6 +332,49 @@ const Settings = ({ + + { + setEchoCancellation(event.target.checked); + roomClient.changeAudioDevice(settings.selectedAudioDevice); + }} + />} + label={intl.formatMessage({ + id : 'settings.echoCancellation', + defaultMessage : 'Echo Cancellation' + })} + /> + { + setAutoGainControl(event.target.checked); + roomClient.changeAudioDevice(settings.selectedAudioDevice); + }} + />} + label={intl.formatMessage({ + id: 'settings.autoGainControl', + defaultMessage: 'Auto Gain Control' + })} + /> + { + setNoiseSuppression(event.target.checked); + roomClient.changeAudioDevice(settings.selectedAudioDevice); + }} + />} + label={intl.formatMessage({ + id: 'settings.noiseSuppression', + defaultMessage: 'Noise Suppression' + })} + /> } @@ -359,6 +405,9 @@ Settings.propTypes = settings : PropTypes.object.isRequired, onToggleAdvancedMode : PropTypes.func.isRequired, onTogglePermanentTopBar : PropTypes.func.isRequired, + setEchoCancellation : PropTypes.func.isRequired, + setAutoGainControl : PropTypes.func.isRequired, + setNoiseSuppression : PropTypes.func.isRequired, handleChangeMode : PropTypes.func.isRequired, handleCloseSettings : PropTypes.func.isRequired, classes : PropTypes.object.isRequired @@ -376,6 +425,9 @@ const mapStateToProps = (state) => const mapDispatchToProps = { onToggleAdvancedMode : settingsActions.toggleAdvancedMode, onTogglePermanentTopBar : settingsActions.togglePermanentTopBar, + setEchoCancellation : settingsActions.setEchoCancellation, + setAutoGainControl : settingsActions.toggleAutoGainControl, + setNoiseSuppression : settingsActions.toggleNoiseSuppression, handleChangeMode : roomActions.setDisplayMode, handleCloseSettings : roomActions.setSettingsOpen }; diff --git a/app/src/reducers/settings.js b/app/src/reducers/settings.js index 21d59db..60f05fd 100644 --- a/app/src/reducers/settings.js +++ b/app/src/reducers/settings.js @@ -4,6 +4,13 @@ const initialState = selectedWebcam : null, selectedAudioDevice : null, advancedMode : false, + sampleRate : 48000, + channelCount : 1, + volume : 1.0, + autoGainControl : true, + echoCancellation : true, + noiseSuppression : true, + sampleSize : 16, resolution : 'medium', // low, medium, high, veryhigh, ultra lastN : 4, permanentTopBar : true @@ -37,6 +44,76 @@ const settings = (state = initialState, action) => return { ...state, advancedMode }; } + case 'SET_SAMPLE_RATE': + { + const { sampleRate } = action.payload; + + return { ...state, sampleRate }; + } + + case 'SET_CHANNEL_COUNT': + { + const { channelCount } = action.payload; + + return { ...state, channelCount }; + } + + case 'SET_VOLUME': + { + const { volume } = action.payload; + + return { ...state, volume }; + } + + case 'SET_AUTO_GAIN_CONTROL': + { + const { autoGainControl } = action.payload; + + return { ...state, autoGainControl }; + } + + case 'SET_ECHO_CANCELLATION': + { + const { echoCancellation } = action.payload; + + return { ...state, echoCancellation }; + } + + case 'SET_NOISE_SUPPRESSION': + { + const { noiseSuppression } = action.payload; + + return { ...state, noiseSuppression }; + } + + case 'TOGGLE_AUTO_GAIN_CONTROL': + { + const autoGainControl = !state.autoGainControl; + + return { ...state, autoGainControl }; + } + + case 'TOGGLE_ECHO_CANCELLATION': + { + const echoCancellation = !state.echoCancellation; + + return { ...state, echoCancellation }; + } + + case 'TOGGLE_NOISE_SUPPRESSION': + { + const noiseSuppression = !state.noiseSuppression; + + return { ...state, noiseSuppression }; + } + + case 'SET_SAMPLE_SIZE': + { + const { sampleSize } = action.payload; + + return { ...state, sampleSize }; + } + case 'SET_LAST_N': { const { lastN } = action.payload; From fa5f4f02a606e9d5ceceba65114a740388fa7435 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9sz=C3=A1ros=20Mih=C3=A1ly?= Date: Tue, 5 May 2020 23:23:52 +0200 Subject: [PATCH 37/88] Use full room and peer object for the statusLogger --- server/config/config.example.js | 8 ++++---- server/server.js | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/server/config/config.example.js b/server/config/config.example.js index 9d27f11..dda9e2f 100644 --- a/server/config/config.example.js +++ b/server/config/config.example.js @@ -96,8 +96,8 @@ module.exports = this._queue = new AwaitQueue(); } - // rooms: number of rooms - // peers: number of peers + // rooms: rooms object + // peers: peers object // eslint-disable-next-line no-unused-vars async log({ rooms, peers }) { @@ -106,9 +106,9 @@ module.exports = // Do your logging in here, use queue to keep correct order // eslint-disable-next-line no-console - console.log('Number of rooms: ', rooms); + console.log('Number of rooms: ', rooms.size); // eslint-disable-next-line no-console - console.log('Number of peers: ', peers); + console.log('Number of peers: ', peers.size); }) .catch((error) => { diff --git a/server/server.js b/server/server.js index 42112fe..5820413 100755 --- a/server/server.js +++ b/server/server.js @@ -197,8 +197,8 @@ function statusLog() if (statusLogger) { statusLogger.log({ - rooms : rooms.size, - peers : peers.size + rooms : rooms, + peers : peers }); } } From da6c9d3ecf4005204020fbad932b5a7e61e12f3b Mon Sep 17 00:00:00 2001 From: Stefan Otto Date: Tue, 5 May 2020 23:41:27 +0200 Subject: [PATCH 38/88] fix settings.js --- app/src/reducers/settings.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/reducers/settings.js b/app/src/reducers/settings.js index 60f05fd..79d2bc3 100644 --- a/app/src/reducers/settings.js +++ b/app/src/reducers/settings.js @@ -86,6 +86,13 @@ const settings = (state = initialState, action) => return { ...state, noiseSuppression }; } + case 'SET_DEFAULT_AUDIO': + { + const { audio } = action.payload; + + return { ...state, audio }; + } + case 'TOGGLE_AUTO_GAIN_CONTROL': { const autoGainControl = !state.autoGainControl; From 136037d83f60261234ecaf8f90710a44b7814c48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Wed, 6 May 2020 01:40:08 +0200 Subject: [PATCH 39/88] Simplify participantlist and order participants based on status. Raise hand queue, and moderator can remove raised hand. Fixes #146, #278 --- app/src/RoomClient.js | 27 +++++++ app/src/actions/peerActions.js | 6 ++ .../MeetingDrawer/ParticipantList/ListMe.js | 11 ++- .../MeetingDrawer/ParticipantList/ListPeer.js | 48 ++++++++++-- .../ParticipantList/ParticipantList.js | 73 ++++++++----------- app/src/components/Selectors.js | 30 +++++++- app/src/reducers/peers.js | 7 ++ server/lib/Room.js | 23 ++++++ 8 files changed, 168 insertions(+), 57 deletions(-) diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index beea13b..1c93794 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -1533,6 +1533,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); @@ -2534,6 +2554,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 fee30a5..414b744 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 d230db2..33873d2 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 1aa70a1..d8b3fb3 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 af35dbd..411c745 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js @@ -1,10 +1,9 @@ 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'; @@ -76,9 +75,9 @@ class ParticipantList extends React.PureComponent roomClient, advancedMode, isModerator, - passivePeers, + participants, + spotlights, selectedPeerId, - spotlightPeers, classes } = this.props; @@ -107,48 +106,34 @@ class ParticipantList extends React.PureComponent
  • - { spotlightPeers.map((peer) => ( + { participants.map((peer) => (
  • roomClient.setSelectedPeer(peer.id)} > - - - -
  • - ))} -
-
    -
  • - -
  • - { passivePeers.map((peer) => ( -
  • roomClient.setSelectedPeer(peer.id)} - > - + { spotlights.includes(peer.id) ? + + + + : + + }
  • ))}
@@ -162,9 +147,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 +159,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 fd22aff..b8e5e41 100644 --- a/app/src/components/Selectors.js +++ b/app/src/components/Selectors.js @@ -12,7 +12,8 @@ const peersKeySelector = createSelector( peersSelector, (peers) => Object.keys(peers) ); -const peersValueSelector = createSelector( + +export const peersValueSelector = createSelector( peersSelector, (peers) => Object.values(peers) ); @@ -113,8 +114,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 4c8bee1..3e2c6a0 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 0f89b4d..1b207fa 100644 --- a/server/lib/Room.js +++ b/server/lib/Room.js @@ -1430,6 +1430,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); From aca3499afb8d10091bc7cc72e08ce240b322af2d Mon Sep 17 00:00:00 2001 From: Stefan Otto Date: Wed, 6 May 2020 02:33:37 +0200 Subject: [PATCH 40/88] merge from develop --- CHANGELOG.md | 32 +- CONTRIBUTING.md | 1 + HAproxy.md | 101 ++ LICENSE.md | 21 + LTI/LTI.md | 61 ++ LTI/lti1.png | Bin 0 -> 87852 bytes LTI/lti2.png | Bin 0 -> 59763 bytes LTI/lti3.png | Bin 0 -> 118045 bytes LTI/lti4.png | Bin 0 -> 136597 bytes README.md | 23 +- app/package.json | 17 +- app/public/config/config.example.js | 34 +- app/public/privacy/privacy.html | 13 + app/src/RoomClient.js | 893 +++++++++++++++--- app/src/ScreenShare.js | 5 +- app/src/__tests__/Room.spec.js | 17 +- app/src/__tests__/RoomClient.spec.js | 2 +- app/src/actions/chatActions.js | 5 + app/src/actions/fileActions.js | 5 + app/src/actions/meActions.js | 25 +- app/src/actions/peerActions.js | 6 +- app/src/actions/roomActions.js | 54 +- app/src/actions/settingsActions.js | 16 + .../AccessControl/LockDialog/ListLobbyPeer.js | 97 +- .../AccessControl/LockDialog/LockDialog.js | 94 +- app/src/components/App.js | 2 +- app/src/components/ChooseRoom.js | 4 +- app/src/components/Containers/Me.js | 481 ++++++++-- app/src/components/Containers/Peer.js | 424 +++++++-- app/src/components/Containers/SpeakerPeer.js | 59 +- app/src/components/Controls/ExtraVideo.js | 167 ++++ app/src/components/Controls/MobileControls.js | 172 ---- app/src/components/Controls/TopBar.js | 692 ++++++++++---- app/src/components/JoinDialog.js | 62 +- app/src/components/MeetingDrawer/Chat/Chat.js | 2 + .../MeetingDrawer/Chat/ChatInput.js | 11 +- .../MeetingDrawer/Chat/ChatModerator.js | 100 ++ .../components/MeetingDrawer/Chat/Message.js | 14 +- .../MeetingDrawer/FileSharing/FileSharing.js | 103 +- .../FileSharing/FileSharingModerator.js | 100 ++ .../components/MeetingDrawer/MeetingDrawer.js | 32 +- .../MeetingDrawer/ParticipantList/ListMe.js | 107 +-- .../ParticipantList/ListModerator.js | 12 +- .../MeetingDrawer/ParticipantList/ListPeer.js | 169 ++-- .../ParticipantList/ParticipantList.js | 39 +- app/src/components/MeetingViews/Filmstrip.js | 121 ++- app/src/components/PeerAudio/AudioPeers.js | 17 +- app/src/components/PeerAudio/PeerAudio.js | 33 +- app/src/components/Room.js | 27 +- app/src/components/Selectors.js | 79 +- .../components/Settings/AdvancedSettings.js | 125 +++ .../components/Settings/AppearenceSettings.js | 143 +++ app/src/components/Settings/MediaSettings.js | 341 +++++++ app/src/components/Settings/Settings.js | 426 ++------- .../VideoContainers/FullScreenView.js | 25 +- .../components/VideoContainers/FullView.js | 9 +- .../components/VideoContainers/VideoView.js | 208 +++- app/src/components/VideoWindow/VideoWindow.js | 25 +- app/src/components/appPropTypes.js | 6 +- app/src/deviceInfo.js | 8 +- app/src/images/icon-hand-black.svg | 26 - app/src/images/icon-hand-white.svg | 26 - app/src/index.js | 16 +- app/src/reducers/chat.js | 5 + app/src/reducers/files.js | 3 + app/src/reducers/lobbyPeers.js | 2 +- app/src/reducers/me.js | 31 +- app/src/reducers/peers.js | 10 +- app/src/reducers/room.js | 107 ++- app/src/reducers/settings.js | 26 +- app/src/reducers/userRoles.js | 4 - app/src/translations/cn.json | 37 +- app/src/translations/{cz.json => cs.json} | 41 +- app/src/translations/de.json | 149 +-- app/src/translations/dk.json | 37 +- app/src/translations/el.json | 37 +- app/src/translations/en.json | 37 +- app/src/translations/es.json | 37 +- app/src/translations/fr.json | 37 +- app/src/translations/hr.json | 71 +- app/src/translations/hu.json | 87 +- app/src/translations/it.json | 47 +- app/src/translations/lv.json | 172 ++++ app/src/translations/nb.json | 37 +- app/src/translations/pl.json | 41 +- app/src/translations/pt.json | 37 +- app/src/translations/ro.json | 37 +- app/src/translations/tr.json | 170 ++++ app/src/translations/uk.json | 178 ++++ munin/mm-plugin | 2 +- prom.md | 55 ++ server/config/config.example.js | 125 ++- server/lib/Lobby.js | 7 +- server/lib/Peer.js | 76 +- server/lib/Room.js | 590 +++++++++--- server/lib/promExporter.js | 284 ++++++ server/package.json | 15 +- server/server.js | 168 +++- server/userRoles.js | 13 +- 99 files changed, 6709 insertions(+), 2038 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 HAproxy.md create mode 100644 LICENSE.md create mode 100644 LTI/LTI.md create mode 100644 LTI/lti1.png create mode 100644 LTI/lti2.png create mode 100644 LTI/lti3.png create mode 100644 LTI/lti4.png create mode 100644 app/public/privacy/privacy.html create mode 100644 app/src/components/Controls/ExtraVideo.js delete mode 100644 app/src/components/Controls/MobileControls.js create mode 100644 app/src/components/MeetingDrawer/Chat/ChatModerator.js create mode 100644 app/src/components/MeetingDrawer/FileSharing/FileSharingModerator.js create mode 100644 app/src/components/Settings/AdvancedSettings.js create mode 100644 app/src/components/Settings/AppearenceSettings.js create mode 100644 app/src/components/Settings/MediaSettings.js delete mode 100644 app/src/images/icon-hand-black.svg delete mode 100644 app/src/images/icon-hand-white.svg delete mode 100644 app/src/reducers/userRoles.js rename app/src/translations/{cz.json => cs.json} (85%) create mode 100644 app/src/translations/lv.json create mode 100644 app/src/translations/tr.json create mode 100644 app/src/translations/uk.json create mode 100644 prom.md create mode 100644 server/lib/promExporter.js diff --git a/CHANGELOG.md b/CHANGELOG.md index bda55dd..471b71f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,32 @@ # Changelog +## 3.2.1 + +* Fix: permananent top bar by default +* Fix: `httpOnly` mode https redirect +* Add some extra checks for video stream and track +* Add Italian translation +* Add Czech translation +* Add new server option `trustProxy` for load balancing http only use case +* Add HAproxy load balance example +* Add LTI LMS integration documentation +* Fix spacing of leave button +* Fix for sharing same file multiple times + ## 3.2 * Add munin plugin -* Add muted=true search param to disble audio by deffault +* Add `muted=true` search param to disable audio by default * Modify webtorrent tracker * Add key shortcut `space` for audio mute * Add key shortcut `v` for video mute * Add user configurable LastN -* Add option to sticky top bar (sticky by default) -* update mediasoup server -* Add simulcast options to app config (disabled by default) -* Add stats option to get counts of rooms and peers -* Add httpOnly option for loadbalancer backend setups +* Add option to permananent top bar (permanent by default) +* Update mediasoup server +* Add `simulcast` options to app config (disabled by default) +* Add `stats` option to get counts of rooms and peers +* Add `httpOnly` option for loadbalancer backend setups * LTI integration for LMS systems like moodle -* Add muted=false search parameter * Add translations (12+1 languages) * Add support IPv6 * Many other fixes and refactorings @@ -33,10 +45,10 @@ * Updated to mediasoup v3 * Replace lib "passport-datporten" with "openid-client" (a general OIDC certified client) - - OpenID Connect discovery - - Auth code flow + * OpenID Connect discovery + * Auth code flow * Add spdy http2 support. - - Notice it does not supports node 11.x + * Notice it does not supports node 11.x * Updated to Material UI v4 ## 2.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d5c8efc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1 @@ +Source code contributions should pass static code analysis as performed by `npm run lint` in `server` and `app` respectively. diff --git a/HAproxy.md b/HAproxy.md new file mode 100644 index 0000000..485e11e --- /dev/null +++ b/HAproxy.md @@ -0,0 +1,101 @@ +# Howto deploy a (room based) load balanced cluster + +This example will show how to setup an HA proxy to provide load balancing between several +multiparty-meeting servers. + +## IP and DNS + +In this basic example we use the following names and ips: + +### Backend + +* `mm1.example.com` <=> `192.0.2.1` +* `mm2.example.com` <=> `192.0.2.2` +* `mm3.example.com` <=> `192.0.2.3` + +### Redis + +* `redis.example.com` <=> `192.0.2.4` + +### Load balancer HAproxy + +* `meet.example.com` <=> `192.0.2.5` + +## Deploy multiple multiparty-meeting servers + +This is most easily done using Ansible (see below), but can be done +in any way you choose (manual, Docker, Ansible). + +Read more here: [mm-ansible](https://github.com/misi/mm-ansible) +[![asciicast](https://asciinema.org/a/311365.svg)](https://asciinema.org/a/311365) + +## Setup Redis for central HTTP session store + +### Use one Redis for all multiparty-meeting servers + +* Deploy a Redis cluster for all instances. + * We will use in our actual example `192.0.2.4` as redis HA cluster ip. It is out of scope howto deploy it. + +OR + +* For testing you can use Redis from one the multiparty-meeting servers. e.g. If you plan only for testing on your first multiparty-meeting server. + * Configure Redis `redis.conf` to not only bind to your loopback but also to your global ip address too: + + ``` plaintext + bind 192.0.2.1 + ``` + + This example sets this to `192.0.2.1`, change this according to your local installation. + + * Change your firewall config to allow incoming Redis. Example (depends on the type of firewall): + + ``` plaintext + chain INPUT { + policy DROP; + + saddr mm2.example.com proto tcp dport 6379 ACCEPT; + saddr mm3.example.com proto tcp dport 6379 ACCEPT; + } + ``` + + * **Set a password, or if you don't (like in this basic example) take care to set strict firewall rules** + +## Configure multiparty-meeting servers + +### Server config + +mm/configs/server/config.js + +``` js +redisOptions : { host: '192.0.2.4'}, +listeningPort: 80, +httpOnly: true, +trustProxy : ['192.0.2.5'], +``` + +## Deploy HA proxy + +* Configure certificate / letsencrypt for `meet.example.com` + * In this example we put a complete chain and private key in /root/certificate.pem. +* Install and setup haproxy + + `apt install haproxy` + +* Add to /etc/haproxy/haproxy.cfg config + + ``` plaintext + backend multipartymeeting + balance url_param roomId + hash-type consistent + + server mm1 192.0.2.1:80 check maxconn 20 verify none + server mm2 192.0.2.2:80 check maxconn 20 verify none + server mm3 192.0.2.3:80 check maxconn 20 verify none + + frontend meet.example.com + bind 192.0.2.5:80 + bind 192.0.2.5:443 ssl crt /root/certificate.pem + http-request redirect scheme https unless { ssl_fc } + reqadd X-Forwarded-Proto:\ https + default_backend multipartymeeting + ``` diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..b3d9d19 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 GÉANT Association + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LTI/LTI.md b/LTI/LTI.md new file mode 100644 index 0000000..15b6e6e --- /dev/null +++ b/LTI/LTI.md @@ -0,0 +1,61 @@ +# Learning Tools Interoperability (LTI) + +## LTI + +Read more about IMS Global defined interface for tools like our VideoConference system integration with Learning Management Systems(LMS) (e.g. moodle). +See: [IMS Global Learning Tool Interoperability](https://www.imsglobal.org/activity/learning-tools-interoperability) + +We implemented LTI interface version 1.0/1.1 + +### Server config auth section LTI settings + +Set in server configuration a random key and secret + +``` json +auth : + { + lti : + { + consumerKey : 'key', + consumerSecret : 'secret' + }, + } +``` + +### Configure your LMS system with secret and key settings above + +#### Auth tool URL + +Set tool URL to your server with path /auth/lti + +``` url +https://mm.example.com/auth/lti +``` + +#### In moodle find external tool plugin setting and external tool action + +See: [moodle external tool settings](https://docs.moodle.org/38/en/External_tool_settings) + +#### Add and activity + +![Add external tool](lti1.png) + +#### Setup Activity + +##### Activity setup basic form + +Open fully the settings **Click on show more!!** +![Add external tool config](lti2.png) + +##### Empty full form + +![Opened external tool config](lti3.png) + +##### Filled out form + +![Filled out external tool config](lti4.png) + +## moodle plugin + +Alternatively you can use multipartymeeting moodle plugin: +[https://github.com/misi/moodle-mod_multipartymeeting](https://github.com/misi/moodle-mod_multipartymeeting) diff --git a/LTI/lti1.png b/LTI/lti1.png new file mode 100644 index 0000000000000000000000000000000000000000..cc420c49bb09244b2d53eda790262ebe77176237 GIT binary patch literal 87852 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

!#|bgz{Y$vcmJ`op;t|fg-=D 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 OU$Qbv(s_~(|NK9;wfI{A literal 0 HcmV?d00001 diff --git a/LTI/lti3.png b/LTI/lti3.png new file mode 100644 index 0000000000000000000000000000000000000000..3b16d35189c4254d56ec8cc4cd2b09a9ec2f67c3 GIT binary patch literal 118045 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ℑyXnAz;t^{<8t)oo4;M#IGX9$<)Zt0-mzddYxi5q z#(oXk^GDz620FH1{(PxioX!EeOFa52=j#$CY!Olw>U-q%Y42;_w_r(8ZZe%ga!$3_ z

Pr*&ERhtY>Y@fHp%!>W>5JBoSTafKUp%=`< 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 z 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", @@ -12,14 +12,15 @@ "@material-ui/icons": "^4.5.1", "bowser": "^2.7.0", "classnames": "^2.2.6", + "create-torrent": "^4.4.1", "dompurify": "^2.0.7", "domready": "^1.0.8", - "end-of-stream": "1.4.0", + "end-of-stream": "1.4.1", "file-saver": "^2.0.2", "hark": "^1.2.3", "is-electron": "^2.2.0", "marked": "^0.8.0", - "mediasoup-client": "^3.5.4", + "mediasoup-client": "^3.6.4", "notistack": "^0.9.5", "prop-types": "^15.7.2", "random-string": "^0.2.0", @@ -29,7 +30,8 @@ "react-intl": "^3.4.0", "react-redux": "^7.1.1", "react-router-dom": "^5.1.2", - "react-scripts": "^3.3.0", + "react-scripts": "3.4.1", + "react-wakelock-react16": "0.0.7", "redux": "^4.0.4", "redux-logger": "^3.0.6", "redux-persist": "^6.0.0", @@ -38,7 +40,7 @@ "riek": "^1.1.0", "socket.io-client": "^2.3.0", "source-map-explorer": "^2.1.0", - "webtorrent": "^0.107.16" + "webtorrent": "^0.107.17" }, "scripts": { "analyze": "source-map-explorer build/static/js/*", @@ -58,11 +60,8 @@ ], "devDependencies": { "electron": "^7.1.1", - "eslint": "^6.5.1", - "eslint-plugin-import": "^2.18.2", - "eslint-plugin-react": "^7.16.0", + "eslint-plugin-react": "^7.19.0", "foreman": "^3.0.1", - "jest": "^24.9.0", "redux-mock-store": "^1.5.3" } } diff --git a/app/public/config/config.example.js b/app/public/config/config.example.js index f64b139..ca7a213 100644 --- a/app/public/config/config.example.js +++ b/app/public/config/config.example.js @@ -1,9 +1,9 @@ // eslint-disable-next-line var config = { - loginEnabled : false, - developmentPort : 3443, - productionPort : 443, + loginEnabled : false, + developmentPort : 3443, + productionPort : 443, /** * If defaultResolution is set, it will override user settings when joining: @@ -25,29 +25,45 @@ var config = { scaleResolutionDownBy: 2 }, { scaleResolutionDownBy: 1 } ], + + /** + * White listing browsers that support audio output device selection. + * It is not yet fully implemented in Firefox. + * See: https://bugzilla.mozilla.org/show_bug.cgi?id=1498512 + */ + audioOutputSupportedBrowsers : + [ + 'chrome', + 'opera' + ], // Socket.io request timeout requestTimeout : 10000, transportOptions : { tcp : true }, - lastN : 4, - mobileLastN : 1, defaultAudio : { sampleRate : 48000, channelCount : 1, volume : 1.0, - autoGainControl : true, + autoGainControl : false, echoCancellation : true, noiseSuppression : true, sampleSize : 16 }, - background : 'images/background.jpg', + background : 'images/background.jpg', + defaultLayout : 'democratic', // democratic, filmstrip + lastN : 4, + mobileLastN : 1, + // Highest number of speakers user can select + maxLastN : 5, + // If truthy, users can NOT change number of speakers visible + lockLastN : false, // Add file and uncomment for adding logo to appbar // logo : 'images/logo.svg', - title : 'Multiparty meeting', - theme : + title : 'Multiparty meeting', + theme : { palette : { diff --git a/app/public/privacy/privacy.html b/app/public/privacy/privacy.html new file mode 100644 index 0000000..89b4959 --- /dev/null +++ b/app/public/privacy/privacy.html @@ -0,0 +1,13 @@ + + + + + + Pleaceholder for Privacy Statetment/Policy, AUP + + +

Privacy Statement

+

Privacy Policy

+

Acceptable use policy (AUP)

+ + \ No newline at end of file diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index de3e177..684a0e3 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -14,6 +14,8 @@ import * as consumerActions from './actions/consumerActions'; import * as producerActions from './actions/producerActions'; import * as notificationActions from './actions/notificationActions'; +let createTorrent; + let WebTorrent; let saveAs; @@ -128,8 +130,6 @@ export default class RoomClient peerId, accessCode, device, - useSimulcast, - useSharingSimulcast, produce, forceTcp, displayName, @@ -142,8 +142,8 @@ export default class RoomClient throw new Error('Missing device'); logger.debug( - 'constructor() [peerId: "%s", device: "%s", useSimulcast: "%s", produce: "%s", forceTcp: "%s", displayName ""]', - peerId, device.flag, useSimulcast, produce, forceTcp, displayName); + 'constructor() [peerId: "%s", device: "%s", produce: "%s", forceTcp: "%s", displayName ""]', + peerId, device.flag, produce, forceTcp, displayName); this._signalingUrl = null; @@ -153,24 +153,26 @@ export default class RoomClient // Whether we should produce. this._produce = produce; - // Wheter we force TCP + // Whether we force TCP this._forceTcp = forceTcp; // Use displayName if (displayName) store.dispatch(settingsActions.setDisplayName(displayName)); + this._tracker = 'wss://tracker.lab.vvc.niif.hu:443'; + // Torrent support this._torrentSupport = null; // Whether simulcast should be used. - this._useSimulcast = useSimulcast; + this._useSimulcast = false; if ('simulcast' in window.config) this._useSimulcast = window.config.simulcast; // Whether simulcast should be used for sharing - this._useSharingSimulcast = useSharingSimulcast; + this._useSharingSimulcast = false; if ('simulcastSharing' in window.config) this._useSharingSimulcast = window.config.simulcastSharing; @@ -199,6 +201,9 @@ export default class RoomClient // @type {mediasoupClient.Device} this._mediasoupDevice = null; + // Put the browser info into state + store.dispatch(meActions.setBrowser(device)); + // Our WebTorrent client this._webTorrent = null; @@ -209,13 +214,10 @@ export default class RoomClient store.dispatch(settingsActions.setDefaultAudio(defaultAudio)); // Max spotlights - if (device.bowser.getPlatformType() === 'desktop') + if (device.platform === 'desktop') this._maxSpotlights = lastN; else - { this._maxSpotlights = mobileLastN; - store.dispatch(meActions.setIsMobile()); - } store.dispatch( settingsActions.setLastN(this._maxSpotlights)); @@ -241,12 +243,17 @@ export default class RoomClient // Local webcam mediasoup Producer. this._webcamProducer = null; + // Extra videos being produced + this._extraVideoProducers = new Map(); + // Map of webcam MediaDeviceInfos indexed by deviceId. // @type {Map} this._webcams = {}; this._audioDevices = {}; + this._audioOutputDevices = {}; + // mediasoup Consumers. // @type {Map} this._consumers = new Map(); @@ -285,7 +292,7 @@ export default class RoomClient _startKeyListener() { - // Add keypress event listner on document + // Add keydown event listener on document document.addEventListener('keydown', (event) => { if (event.repeat) return; @@ -363,7 +370,7 @@ export default class RoomClient store.dispatch(requestActions.notify( { text : intl.formatMessage({ - id : 'devices.microPhoneMute', + id : 'devices.microphoneMute', defaultMessage : 'Muted your microphone' }) })); @@ -375,7 +382,7 @@ export default class RoomClient store.dispatch(requestActions.notify( { text : intl.formatMessage({ - id : 'devices.microPhoneUnMute', + id : 'devices.microphoneUnMute', defaultMessage : 'Unmuted your microphone' }) })); @@ -459,6 +466,7 @@ export default class RoomClient await this._updateAudioDevices(); await this._updateWebcams(); + await this._updateAudioOutputDevices(); store.dispatch(requestActions.notify( { @@ -470,9 +478,9 @@ export default class RoomClient }); } - login() + login(roomId = this._roomId) { - const url = `/auth/login?peerId=${this._peerId}&roomId=${this._roomId}`; + const url = `/auth/login?peerId=${this._peerId}&roomId=${roomId}`; window.open(url, 'loginWindow'); } @@ -506,6 +514,8 @@ export default class RoomClient { logger.debug('receiveLogoutChildWindow()'); + store.dispatch(meActions.setPicture(null)); + store.dispatch(meActions.loggedIn(false)); store.dispatch(requestActions.notify( @@ -519,22 +529,22 @@ export default class RoomClient _soundNotification() { - const alertPromise = this._soundAlert.play(); + const { notificationSounds } = store.getState().settings; - if (alertPromise !== undefined) + if (notificationSounds) { - alertPromise - .then() - .catch((error) => - { - logger.error('_soundAlert.play() | failed: %o', error); - }); - } - } + const alertPromise = this._soundAlert.play(); - notify(text) - { - store.dispatch(requestActions.notify({ text: text })); + if (alertPromise !== undefined) + { + alertPromise + .then() + .catch((error) => + { + logger.error('_soundAlert.play() | failed: %o', error); + }); + } + } } timeoutCallback(callback) @@ -627,7 +637,7 @@ export default class RoomClient type : 'error', text : intl.formatMessage({ id : 'room.changeDisplayNameError', - defaultMessage : 'An error occured while changing your display name' + defaultMessage : 'An error occurred while changing your display name' }) })); } @@ -682,7 +692,7 @@ export default class RoomClient { if (err) { - return store.dispatch(requestActions.notify( + store.dispatch(requestActions.notify( { type : 'error', text : intl.formatMessage({ @@ -690,6 +700,8 @@ export default class RoomClient defaultMessage : 'Unable to save file' }) })); + + return; } saveAs(blob, file.name); @@ -706,7 +718,9 @@ export default class RoomClient if (existingTorrent) { // Never add duplicate torrents, use the existing one instead. - return this._handleTorrent(existingTorrent); + this._handleTorrent(existingTorrent); + + return; } this._webTorrent.add(magnetUri, this._handleTorrent); @@ -718,11 +732,13 @@ export default class RoomClient // same file was sent multiple times. if (torrent.progress === 1) { - return store.dispatch( + store.dispatch( fileActions.setFileDone( torrent.magnetURI, torrent.files )); + + return; } let lastMove = 0; @@ -761,10 +777,25 @@ export default class RoomClient }) })); - this._webTorrent.seed( - files, - { announceList: [ [ 'wss://tracker.lab.vvc.niif.hu:443' ] ] }, - (torrent) => + createTorrent(files, (err, torrent) => + { + if (err) + { + store.dispatch(requestActions.notify( + { + type : 'error', + text : intl.formatMessage({ + id : 'filesharing.unableToShare', + defaultMessage : 'Unable to share file' + }) + })); + + return; + } + + const existingTorrent = this._webTorrent.get(torrent); + + if (existingTorrent) { store.dispatch(requestActions.notify( { @@ -776,11 +807,35 @@ export default class RoomClient store.dispatch(fileActions.addFile( this._peerId, - torrent.magnetURI + existingTorrent.magnetURI )); - this._sendFile(torrent.magnetURI); - }); + this._sendFile(existingTorrent.magnetURI); + + return; + } + + this._webTorrent.seed( + files, + { announceList: [ [ this._tracker ] ] }, + (newTorrent) => + { + store.dispatch(requestActions.notify( + { + text : intl.formatMessage({ + id : 'filesharing.successfulFileShare', + defaultMessage : 'File successfully shared' + }) + })); + + store.dispatch(fileActions.addFile( + this._peerId, + newTorrent.magnetURI + )); + + this._sendFile(newTorrent.magnetURI); + }); + }); } // { file, name, picture } @@ -807,62 +862,6 @@ export default class RoomClient } } - async getServerHistory() - { - logger.debug('getServerHistory()'); - - try - { - const { - chatHistory, - fileHistory, - lastNHistory, - locked, - lobbyPeers, - accessCode - } = await this.sendRequest('serverHistory'); - - (chatHistory.length > 0) && store.dispatch( - chatActions.addChatHistory(chatHistory)); - - (fileHistory.length > 0) && store.dispatch( - fileActions.addFileHistory(fileHistory)); - - if (lastNHistory.length > 0) - { - logger.debug('Got lastNHistory'); - - // Remove our self from list - const index = lastNHistory.indexOf(this._peerId); - - lastNHistory.splice(index, 1); - - this._spotlights.addSpeakerList(lastNHistory); - } - - locked ? - store.dispatch(roomActions.setRoomLocked()) : - store.dispatch(roomActions.setRoomUnLocked()); - - (lobbyPeers.length > 0) && lobbyPeers.forEach((peer) => - { - store.dispatch( - lobbyPeerActions.addLobbyPeer(peer.peerId)); - store.dispatch( - lobbyPeerActions.setLobbyPeerDisplayName(peer.displayName)); - store.dispatch( - lobbyPeerActions.setLobbyPeerPicture(peer.picture)); - }); - - (accessCode != null) && store.dispatch( - roomActions.setAccessCode(accessCode)); - } - catch (error) - { - logger.error('getServerHistory() | failed: %o', error); - } - } - async muteMic() { logger.debug('muteMic()'); @@ -1086,6 +1085,37 @@ export default class RoomClient meActions.setAudioInProgress(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); @@ -1114,14 +1144,41 @@ export default class RoomClient ...VIDEO_CONSTRAINS[resolution] } }); + + if (stream) + { + const track = stream.getVideoTracks()[0]; - const track = stream.getVideoTracks()[0]; - - await this._webcamProducer.replaceTrack({ track }); - - store.dispatch( - producerActions.setProducerTrack(this._webcamProducer.id, track)); + if (track) + { + if (this._webcamProducer) + { + await this._webcamProducer.replaceTrack({ track }); + } + else + { + this._webcamProducer = await this._sendTransport.produce({ + track, + appData : + { + source : 'webcam' + } + }); + } + store.dispatch( + producerActions.setProducerTrack(this._webcamProducer.id, track)); + } + else + { + logger.warn('getVideoTracks Error: First Video Track is null'); + } + + } + else + { + logger.warn('getUserMedia Error: Stream is null!'); + } store.dispatch(settingsActions.setSelectedWebcamDevice(deviceId)); store.dispatch(settingsActions.setVideoResolution(resolution)); @@ -1174,11 +1231,24 @@ export default class RoomClient if (track) { - await this._webcamProducer.replaceTrack({ track }); - + if (this._webcamProducer) + { + await this._webcamProducer.replaceTrack({ track }); + } + else + { + this._webcamProducer = await this._sendTransport.produce({ + track, + appData : + { + source : 'webcam' + } + }); + } + store.dispatch( producerActions.setProducerTrack(this._webcamProducer.id, track)); - + } else { @@ -1214,6 +1284,26 @@ export default class RoomClient roomActions.setSelectedPeer(peerId)); } + async promoteAllLobbyPeers() + { + logger.debug('promoteAllLobbyPeers()'); + + store.dispatch( + roomActions.setLobbyPeersPromotionInProgress(true)); + + try + { + await this.sendRequest('promoteAllPeers'); + } + catch (error) + { + logger.error('promoteAllLobbyPeers() [error:"%o"]', error); + } + + store.dispatch( + roomActions.setLobbyPeersPromotionInProgress(false)); + } + async promoteLobbyPeer(peerId) { logger.debug('promoteLobbyPeer() [peerId:"%s"]', peerId); @@ -1234,6 +1324,50 @@ export default class RoomClient lobbyPeerActions.setLobbyPeerPromotionInProgress(peerId, false)); } + async clearChat() + { + logger.debug('clearChat()'); + + store.dispatch( + roomActions.setClearChatInProgress(true)); + + try + { + await this.sendRequest('moderator:clearChat'); + + store.dispatch(chatActions.clearChat()); + } + catch (error) + { + logger.error('clearChat() failed: %o', error); + } + + store.dispatch( + roomActions.setClearChatInProgress(false)); + } + + async clearFileSharing() + { + logger.debug('clearFileSharing()'); + + store.dispatch( + roomActions.setClearFileSharingInProgress(true)); + + try + { + await this.sendRequest('moderator:clearFileSharing'); + + store.dispatch(fileActions.clearFiles()); + } + catch (error) + { + logger.error('clearFileSharing() failed: %o', error); + } + + store.dispatch( + roomActions.setClearFileSharingInProgress(false)); + } + async kickPeer(peerId) { logger.debug('kickPeer() [peerId:"%s"]', peerId); @@ -1409,30 +1543,30 @@ export default class RoomClient } } - async sendRaiseHandState(state) + async setRaisedHand(raisedHand) { - logger.debug('sendRaiseHandState: ', state); + logger.debug('setRaisedHand: ', raisedHand); store.dispatch( - meActions.setMyRaiseHandStateInProgress(true)); + meActions.setRaisedHandInProgress(true)); try { - await this.sendRequest('raiseHand', { raiseHandState: state }); + await this.sendRequest('raisedHand', { raisedHand }); store.dispatch( - meActions.setMyRaiseHandState(state)); + meActions.setRaisedHand(raisedHand)); } catch (error) { - logger.error('sendRaiseHandState() | failed: %o', error); + logger.error('setRaisedHand() | [error:"%o"]', error); // We need to refresh the component for it to render changed state - store.dispatch(meActions.setMyRaiseHandState(!state)); + store.dispatch(meActions.setRaisedHand(!raisedHand)); } store.dispatch( - meActions.setMyRaiseHandStateInProgress(false)); + meActions.setRaisedHandInProgress(false)); } async setMaxSendingSpatialLayer(spatialLayer) @@ -1506,6 +1640,13 @@ export default class RoomClient async _loadDynamicImports() { + ({ default: createTorrent } = await import( + + /* webpackPrefetch: true */ + /* webpackChunkName: "createtorrent" */ + 'create-torrent' + )); + ({ default: WebTorrent } = await import( /* webpackPrefetch: true */ @@ -1600,6 +1741,50 @@ export default class RoomClient }) })); + if (this._screenSharingProducer) + { + this._screenSharingProducer.close(); + + 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')); }); @@ -1728,7 +1913,7 @@ export default class RoomClient { // 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 + // 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); @@ -1788,6 +1973,13 @@ export default class RoomClient break; } + case 'overRoomLimit': + { + store.dispatch(roomActions.setOverRoomLimit(true)); + + break; + } + case 'roomReady': { const { turnServers } = notification.data; @@ -1801,6 +1993,13 @@ export default class RoomClient break; } + + case 'roomBack': + { + await this._joinRoom({ joinVideo }); + + break; + } case 'lockRoom': { @@ -1842,6 +2041,8 @@ export default class RoomClient lobbyPeerActions.addLobbyPeer(peerId)); store.dispatch( roomActions.setToolbarsVisible(true)); + + this._soundNotification(); store.dispatch(requestActions.notify( { @@ -1853,6 +2054,43 @@ export default class RoomClient break; } + + case 'parkedPeers': + { + const { lobbyPeers } = notification.data; + + if (lobbyPeers.length > 0) + { + lobbyPeers.forEach((peer) => + { + store.dispatch( + lobbyPeerActions.addLobbyPeer(peer.peerId)); + store.dispatch( + lobbyPeerActions.setLobbyPeerDisplayName( + peer.displayName, + peer.peerId + ) + ); + store.dispatch( + lobbyPeerActions.setLobbyPeerPicture(peer.picture)); + }); + + store.dispatch( + roomActions.setToolbarsVisible(true)); + + this._soundNotification(); + + store.dispatch(requestActions.notify( + { + text : intl.formatMessage({ + id : 'room.newLobbyPeer', + defaultMessage : 'New participant entered the lobby' + }) + })); + } + + break; + } case 'lobby:peerClosed': { @@ -2012,6 +2250,58 @@ export default class RoomClient break; } + case 'raisedHand': + { + const { + peerId, + raisedHand, + raisedHandTimestamp + } = notification.data; + + store.dispatch( + peerActions.setPeerRaisedHand( + peerId, + raisedHand, + raisedHandTimestamp + ) + ); + + const { displayName } = store.getState().peers[peerId]; + + let text; + + if (raisedHand) + { + text = intl.formatMessage({ + id : 'room.raisedHand', + defaultMessage : '{displayName} raised their hand' + }, { + displayName + }); + } + else + { + text = intl.formatMessage({ + id : 'room.loweredHand', + defaultMessage : '{displayName} put their hand down' + }, { + displayName + }); + } + + if (displayName) + { + store.dispatch(requestActions.notify( + { + text + })); + } + + this._soundNotification(); + + break; + } + case 'chatMessage': { const { peerId, chatMessage } = notification.data; @@ -2033,6 +2323,21 @@ export default class RoomClient break; } + case 'moderator:clearChat': + { + store.dispatch(chatActions.clearChat()); + + store.dispatch(requestActions.notify( + { + text : intl.formatMessage({ + id : 'moderator.clearChat', + defaultMessage : 'Moderator cleared the chat' + }) + })); + + break; + } + case 'sendFile': { const { peerId, magnetUri } = notification.data; @@ -2061,6 +2366,21 @@ export default class RoomClient break; } + case 'moderator:clearFileSharing': + { + store.dispatch(fileActions.clearFiles()); + + store.dispatch(requestActions.notify( + { + text : intl.formatMessage({ + id : 'moderator.clearFiles', + defaultMessage : 'Moderator cleared the files' + }) + })); + + break; + } + case 'producerScore': { const { producerId, score } = notification.data; @@ -2078,6 +2398,8 @@ export default class RoomClient store.dispatch( peerActions.addPeer({ id, displayName, picture, roles, consumers: [] })); + this._soundNotification(); + store.dispatch(requestActions.notify( { text : intl.formatMessage({ @@ -2187,8 +2509,8 @@ export default class RoomClient store.dispatch(requestActions.notify( { text : intl.formatMessage({ - id : 'moderator.mute', - defaultMessage : 'Moderator muted your microphone' + id : 'moderator.muteAudio', + defaultMessage : 'Moderator muted your audio' }) })); } @@ -2206,7 +2528,7 @@ export default class RoomClient store.dispatch(requestActions.notify( { text : intl.formatMessage({ - id : 'moderator.mute', + id : 'moderator.muteVideo', defaultMessage : 'Moderator stopped your video' }) })); @@ -2234,7 +2556,9 @@ export default class RoomClient { text : intl.formatMessage({ id : 'roles.gotRole', - defaultMessage : `You got the role: ${role}` + defaultMessage : 'You got the role: {role}' + }, { + role }) })); } @@ -2256,7 +2580,9 @@ export default class RoomClient { text : intl.formatMessage({ id : 'roles.lostRole', - defaultMessage : `You lost the role: ${role}` + defaultMessage : 'You lost the role: {role}' + }, { + role }) })); } @@ -2294,10 +2620,8 @@ export default class RoomClient { logger.debug('_joinRoom()'); - const { - displayName, - picture - } = store.getState().settings; + const { displayName } = store.getState().settings; + const { picture } = store.getState().me; try { @@ -2310,7 +2634,7 @@ export default class RoomClient } } }); - + this._webTorrent.on('error', (error) => { logger.error('Filesharing [error:"%o"]', error); @@ -2318,7 +2642,10 @@ export default class RoomClient store.dispatch(requestActions.notify( { type : 'error', - text : intl.formatMessage({ id: 'filesharing.error', defaultMessage: 'There was a filesharing error' }) + text : intl.formatMessage({ + id : 'filesharing.error', + defaultMessage : 'There was a filesharing error' + }) })); }); @@ -2327,6 +2654,9 @@ export default class RoomClient const routerRtpCapabilities = await this.sendRequest('getRouterRtpCapabilities'); + routerRtpCapabilities.headerExtensions = routerRtpCapabilities.headerExtensions + .filter((ext) => ext.uri !== 'urn:3gpp:video-orientation'); + await this._mediasoupDevice.load({ routerRtpCapabilities }); if (this._produce) @@ -2354,7 +2684,7 @@ export default class RoomClient dtlsParameters, iceServers : this._turnServers, // TODO: Fix for issue #72 - iceTransportPolicy : this._device.flag === 'firefox' ? 'relay' : undefined, + iceTransportPolicy : this._device.flag === 'firefox' && this._turnServers ? 'relay' : undefined, proprietaryConstraints : PC_PROPRIETARY_CONSTRAINTS }); @@ -2418,7 +2748,7 @@ export default class RoomClient dtlsParameters, iceServers : this._turnServers, // TODO: Fix for issue #72 - iceTransportPolicy : this._device.flag === 'firefox' ? 'relay' : undefined + iceTransportPolicy : this._device.flag === 'firefox' && this._turnServers ? 'relay' : undefined }); this._recvTransport.on( @@ -2444,7 +2774,20 @@ export default class RoomClient canShareFiles : this._torrentSupport })); - const { roles, peers } = await this.sendRequest( + const { + authenticated, + roles, + peers, + tracker, + permissionsFromRoles, + userRoles, + chatHistory, + fileHistory, + lastNHistory, + locked, + lobbyPeers, + accessCode + } = await this.sendRequest( 'join', { displayName : displayName, @@ -2452,7 +2795,19 @@ export default class RoomClient rtpCapabilities : this._mediasoupDevice.rtpCapabilities }); - logger.debug('_joinRoom() joined [peers:"%o", roles:"%o"]', peers, roles); + logger.debug( + '_joinRoom() joined [authenticated:"%s", peers:"%o", roles:"%o"]', + authenticated, + peers, + roles + ); + + tracker && (this._tracker = tracker); + + store.dispatch(meActions.loggedIn(authenticated)); + + store.dispatch(roomActions.setUserRoles(userRoles)); + store.dispatch(roomActions.setPermissionsFromRoles(permissionsFromRoles)); const myRoles = store.getState().me.roles; @@ -2466,7 +2821,9 @@ export default class RoomClient { text : intl.formatMessage({ id : 'roles.gotRole', - defaultMessage : `You got the role: ${role}` + defaultMessage : 'You got the role: {role}' + }, { + role }) })); } @@ -2486,7 +2843,39 @@ export default class RoomClient this.updateSpotlights(spotlights); }); - // Don't produce if explicitely requested to not to do it. + (chatHistory.length > 0) && store.dispatch( + chatActions.addChatHistory(chatHistory)); + + (fileHistory.length > 0) && store.dispatch( + fileActions.addFileHistory(fileHistory)); + + if (lastNHistory.length > 0) + { + logger.debug('_joinRoom() | got lastN history'); + + this._spotlights.addSpeakerList( + lastNHistory.filter((peerId) => peerId !== this._peerId) + ); + } + + locked ? + store.dispatch(roomActions.setRoomLocked()) : + store.dispatch(roomActions.setRoomUnLocked()); + + (lobbyPeers.length > 0) && lobbyPeers.forEach((peer) => + { + store.dispatch( + lobbyPeerActions.addLobbyPeer(peer.peerId)); + store.dispatch( + lobbyPeerActions.setLobbyPeerDisplayName(peer.displayName, peer.peerId)); + store.dispatch( + lobbyPeerActions.setLobbyPeerPicture(peer.picture)); + }); + + (accessCode != null) && store.dispatch( + roomActions.setAccessCode(accessCode)); + + // Don't produce if explicitly requested to not to do it. if (this._produce) { if (this._mediasoupDevice.canProduce('audio')) @@ -2500,14 +2889,25 @@ export default class RoomClient if (joinVideo && this._mediasoupDevice.canProduce('video')) this.enableWebcam(); } + + await this._updateAudioOutputDevices(); + const { selectedAudioOutputDevice } = store.getState().settings; + + if (!selectedAudioOutputDevice && this._audioOutputDevices !== {}) + { + store.dispatch( + settingsActions.setSelectedAudioOutputDevice( + Object.keys(this._audioOutputDevices)[0] + ) + ); + } + store.dispatch(roomActions.setRoomState('connected')); - // Clean all the existing notifcations. + // Clean all the existing notifications. store.dispatch(notificationActions.removeAllNotifications()); - this.getServerHistory(); - store.dispatch(requestActions.notify( { text : intl.formatMessage({ @@ -2657,6 +3057,159 @@ export default class RoomClient } } + async addExtraVideo(videoDeviceId) + { + logger.debug( + 'addExtraVideo() [videoDeviceId:"%s"]', + videoDeviceId + ); + + store.dispatch( + roomActions.setExtraVideoOpen(false)); + + if (!this._mediasoupDevice.canProduce('video')) + { + logger.error('enableWebcam() | cannot produce video'); + + return; + } + + let track; + + store.dispatch( + meActions.setWebcamInProgress(true)); + + try + { + const device = this._webcams[videoDeviceId]; + const resolution = store.getState().settings.resolution; + + if (!device) + throw new Error('no webcam devices'); + + logger.debug( + 'addExtraVideo() | new selected webcam [device:%o]', + device); + + logger.debug('_setWebcamProducer() | calling getUserMedia()'); + + const stream = await navigator.mediaDevices.getUserMedia( + { + video : + { + deviceId : { ideal: videoDeviceId }, + ...VIDEO_CONSTRAINS[resolution] + } + }); + + track = stream.getVideoTracks()[0]; + + let producer; + + if (this._useSimulcast) + { + // If VP9 is the only available video codec then use SVC. + const firstVideoCodec = this._mediasoupDevice + .rtpCapabilities + .codecs + .find((c) => c.kind === 'video'); + + let encodings; + + if (firstVideoCodec.mimeType.toLowerCase() === 'video/vp9') + encodings = VIDEO_KSVC_ENCODINGS; + else if ('simulcastEncodings' in window.config) + encodings = window.config.simulcastEncodings; + else + encodings = VIDEO_SIMULCAST_ENCODINGS; + + producer = await this._sendTransport.produce( + { + track, + encodings, + codecOptions : + { + videoGoogleStartBitrate : 1000 + }, + appData : + { + source : 'extravideo' + } + }); + } + else + { + producer = await this._sendTransport.produce({ + track, + appData : + { + source : 'extravideo' + } + }); + } + + this._extraVideoProducers.set(producer.id, producer); + + store.dispatch(producerActions.addProducer( + { + id : producer.id, + deviceLabel : device.label, + source : 'extravideo', + paused : producer.paused, + track : producer.track, + rtpParameters : producer.rtpParameters, + codec : producer.rtpParameters.codecs[0].mimeType.split('/')[1] + })); + + // store.dispatch(settingsActions.setSelectedWebcamDevice(deviceId)); + + await this._updateWebcams(); + + producer.on('transportclose', () => + { + this._extraVideoProducers.delete(producer.id); + + producer = null; + }); + + producer.on('trackended', () => + { + store.dispatch(requestActions.notify( + { + type : 'error', + text : intl.formatMessage({ + id : 'devices.cameraDisconnected', + defaultMessage : 'Camera disconnected' + }) + })); + + this.disableExtraVideo(producer.id) + .catch(() => {}); + }); + + logger.debug('addExtraVideo() succeeded'); + } + catch (error) + { + logger.error('addExtraVideo() failed:%o', error); + + store.dispatch(requestActions.notify( + { + type : 'error', + text : intl.formatMessage({ + id : 'devices.cameraError', + defaultMessage : 'An error occurred while accessing your camera' + }) + })); + + if (track) + track.stop(); + } + + store.dispatch( + meActions.setWebcamInProgress(false)); + } + async enableMic() { if (this._micProducer) @@ -2692,7 +3245,7 @@ export default class RoomClient const stream = await navigator.mediaDevices.getUserMedia( { audio : { - deviceId : { exact: deviceId }, + deviceId : { ideal: deviceId }, sampleRate : 48000, channelCount : 1, volume : 1.0, @@ -2771,7 +3324,7 @@ export default class RoomClient type : 'error', text : intl.formatMessage({ id : 'devices.microphoneError', - defaultMessage : 'An error occured while accessing your microphone' + defaultMessage : 'An error occurred while accessing your microphone' }) })); @@ -2938,7 +3491,7 @@ export default class RoomClient type : 'error', text : intl.formatMessage({ id : 'devices.screenSharingError', - defaultMessage : 'An error occured while accessing your screen' + defaultMessage : 'An error occurred while accessing your screen' }) })); @@ -3016,7 +3569,7 @@ export default class RoomClient { video : { - deviceId : { exact: deviceId }, + deviceId : { ideal: deviceId }, ...VIDEO_CONSTRAINS[resolution] } }); @@ -3111,7 +3664,7 @@ export default class RoomClient type : 'error', text : intl.formatMessage({ id : 'devices.cameraError', - defaultMessage : 'An error occured while accessing your camera' + defaultMessage : 'An error occurred while accessing your camera' }) })); @@ -3123,6 +3676,37 @@ export default class RoomClient meActions.setWebcamInProgress(false)); } + async disableExtraVideo(id) + { + logger.debug('disableExtraVideo()'); + + const producer = this._extraVideoProducers.get(id); + + if (!producer) + return; + + store.dispatch(meActions.setWebcamInProgress(true)); + + producer.close(); + + store.dispatch( + producerActions.removeProducer(id)); + + try + { + await this.sendRequest( + 'closeProducer', { producerId: id }); + } + catch (error) + { + logger.error('disableWebcam() [error:"%o"]', error); + } + + this._extraVideoProducers.delete(id); + + store.dispatch(meActions.setWebcamInProgress(false)); + } + async disableWebcam() { logger.debug('disableWebcam()'); @@ -3265,4 +3849,35 @@ export default class RoomClient logger.error('_getWebcamDeviceId() failed:%o', error); } } + + async _updateAudioOutputDevices() + { + logger.debug('_updateAudioOutputDevices()'); + + // Reset the list. + this._audioOutputDevices = {}; + + try + { + logger.debug('_updateAudioOutputDevices() | calling enumerateDevices()'); + + const devices = await navigator.mediaDevices.enumerateDevices(); + + for (const device of devices) + { + if (device.kind !== 'audiooutput') + continue; + + this._audioOutputDevices[device.deviceId] = device; + } + + store.dispatch( + meActions.setAudioOutputDevices(this._audioOutputDevices)); + } + catch (error) + { + logger.error('_updateAudioOutputDevices() failed:%o', error); + } + } + } diff --git a/app/src/ScreenShare.js b/app/src/ScreenShare.js index 180fe2a..2ff1bcc 100644 --- a/app/src/ScreenShare.js +++ b/app/src/ScreenShare.js @@ -225,10 +225,7 @@ export default class ScreenShare return new DisplayMediaScreenShare(); } case 'chrome': - { - return new DisplayMediaScreenShare(); - } - case 'msedge': + case 'edge': { return new DisplayMediaScreenShare(); } diff --git a/app/src/__tests__/Room.spec.js b/app/src/__tests__/Room.spec.js index a866e06..bd62322 100644 --- a/app/src/__tests__/Room.spec.js +++ b/app/src/__tests__/Room.spec.js @@ -31,6 +31,8 @@ beforeEach(() => me : { audioDevices : null, audioInProgress : false, + audioOutputDevices : null, + audioOutputInProgress : false, canSendMic : false, canSendWebcam : false, canShareFiles : false, @@ -40,8 +42,8 @@ beforeEach(() => loggedIn : false, loginEnabled : true, picture : null, - raiseHand : false, - raiseHandInProgress : false, + raisedHand : false, + raisedHandInProgress : false, screenShareInProgress : false, webcamDevices : null, webcamInProgress : false @@ -72,11 +74,12 @@ beforeEach(() => windowConsumer : null }, settings : { - advancedMode : true, - displayName : 'Jest Tester', - resolution : 'ultra', - selectedAudioDevice : 'default', - selectedWebcam : 'soifjsiajosjfoi' + advancedMode : true, + displayName : 'Jest Tester', + resolution : 'ultra', + selectedAudioDevice : 'default', + selectedAudioOutputDevice : 'default', + selectedWebcam : 'soifjsiajosjfoi' }, toolarea : { currentToolTab : 'chat', diff --git a/app/src/__tests__/RoomClient.spec.js b/app/src/__tests__/RoomClient.spec.js index 086e6a5..5e1191c 100644 --- a/app/src/__tests__/RoomClient.spec.js +++ b/app/src/__tests__/RoomClient.spec.js @@ -1,6 +1,6 @@ import RoomClient from '../RoomClient'; -describe('new RoomClient() without paramaters throws Error', () => +describe('new RoomClient() without parameters throws Error', () => { test('Matches the snapshot', () => { diff --git a/app/src/actions/chatActions.js b/app/src/actions/chatActions.js index c38d92d..f7b0cf3 100644 --- a/app/src/actions/chatActions.js +++ b/app/src/actions/chatActions.js @@ -14,4 +14,9 @@ export const addChatHistory = (chatHistory) => ({ type : 'ADD_CHAT_HISTORY', payload : { chatHistory } + }); + +export const clearChat = () => + ({ + type : 'CLEAR_CHAT' }); \ No newline at end of file diff --git a/app/src/actions/fileActions.js b/app/src/actions/fileActions.js index f9277d6..b5d90e5 100644 --- a/app/src/actions/fileActions.js +++ b/app/src/actions/fileActions.js @@ -32,4 +32,9 @@ export const setFileDone = (magnetUri, sharedFiles) => ({ type : 'SET_FILE_DONE', payload : { magnetUri, sharedFiles } + }); + +export const clearFiles = () => + ({ + type : 'CLEAR_FILES' }); \ No newline at end of file diff --git a/app/src/actions/meActions.js b/app/src/actions/meActions.js index fc72592..7fb34ea 100644 --- a/app/src/actions/meActions.js +++ b/app/src/actions/meActions.js @@ -4,9 +4,10 @@ export const setMe = ({ peerId, loginEnabled }) => payload : { peerId, loginEnabled } }); -export const setIsMobile = () => +export const setBrowser = (browser) => ({ - type : 'SET_IS_MOBILE' + type : 'SET_BROWSER', + payload : { browser } }); export const loggedIn = (flag) => @@ -50,15 +51,21 @@ export const setAudioDevices = (devices) => payload : { devices } }); +export const setAudioOutputDevices = (devices) => + ({ + type : 'SET_AUDIO_OUTPUT_DEVICES', + payload : { devices } + }); + export const setWebcamDevices = (devices) => ({ type : 'SET_WEBCAM_DEVICES', payload : { devices } }); -export const setMyRaiseHandState = (flag) => +export const setRaisedHand = (flag) => ({ - type : 'SET_MY_RAISE_HAND_STATE', + type : 'SET_RAISED_HAND', payload : { flag } }); @@ -67,6 +74,12 @@ export const setAudioInProgress = (flag) => type : 'SET_AUDIO_IN_PROGRESS', payload : { flag } }); + +export const setAudioOutputInProgress = (flag) => + ({ + type : 'SET_AUDIO_OUTPUT_IN_PROGRESS', + payload : { flag } + }); export const setWebcamInProgress = (flag) => ({ @@ -80,9 +93,9 @@ export const setScreenShareInProgress = (flag) => payload : { flag } }); -export const setMyRaiseHandStateInProgress = (flag) => +export const setRaisedHandInProgress = (flag) => ({ - type : 'SET_MY_RAISE_HAND_STATE_IN_PROGRESS', + type : 'SET_RAISED_HAND_IN_PROGRESS', payload : { flag } }); diff --git a/app/src/actions/peerActions.js b/app/src/actions/peerActions.js index dc41568..fee30a5 100644 --- a/app/src/actions/peerActions.js +++ b/app/src/actions/peerActions.js @@ -34,10 +34,10 @@ export const setPeerScreenInProgress = (peerId, flag) => payload : { peerId, flag } }); -export const setPeerRaiseHandState = (peerId, raiseHandState) => +export const setPeerRaisedHand = (peerId, raisedHand, raisedHandTimestamp) => ({ - type : 'SET_PEER_RAISE_HAND_STATE', - payload : { peerId, raiseHandState } + type : 'SET_PEER_RAISED_HAND', + payload : { peerId, raisedHand, raisedHandTimestamp } }); export const setPeerPicture = (peerId, picture) => diff --git a/app/src/actions/roomActions.js b/app/src/actions/roomActions.js index 6003b9e..b90bf1b 100644 --- a/app/src/actions/roomActions.js +++ b/app/src/actions/roomActions.js @@ -40,6 +40,12 @@ export const setSignInRequired = (signInRequired) => payload : { signInRequired } }); +export const setOverRoomLimit = (overRoomLimit) => + ({ + type : 'SET_OVER_ROOM_LIMIT', + payload : { overRoomLimit } + }); + export const setAccessCode = (accessCode) => ({ type : 'SET_ACCESS_CODE', @@ -52,13 +58,25 @@ export const setJoinByAccessCode = (joinByAccessCode) => payload : { joinByAccessCode } }); -export const setSettingsOpen = ({ settingsOpen }) => +export const setSettingsOpen = (settingsOpen) => ({ type : 'SET_SETTINGS_OPEN', payload : { settingsOpen } }); -export const setLockDialogOpen = ({ lockDialogOpen }) => +export const setExtraVideoOpen = (extraVideoOpen) => + ({ + type : 'SET_EXTRA_VIDEO_OPEN', + payload : { extraVideoOpen } + }); + +export const setSettingsTab = (tab) => + ({ + type : 'SET_SETTINGS_TAB', + payload : { tab } + }); + +export const setLockDialogOpen = (lockDialogOpen) => ({ type : 'SET_LOCK_DIALOG_OPEN', payload : { lockDialogOpen } @@ -111,6 +129,12 @@ export const toggleConsumerFullscreen = (consumerId) => payload : { consumerId } }); +export const setLobbyPeersPromotionInProgress = (flag) => + ({ + type : 'SET_LOBBY_PEERS_PROMOTION_IN_PROGRESS', + payload : { flag } + }); + export const setMuteAllInProgress = (flag) => ({ type : 'MUTE_ALL_IN_PROGRESS', @@ -127,4 +151,28 @@ export const setCloseMeetingInProgress = (flag) => ({ type : 'CLOSE_MEETING_IN_PROGRESS', payload : { flag } - }); \ No newline at end of file + }); + +export const setClearChatInProgress = (flag) => + ({ + type : 'CLEAR_CHAT_IN_PROGRESS', + payload : { flag } + }); + +export const setClearFileSharingInProgress = (flag) => + ({ + type : 'CLEAR_FILE_SHARING_IN_PROGRESS', + payload : { flag } + }); + +export const setUserRoles = (userRoles) => + ({ + type : 'SET_USER_ROLES', + payload : { userRoles } + }); + +export const setPermissionsFromRoles = (permissionsFromRoles) => + ({ + type : 'SET_PERMISSIONS_FROM_ROLES', + payload : { permissionsFromRoles } + }); diff --git a/app/src/actions/settingsActions.js b/app/src/actions/settingsActions.js index 68b4257..654600e 100644 --- a/app/src/actions/settingsActions.js +++ b/app/src/actions/settingsActions.js @@ -4,6 +4,12 @@ export const setSelectedAudioDevice = (deviceId) => payload : { deviceId } }); +export const setSelectedAudioOutputDevice = (deviceId) => + ({ + type : 'CHANGE_AUDIO_OUTPUT_DEVICE', + payload : { deviceId } + }); + export const setSelectedWebcamDevice = (deviceId) => ({ type : 'CHANGE_WEBCAM', @@ -71,6 +77,16 @@ export const toggleNoiseSuppression = () => type : 'TOGGLE_NOISE_SUPPRESSION' }); +export const toggleHiddenControls = () => + ({ + type : 'TOGGLE_HIDDEN_CONTROLS' + }); + +export const toggleNotificationSounds = () => + ({ + type : 'TOGGLE_NOTIFICATION_SOUNDS' + }); + export const setLastN = (lastN) => ({ type : 'SET_LAST_N', diff --git a/app/src/components/AccessControl/LockDialog/ListLobbyPeer.js b/app/src/components/AccessControl/LockDialog/ListLobbyPeer.js index 94d04d2..9e73e82 100644 --- a/app/src/components/AccessControl/LockDialog/ListLobbyPeer.js +++ b/app/src/components/AccessControl/LockDialog/ListLobbyPeer.js @@ -7,72 +7,16 @@ import { withRoomContext } from '../../../RoomContext'; import { useIntl } from 'react-intl'; import ListItem from '@material-ui/core/ListItem'; import ListItemText from '@material-ui/core/ListItemText'; -import ListItemIcon from '@material-ui/core/ListItemIcon'; +import IconButton from '@material-ui/core/IconButton'; import ListItemAvatar from '@material-ui/core/ListItemAvatar'; import Avatar from '@material-ui/core/Avatar'; import EmptyAvatar from '../../../images/avatar-empty.jpeg'; import PromoteIcon from '@material-ui/icons/OpenInBrowser'; import Tooltip from '@material-ui/core/Tooltip'; -const styles = (theme) => +const styles = () => ({ root : - { - padding : theme.spacing(1), - width : '100%', - overflow : 'hidden', - cursor : 'auto', - display : 'flex' - }, - avatar : - { - borderRadius : '50%', - height : '2rem' - }, - peerInfo : - { - fontSize : '1rem', - border : 'none', - display : 'flex', - paddingLeft : theme.spacing(1), - flexGrow : 1, - alignItems : 'center' - }, - controls : - { - float : 'right', - display : 'flex', - 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 - }, - '&.disabled' : - { - pointerEvents : 'none', - backgroundColor : 'var(--media-control-botton-disabled)' - }, - '&.promote' : - { - backgroundColor : 'var(--media-control-botton-on)' - } - }, - ListItem : { alignItems : 'center' } @@ -83,6 +27,8 @@ const ListLobbyPeer = (props) => const { roomClient, peer, + promotionInProgress, + canPromote, classes } = props; @@ -92,7 +38,7 @@ const ListLobbyPeer = (props) => return ( defaultMessage : 'Click to let them in' })} > - { e.stopPropagation(); @@ -120,7 +69,7 @@ const ListLobbyPeer = (props) => }} > - +
); @@ -128,16 +77,22 @@ const ListLobbyPeer = (props) => ListLobbyPeer.propTypes = { - roomClient : PropTypes.any.isRequired, - advancedMode : PropTypes.bool, - peer : PropTypes.object.isRequired, - classes : PropTypes.object.isRequired + roomClient : PropTypes.any.isRequired, + advancedMode : PropTypes.bool, + peer : PropTypes.object.isRequired, + promotionInProgress : PropTypes.bool.isRequired, + canPromote : PropTypes.bool.isRequired, + classes : PropTypes.object.isRequired }; const mapStateToProps = (state, { id }) => { return { - peer : state.lobbyPeers[id] + peer : state.lobbyPeers[id], + promotionInProgress : state.room.lobbyPeersPromotionInProgress, + canPromote : + state.me.roles.some((role) => + state.room.permissionsFromRoles.PROMOTE_PEER.includes(role)) }; }; @@ -149,6 +104,10 @@ export default withRoomContext(connect( areStatesEqual : (next, prev) => { return ( + prev.room.permissionsFromRoles === next.room.permissionsFromRoles && + prev.room.lobbyPeersPromotionInProgress === + next.room.lobbyPeersPromotionInProgress && + prev.me.roles === next.me.roles && prev.lobbyPeers === next.lobbyPeers ); } diff --git a/app/src/components/AccessControl/LockDialog/LockDialog.js b/app/src/components/AccessControl/LockDialog/LockDialog.js index 57dd0ea..4d6cd24 100644 --- a/app/src/components/AccessControl/LockDialog/LockDialog.js +++ b/app/src/components/AccessControl/LockDialog/LockDialog.js @@ -15,14 +15,6 @@ import DialogActions from '@material-ui/core/DialogActions'; import DialogContent from '@material-ui/core/DialogContent'; import DialogContentText from '@material-ui/core/DialogContentText'; import Button from '@material-ui/core/Button'; -// import FormLabel from '@material-ui/core/FormLabel'; -// import FormControl from '@material-ui/core/FormControl'; -// import FormGroup from '@material-ui/core/FormGroup'; -// import FormControlLabel from '@material-ui/core/FormControlLabel'; -// import Checkbox from '@material-ui/core/Checkbox'; -// import InputLabel from '@material-ui/core/InputLabel'; -// import OutlinedInput from '@material-ui/core/OutlinedInput'; -// import Switch from '@material-ui/core/Switch'; import List from '@material-ui/core/List'; import ListSubheader from '@material-ui/core/ListSubheader'; import ListLobbyPeer from './ListLobbyPeer'; @@ -59,11 +51,11 @@ const styles = (theme) => }); const LockDialog = ({ - // roomClient, + roomClient, room, handleCloseLockDialog, - // handleAccessCode, lobbyPeers, + canPromote, classes }) => { @@ -71,7 +63,7 @@ const LockDialog = ({ handleCloseLockDialog({ lockDialogOpen: false })} + onClose={() => handleCloseLockDialog(false)} classes={{ paper : classes.dialogPaper }} @@ -82,54 +74,6 @@ const LockDialog = ({ defaultMessage='Lobby administration' /> - {/* - - Room lock - - - { - if (room.locked) - { - roomClient.unlockRoom(); - } - else - { - roomClient.lockRoom(); - } - }} - />} - label='Lock' - /> - TODO: access code - roomClient.setJoinByAccessCode(event.target.checked) - } - />} - label='Join by Access code' - /> - - handleAccessCode(event.target.value)} - > - - - - - - */} { lobbyPeers.length > 0 ? } - + + + + ); +}; + +ExtraVideo.propTypes = +{ + roomClient : PropTypes.object.isRequired, + extraVideoOpen : PropTypes.bool.isRequired, + webcamDevices : PropTypes.object, + handleCloseExtraVideo : PropTypes.func.isRequired, + classes : PropTypes.object.isRequired +}; + +const mapStateToProps = (state) => + ({ + webcamDevices : state.me.webcamDevices, + extraVideoOpen : state.room.extraVideoOpen + }); + +const mapDispatchToProps = { + handleCloseExtraVideo : roomActions.setExtraVideoOpen +}; + +export default withRoomContext(connect( + mapStateToProps, + mapDispatchToProps, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.me.webcamDevices === next.me.webcamDevices && + prev.room.extraVideoOpen === next.room.extraVideoOpen + ); + } + } +)(withStyles(styles)(ExtraVideo))); \ No newline at end of file diff --git a/app/src/components/Controls/MobileControls.js b/app/src/components/Controls/MobileControls.js deleted file mode 100644 index 48ab33e..0000000 --- a/app/src/components/Controls/MobileControls.js +++ /dev/null @@ -1,172 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { meProducersSelector } from '../Selectors'; -import { withStyles } from '@material-ui/core/styles'; -import * as appPropTypes from '../appPropTypes'; -import { withRoomContext } from '../../RoomContext'; -import Fab from '@material-ui/core/Fab'; -import Tooltip from '@material-ui/core/Tooltip'; -import MicIcon from '@material-ui/icons/Mic'; -import MicOffIcon from '@material-ui/icons/MicOff'; -import VideoIcon from '@material-ui/icons/Videocam'; -import VideoOffIcon from '@material-ui/icons/VideocamOff'; - -const styles = (theme) => - ({ - root : - { - position : 'fixed', - zIndex : 500, - display : 'flex', - flexDirection : 'row', - bottom : '0.5em', - left : '50%', - transform : 'translate(-50%, -0%)' - }, - fab : - { - margin : theme.spacing(1) - } - }); - -const MobileControls = (props) => -{ - const { - roomClient, - me, - micProducer, - webcamProducer, - classes - } = props; - - let micState; - - let micTip; - - if (!me.canSendMic) - { - micState = 'unsupported'; - micTip = 'Audio unsupported'; - } - else if (!micProducer) - { - micState = 'off'; - micTip = 'Activate audio'; - } - else if (!micProducer.locallyPaused && !micProducer.remotelyPaused) - { - micState = 'on'; - micTip = 'Mute audio'; - } - else - { - micState = 'muted'; - micTip = 'Unmute audio'; - } - - let webcamState; - - let webcamTip; - - if (!me.canSendWebcam) - { - webcamState = 'unsupported'; - webcamTip = 'Video unsupported'; - } - else if (webcamProducer) - { - webcamState = 'on'; - webcamTip = 'Stop video'; - } - else - { - webcamState = 'off'; - webcamTip = 'Start video'; - } - - return ( -
- -
- - { - if (micState === 'off') - roomClient.enableMic(); - else if (micState === 'on') - roomClient.muteMic(); - else - roomClient.unmuteMic(); - }} - > - { micState === 'on' ? - - : - - } - -
-
- -
- - { - webcamState === 'on' ? - roomClient.disableWebcam() : - roomClient.enableWebcam(); - }} - > - { webcamState === 'on' ? - - : - - } - -
-
-
- ); -}; - -MobileControls.propTypes = -{ - roomClient : PropTypes.any.isRequired, - me : appPropTypes.Me.isRequired, - micProducer : appPropTypes.Producer, - webcamProducer : appPropTypes.Producer, - classes : PropTypes.object.isRequired, - theme : PropTypes.object.isRequired -}; - -const mapStateToProps = (state) => - ({ - ...meProducersSelector(state), - me : state.me - }); - -export default withRoomContext(connect( - mapStateToProps, - null, - null, - { - areStatesEqual : (next, prev) => - { - return ( - prev.producers === next.producers && - prev.me === next.me - ); - } - } -)(withStyles(styles, { withTheme: true })(MobileControls))); \ No newline at end of file diff --git a/app/src/components/Controls/TopBar.js b/app/src/components/Controls/TopBar.js index 50a20c0..6422788 100644 --- a/app/src/components/Controls/TopBar.js +++ b/app/src/components/Controls/TopBar.js @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { useState } from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import { lobbyPeersKeySelector, - peersLengthSelector + peersLengthSelector, + raisedHandsSelector } from '../Selectors'; import * as appPropTypes from '../appPropTypes'; import { withRoomContext } from '../../RoomContext'; @@ -13,11 +14,16 @@ import * as toolareaActions from '../../actions/toolareaActions'; import { useIntl, FormattedMessage } from 'react-intl'; import AppBar from '@material-ui/core/AppBar'; import Toolbar from '@material-ui/core/Toolbar'; +import MenuItem from '@material-ui/core/MenuItem'; +import Menu from '@material-ui/core/Menu'; +import Popover from '@material-ui/core/Popover'; import Typography from '@material-ui/core/Typography'; import IconButton from '@material-ui/core/IconButton'; import MenuIcon from '@material-ui/icons/Menu'; import Avatar from '@material-ui/core/Avatar'; import Badge from '@material-ui/core/Badge'; +import Paper from '@material-ui/core/Paper'; +import ExtensionIcon from '@material-ui/icons/Extension'; import AccountCircle from '@material-ui/icons/AccountCircle'; import FullScreenIcon from '@material-ui/icons/Fullscreen'; import FullScreenExitIcon from '@material-ui/icons/FullscreenExit'; @@ -26,8 +32,10 @@ import SecurityIcon from '@material-ui/icons/Security'; import PeopleIcon from '@material-ui/icons/People'; import LockIcon from '@material-ui/icons/Lock'; import LockOpenIcon from '@material-ui/icons/LockOpen'; +import VideoCallIcon from '@material-ui/icons/VideoCall'; import Button from '@material-ui/core/Button'; import Tooltip from '@material-ui/core/Tooltip'; +import MoreIcon from '@material-ui/icons/MoreVert'; const styles = (theme) => ({ @@ -72,14 +80,34 @@ const styles = (theme) => display : 'block' } }, - actionButtons : - { - display : 'flex' + sectionDesktop : { + display : 'none', + [theme.breakpoints.up('md')] : { + display : 'flex' + } + }, + sectionMobile : { + display : 'flex', + [theme.breakpoints.up('md')] : { + display : 'none' + } }, actionButton : { - margin : theme.spacing(1), - padding : 0 + margin : theme.spacing(1, 0), + padding : theme.spacing(0, 1) + }, + disabledButton : + { + margin : theme.spacing(1, 0) + }, + green : + { + color : 'rgba(0, 153, 0, 1)' + }, + moreAction : + { + margin : theme.spacing(0.5, 0, 0.5, 1.5) } }); @@ -118,6 +146,38 @@ const TopBar = (props) => { const intl = useIntl(); + const [ mobileMoreAnchorEl, setMobileMoreAnchorEl ] = useState(null); + const [ anchorEl, setAnchorEl ] = useState(null); + const [ currentMenu, setCurrentMenu ] = useState(null); + + const handleExited = () => + { + setCurrentMenu(null); + }; + + const handleMobileMenuOpen = (event) => + { + setMobileMoreAnchorEl(event.currentTarget); + }; + + const handleMobileMenuClose = () => + { + setMobileMoreAnchorEl(null); + }; + + const handleMenuOpen = (event, menu) => + { + setAnchorEl(event.currentTarget); + setCurrentMenu(menu); + }; + + const handleMenuClose = () => + { + setAnchorEl(null); + + handleMobileMenuClose(); + }; + const { roomClient, room, @@ -131,13 +191,20 @@ const TopBar = (props) => fullscreen, onFullscreen, setSettingsOpen, + setExtraVideoOpen, setLockDialogOpen, toggleToolArea, openUsersTab, unread, + canProduceExtraVideo, + canLock, + canPromote, classes } = props; + const isMenuOpen = Boolean(anchorEl); + const isMobileMenuOpen = Boolean(mobileMoreAnchorEl); + const lockTooltip = room.locked ? intl.formatMessage({ id : 'tooltip.unLockRoom', @@ -172,170 +239,200 @@ const TopBar = (props) => }); return ( - - - toggleToolArea()} - > - - - - - { window.config && window.config.logo && Logo } - - { window.config && window.config.title ? window.config.title : 'Multiparty meeting' } - -
-
- { fullscreenEnabled && - - - { fullscreen ? - - : - - } - - - } - + + + toggleToolArea()} > + + + + { window.config.logo && Logo } + + { window.config.title ? window.config.title : 'Multiparty meeting' } + +
+
+ handleMenuOpen(event, 'moreActions')} + color='inherit' + > + + + { fullscreenEnabled && + + + { fullscreen ? + + : + + } + + + } + openUsersTab()} > - openUsersTab()} > - - - - - - + + + + + setSettingsOpen(!room.settingsOpen)} - > - - - - - - { - if (room.locked) - { - roomClient.unlockRoom(); - } - else - { - roomClient.lockRoom(); - } - }} - > - { room.locked ? - - : - - } - - - { lobbyPeers.length > 0 && - setLockDialogOpen(!room.lockDialogOpen)} - > - - - - - - } - { loginEnabled && - - - { - loggedIn ? roomClient.logout() : roomClient.login(); - }} + onClick={() => setSettingsOpen(!room.settingsOpen)} > - { myPicture ? - - : - - } + - } + + + + { + if (room.locked) + { + roomClient.unlockRoom(); + } + else + { + roomClient.lockRoom(); + } + }} + > + { room.locked ? + + : + + } + + + + { lobbyPeers.length > 0 && + + + setLockDialogOpen(!room.lockDialogOpen)} + > + + + + + + + } + { loginEnabled && + + + { + loggedIn ? roomClient.logout() : roomClient.login(); + }} + > + { myPicture ? + + : + + } + + + } +
+
+ + + +
-
- - + + + + { currentMenu === 'moreActions' && + + + { + handleMenuClose(); + setExtraVideoOpen(!room.extraVideoOpen); + }} + > + +

+ +

+
+
+ } +
+ + { loginEnabled && + + { + handleMenuClose(); + loggedIn ? roomClient.logout() : roomClient.login(); + }} + > + { myPicture ? + + : + + } + { loggedIn ? +

+ +

+ : +

+ +

+ } +
+ } + + { + handleMenuClose(); + + if (room.locked) + { + roomClient.unlockRoom(); + } + else + { + roomClient.lockRoom(); + } + }} + > + { room.locked ? + + : + + } + { room.locked ? +

+ +

+ : +

+ +

+ } +
+ + { + handleMenuClose(); + setSettingsOpen(!room.settingsOpen); + }} + > + +

+ +

+
+ { lobbyPeers.length > 0 && + + { + handleMenuClose(); + setLockDialogOpen(!room.lockDialogOpen); + }} + > + + + +

+ +

+
+ } + + { + handleMenuClose(); + openUsersTab(); + }} + > + + + +

+ +

+
+ { fullscreenEnabled && + + { + handleMenuClose(); + onFullscreen(); + }} + > + { fullscreen ? + + : + + } +

+ +

+
+ } + handleMenuOpen(event, 'moreActions')} + > + +

+ +

+
+
+ ); }; TopBar.propTypes = { - roomClient : PropTypes.object.isRequired, - room : appPropTypes.Room.isRequired, - peersLength : PropTypes.number, - lobbyPeers : PropTypes.array, - permanentTopBar : PropTypes.bool, - myPicture : PropTypes.string, - loggedIn : PropTypes.bool.isRequired, - loginEnabled : PropTypes.bool.isRequired, - fullscreenEnabled : PropTypes.bool, - fullscreen : PropTypes.bool, - onFullscreen : PropTypes.func.isRequired, - setToolbarsVisible : PropTypes.func.isRequired, - setSettingsOpen : PropTypes.func.isRequired, - setLockDialogOpen : PropTypes.func.isRequired, - toggleToolArea : PropTypes.func.isRequired, - openUsersTab : PropTypes.func.isRequired, - unread : PropTypes.number.isRequired, - classes : PropTypes.object.isRequired, - theme : PropTypes.object.isRequired + roomClient : PropTypes.object.isRequired, + room : appPropTypes.Room.isRequired, + peersLength : PropTypes.number, + lobbyPeers : PropTypes.array, + permanentTopBar : PropTypes.bool, + myPicture : PropTypes.string, + loggedIn : PropTypes.bool.isRequired, + loginEnabled : PropTypes.bool.isRequired, + fullscreenEnabled : PropTypes.bool, + fullscreen : PropTypes.bool, + onFullscreen : PropTypes.func.isRequired, + setToolbarsVisible : PropTypes.func.isRequired, + setSettingsOpen : PropTypes.func.isRequired, + setExtraVideoOpen : PropTypes.func.isRequired, + setLockDialogOpen : PropTypes.func.isRequired, + toggleToolArea : PropTypes.func.isRequired, + openUsersTab : PropTypes.func.isRequired, + unread : PropTypes.number.isRequired, + canProduceExtraVideo : PropTypes.bool.isRequired, + canLock : PropTypes.bool.isRequired, + canPromote : PropTypes.bool.isRequired, + classes : PropTypes.object.isRequired, + theme : PropTypes.object.isRequired }; const mapStateToProps = (state) => @@ -391,7 +715,16 @@ const mapStateToProps = (state) => loginEnabled : state.me.loginEnabled, myPicture : state.me.picture, unread : state.toolarea.unreadMessages + - state.toolarea.unreadFiles + state.toolarea.unreadFiles + raisedHandsSelector(state), + canProduceExtraVideo : + state.me.roles.some((role) => + state.room.permissionsFromRoles.EXTRA_VIDEO.includes(role)), + canLock : + state.me.roles.some((role) => + state.room.permissionsFromRoles.CHANGE_ROOM_LOCK.includes(role)), + canPromote : + state.me.roles.some((role) => + state.room.permissionsFromRoles.PROMOTE_PEER.includes(role)) }); const mapDispatchToProps = (dispatch) => @@ -402,11 +735,15 @@ const mapDispatchToProps = (dispatch) => }, setSettingsOpen : (settingsOpen) => { - dispatch(roomActions.setSettingsOpen({ settingsOpen })); + dispatch(roomActions.setSettingsOpen(settingsOpen)); + }, + setExtraVideoOpen : (extraVideoOpen) => + { + dispatch(roomActions.setExtraVideoOpen(extraVideoOpen)); }, setLockDialogOpen : (lockDialogOpen) => { - dispatch(roomActions.setLockDialogOpen({ lockDialogOpen })); + dispatch(roomActions.setLockDialogOpen(lockDialogOpen)); }, toggleToolArea : () => { @@ -434,9 +771,10 @@ export default withRoomContext(connect( prev.me.loggedIn === next.me.loggedIn && prev.me.loginEnabled === next.me.loginEnabled && prev.me.picture === next.me.picture && + prev.me.roles === next.me.roles && prev.toolarea.unreadMessages === next.toolarea.unreadMessages && prev.toolarea.unreadFiles === next.toolarea.unreadFiles ); } } -)(withStyles(styles, { withTheme: true })(TopBar))); \ No newline at end of file +)(withStyles(styles, { withTheme: true })(TopBar))); diff --git a/app/src/components/JoinDialog.js b/app/src/components/JoinDialog.js index 814a18d..3d2596f 100644 --- a/app/src/components/JoinDialog.js +++ b/app/src/components/JoinDialog.js @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { connect } from 'react-redux'; import { withStyles } from '@material-ui/core/styles'; import { withRoomContext } from '../RoomContext'; +import classnames from 'classnames'; import isElectron from 'is-electron'; import * as settingsActions from '../actions/settingsActions'; import PropTypes from 'prop-types'; @@ -82,6 +83,10 @@ const styles = (theme) => green : { color : 'rgba(0, 153, 0, 1)' + }, + red : + { + color : 'rgba(153, 0, 0, 1)' } }); @@ -103,7 +108,7 @@ const DialogTitle = withStyles(styles)((props) => }; }, []); - const { children, classes, myPicture, onLogin, ...other } = props; + const { children, classes, myPicture, onLogin, loggedIn, ...other } = props; const handleTooltipClose = () => { @@ -115,19 +120,27 @@ const DialogTitle = withStyles(styles)((props) => setOpen(true); }; + const loginTooltip = loggedIn ? + intl.formatMessage({ + id : 'tooltip.logout', + defaultMessage : 'Log out' + }) + : + intl.formatMessage({ + id : 'tooltip.login', + defaultMessage : 'Log in' + }); + return ( - { window.config && window.config.logo && Logo } + { window.config.logo && Logo } {children} - { window.config && window.config.loginEnabled && + { window.config.loginEnabled && { myPicture ? : - + } @@ -207,12 +222,13 @@ const JoinDialog = ({ > + onLogin={() => { - loggedIn ? roomClient.logout() : roomClient.login(); + loggedIn ? roomClient.logout() : roomClient.login(roomId); }} + loggedIn={loggedIn} > - { window.config && window.config.title ? window.config.title : 'Multiparty meeting' } + { window.config.title ? window.config.title : 'Multiparty meeting' }
@@ -269,6 +285,16 @@ const JoinDialog = ({ }} fullWidth /> + {!room.inLobby && room.overRoomLimit && + + + + } @@ -307,6 +333,7 @@ const JoinDialog = ({ className={classes.green} gutterBottom variant='h6' + style={{ fontWeight: '600' }} align='center' > { room.signInRequired ? - + : - + return ( + diff --git a/app/src/components/MeetingDrawer/Chat/ChatInput.js b/app/src/components/MeetingDrawer/Chat/ChatInput.js index 5be44e9..480bb26 100644 --- a/app/src/components/MeetingDrawer/Chat/ChatInput.js +++ b/app/src/components/MeetingDrawer/Chat/ChatInput.js @@ -54,6 +54,7 @@ const ChatInput = (props) => roomClient, displayName, picture, + canChat, classes } = props; @@ -66,6 +67,7 @@ const ChatInput = (props) => defaultMessage : 'Enter chat message...' })} value={message || ''} + disabled={!canChat} onChange={handleChange} onKeyPress={(ev) => { @@ -89,6 +91,7 @@ const ChatInput = (props) => color='primary' className={classes.iconButton} aria-label='Send' + disabled={!canChat} onClick={() => { if (message && message !== '') @@ -112,13 +115,17 @@ ChatInput.propTypes = roomClient : PropTypes.object.isRequired, displayName : PropTypes.string, picture : PropTypes.string, + canChat : PropTypes.bool.isRequired, classes : PropTypes.object.isRequired }; const mapStateToProps = (state) => ({ displayName : state.settings.displayName, - picture : state.me.picture + picture : state.me.picture, + canChat : + state.me.roles.some((role) => + state.room.permissionsFromRoles.SEND_CHAT.includes(role)) }); export default withRoomContext( @@ -130,6 +137,8 @@ export default withRoomContext( areStatesEqual : (next, prev) => { return ( + prev.room.permissionsFromRoles === next.room.permissionsFromRoles && + prev.me.roles === next.me.roles && prev.settings.displayName === next.settings.displayName && prev.me.picture === next.me.picture ); diff --git a/app/src/components/MeetingDrawer/Chat/ChatModerator.js b/app/src/components/MeetingDrawer/Chat/ChatModerator.js new file mode 100644 index 0000000..a35675b --- /dev/null +++ b/app/src/components/MeetingDrawer/Chat/ChatModerator.js @@ -0,0 +1,100 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { withRoomContext } from '../../../RoomContext'; +import { withStyles } from '@material-ui/core/styles'; +import { useIntl, FormattedMessage } from 'react-intl'; +import Button from '@material-ui/core/Button'; + +const styles = (theme) => + ({ + root : + { + display : 'flex', + padding : theme.spacing(1), + boxShadow : '0 2px 5px 2px rgba(0, 0, 0, 0.2)', + backgroundColor : 'rgba(255, 255, 255, 1)' + }, + listheader : + { + padding : theme.spacing(1), + fontWeight : 'bolder' + }, + actionButton : + { + marginLeft : 'auto' + } + }); + +const ChatModerator = (props) => +{ + const intl = useIntl(); + + const { + roomClient, + isChatModerator, + room, + classes + } = props; + + if (!isChatModerator) + return null; + + return ( +
    +
  • + +
  • + +
+ ); +}; + +ChatModerator.propTypes = +{ + roomClient : PropTypes.any.isRequired, + isChatModerator : PropTypes.bool, + room : PropTypes.object, + classes : PropTypes.object.isRequired +}; + +const mapStateToProps = (state) => + ({ + isChatModerator : + state.me.roles.some((role) => + state.room.permissionsFromRoles.MODERATE_CHAT.includes(role)), + room : state.room + }); + +export default withRoomContext(connect( + mapStateToProps, + null, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.room === next.room && + prev.me === next.me + ); + } + } +)(withStyles(styles)(ChatModerator))); \ No newline at end of file diff --git a/app/src/components/MeetingDrawer/Chat/Message.js b/app/src/components/MeetingDrawer/Chat/Message.js index a60c245..b770689 100644 --- a/app/src/components/MeetingDrawer/Chat/Message.js +++ b/app/src/components/MeetingDrawer/Chat/Message.js @@ -6,6 +6,7 @@ import DOMPurify from 'dompurify'; import marked from 'marked'; import Paper from '@material-ui/core/Paper'; import Typography from '@material-ui/core/Typography'; +import { useIntl } from 'react-intl'; const linkRenderer = new marked.Renderer(); @@ -55,6 +56,8 @@ const styles = (theme) => const Message = (props) => { + const intl = useIntl(); + const { self, picture, @@ -88,7 +91,16 @@ const Message = (props) => } ) }} /> - {self ? 'Me' : name} - {time} + + { self ? + intl.formatMessage({ + id : 'room.me', + defaultMessage : 'Me' + }) + : + name + } - {time} +
); diff --git a/app/src/components/MeetingDrawer/FileSharing/FileSharing.js b/app/src/components/MeetingDrawer/FileSharing/FileSharing.js index 8713134..78ba569 100644 --- a/app/src/components/MeetingDrawer/FileSharing/FileSharing.js +++ b/app/src/components/MeetingDrawer/FileSharing/FileSharing.js @@ -5,6 +5,7 @@ import { withStyles } from '@material-ui/core/styles'; import { withRoomContext } from '../../../RoomContext'; import { useIntl } from 'react-intl'; import FileList from './FileList'; +import FileSharingModerator from './FileSharingModerator'; import Paper from '@material-ui/core/Paper'; import Button from '@material-ui/core/Button'; @@ -24,6 +25,10 @@ const styles = (theme) => button : { margin : theme.spacing(1) + }, + shareButtonsWrapper : + { + display : 'flex' } }); @@ -35,12 +40,14 @@ const FileSharing = (props) => { if (event.target.files.length > 0) { - props.roomClient.shareFiles(event.target.files); + await props.roomClient.shareFiles(event.target.files); } }; const { canShareFiles, + browser, + canShare, classes } = props; @@ -55,25 +62,61 @@ const FileSharing = (props) => defaultMessage : 'File sharing not supported' }); + const buttonGalleryDescription = canShareFiles ? + intl.formatMessage({ + id : 'label.shareGalleryFile', + defaultMessage : 'Share image' + }) + : + intl.formatMessage({ + id : 'label.fileSharingUnsupported', + defaultMessage : 'File sharing not supported' + }); + return ( - - - + +
+ (e.target.value = null)} + id='share-files-button' + /> + + + { + (browser.platform === 'mobile') && canShareFiles && canShare && + } +
); @@ -81,8 +124,10 @@ const FileSharing = (props) => FileSharing.propTypes = { roomClient : PropTypes.any.isRequired, + browser : PropTypes.object.isRequired, canShareFiles : PropTypes.bool.isRequired, tabOpen : PropTypes.bool.isRequired, + canShare : PropTypes.bool.isRequired, classes : PropTypes.object.isRequired }; @@ -90,10 +135,28 @@ const mapStateToProps = (state) => { return { canShareFiles : state.me.canShareFiles, - tabOpen : state.toolarea.currentToolTab === 'files' + browser : state.me.browser, + tabOpen : state.toolarea.currentToolTab === 'files', + canShare : + state.me.roles.some((role) => + state.room.permissionsFromRoles.SHARE_FILE.includes(role)) }; }; export default withRoomContext(connect( - mapStateToProps + mapStateToProps, + null, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.room.permissionsFromRoles === next.room.permissionsFromRoles && + prev.me.browser === next.me.browser && + prev.me.roles === next.me.roles && + prev.me.canShareFiles === next.me.canShareFiles && + prev.toolarea.currentToolTab === next.toolarea.currentToolTab + ); + } + } )(withStyles(styles)(FileSharing))); diff --git a/app/src/components/MeetingDrawer/FileSharing/FileSharingModerator.js b/app/src/components/MeetingDrawer/FileSharing/FileSharingModerator.js new file mode 100644 index 0000000..e38e54c --- /dev/null +++ b/app/src/components/MeetingDrawer/FileSharing/FileSharingModerator.js @@ -0,0 +1,100 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { withRoomContext } from '../../../RoomContext'; +import { withStyles } from '@material-ui/core/styles'; +import { useIntl, FormattedMessage } from 'react-intl'; +import Button from '@material-ui/core/Button'; + +const styles = (theme) => + ({ + root : + { + display : 'flex', + padding : theme.spacing(1), + boxShadow : '0 2px 5px 2px rgba(0, 0, 0, 0.2)', + backgroundColor : 'rgba(255, 255, 255, 1)' + }, + listheader : + { + padding : theme.spacing(1), + fontWeight : 'bolder' + }, + actionButton : + { + marginLeft : 'auto' + } + }); + +const FileSharingModerator = (props) => +{ + const intl = useIntl(); + + const { + roomClient, + isFileSharingModerator, + room, + classes + } = props; + + if (!isFileSharingModerator) + return null; + + return ( +
    +
  • + +
  • + +
+ ); +}; + +FileSharingModerator.propTypes = +{ + roomClient : PropTypes.any.isRequired, + isFileSharingModerator : PropTypes.bool, + room : PropTypes.object, + classes : PropTypes.object.isRequired +}; + +const mapStateToProps = (state) => + ({ + isFileSharingModerator : + state.me.roles.some((role) => + state.room.permissionsFromRoles.MODERATE_FILES.includes(role)), + room : state.room + }); + +export default withRoomContext(connect( + mapStateToProps, + null, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.room === next.room && + prev.me === next.me + ); + } + } +)(withStyles(styles)(FileSharingModerator))); \ No newline at end of file diff --git a/app/src/components/MeetingDrawer/MeetingDrawer.js b/app/src/components/MeetingDrawer/MeetingDrawer.js index 0f81cde..4ac0831 100644 --- a/app/src/components/MeetingDrawer/MeetingDrawer.js +++ b/app/src/components/MeetingDrawer/MeetingDrawer.js @@ -1,5 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; +import { raisedHandsSelector } from '../Selectors'; import PropTypes from 'prop-types'; import { withStyles } from '@material-ui/core/styles'; import * as toolareaActions from '../../actions/toolareaActions'; @@ -51,6 +52,7 @@ const MeetingDrawer = (props) => currentToolTab, unreadMessages, unreadFiles, + raisedHands, closeDrawer, setToolTab, classes, @@ -93,10 +95,14 @@ const MeetingDrawer = (props) => } /> + {intl.formatMessage({ + id : 'label.participants', + defaultMessage : 'Participants' + })} + + } /> @@ -116,16 +122,21 @@ MeetingDrawer.propTypes = setToolTab : PropTypes.func.isRequired, unreadMessages : PropTypes.number.isRequired, unreadFiles : PropTypes.number.isRequired, + raisedHands : PropTypes.number.isRequired, closeDrawer : PropTypes.func.isRequired, classes : PropTypes.object.isRequired, theme : PropTypes.object.isRequired }; -const mapStateToProps = (state) => ({ - currentToolTab : state.toolarea.currentToolTab, - unreadMessages : state.toolarea.unreadMessages, - unreadFiles : state.toolarea.unreadFiles -}); +const mapStateToProps = (state) => +{ + return { + currentToolTab : state.toolarea.currentToolTab, + unreadMessages : state.toolarea.unreadMessages, + unreadFiles : state.toolarea.unreadFiles, + raisedHands : raisedHandsSelector(state) + }; +}; const mapDispatchToProps = { setToolTab : toolareaActions.setToolTab @@ -141,7 +152,8 @@ export default connect( return ( prev.toolarea.currentToolTab === next.toolarea.currentToolTab && prev.toolarea.unreadMessages === next.toolarea.unreadMessages && - prev.toolarea.unreadFiles === next.toolarea.unreadFiles + prev.toolarea.unreadFiles === next.toolarea.unreadFiles && + prev.peers === next.peers ); } } diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListMe.js b/app/src/components/MeetingDrawer/ParticipantList/ListMe.js index 304cbb9..d230db2 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ListMe.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ListMe.js @@ -1,79 +1,50 @@ import React from 'react'; import { connect } from 'react-redux'; import { withStyles } from '@material-ui/core/styles'; -import classnames from 'classnames'; +import { withRoomContext } from '../../../RoomContext'; import PropTypes from 'prop-types'; import * as appPropTypes from '../../appPropTypes'; +import { useIntl } from 'react-intl'; +import IconButton from '@material-ui/core/IconButton'; +import Tooltip from '@material-ui/core/Tooltip'; +import PanIcon from '@material-ui/icons/PanTool'; import EmptyAvatar from '../../../images/avatar-empty.jpeg'; -import HandIcon from '../../../images/icon-hand-white.svg'; const styles = (theme) => ({ root : { - padding : theme.spacing(1), width : '100%', overflow : 'hidden', cursor : 'auto', display : 'flex' }, - listPeer : - { - display : 'flex' - }, avatar : { borderRadius : '50%', - height : '2rem' + height : '2rem', + marginTop : theme.spacing(1) }, peerInfo : { fontSize : '1rem', - border : 'none', display : 'flex', paddingLeft : theme.spacing(1), flexGrow : 1, alignItems : 'center' }, - indicators : + green : { - left : 0, - top : 0, - display : 'flex', - flexDirection : 'row', - justifyContent : 'flex-start', - alignItems : 'center', - transition : 'opacity 0.3s' - }, - icon : - { - flex : '0 0 auto', - margin : '0.3rem', - borderRadius : 2, - backgroundPosition : 'center', - backgroundSize : '75%', - backgroundRepeat : 'no-repeat', - backgroundColor : 'rgba(0, 0, 0, 0.5)', - 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 - }, - '&.raise-hand' : - { - backgroundImage : `url(${HandIcon})`, - opacity : 1 - } + color : 'rgba(0, 153, 0, 1)' } }); const ListMe = (props) => { + const intl = useIntl(); + const { + roomClient, me, settings, classes @@ -82,29 +53,47 @@ const ListMe = (props) => const picture = me.picture || EmptyAvatar; return ( -
  • -
    - My avatar +
    + My avatar -
    - {settings.displayName} -
    - -
    - { me.raisedHand && -
    - } -
    +
    + {settings.displayName}
    -
  • + + + { + e.stopPropagation(); + + roomClient.setRaisedHand(!me.raisedHand); + }} + > + + + +
    ); }; ListMe.propTypes = { - me : appPropTypes.Me.isRequired, - settings : PropTypes.object.isRequired, - classes : PropTypes.object.isRequired + roomClient : PropTypes.object.isRequired, + me : appPropTypes.Me.isRequired, + settings : PropTypes.object.isRequired, + classes : PropTypes.object.isRequired }; const mapStateToProps = (state) => ({ @@ -112,7 +101,7 @@ const mapStateToProps = (state) => ({ settings : state.settings }); -export default connect( +export default withRoomContext(connect( mapStateToProps, null, null, @@ -125,4 +114,4 @@ export default connect( ); } } -)(withStyles(styles)(ListMe)); +)(withStyles(styles)(ListMe))); diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListModerator.js b/app/src/components/MeetingDrawer/ParticipantList/ListModerator.js index 1c711a1..c10506a 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ListModerator.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ListModerator.js @@ -10,14 +10,7 @@ const styles = (theme) => ({ root : { - padding : theme.spacing(1), - width : '100%', - overflow : 'hidden', - cursor : 'auto', - display : 'flex' - }, - actionButtons : - { + padding : theme.spacing(1), display : 'flex' }, divider : @@ -43,7 +36,6 @@ const ListModerator = (props) => id : 'room.muteAll', defaultMessage : 'Mute all' })} - className={classes.actionButton} variant='contained' color='secondary' disabled={room.muteAllInProgress} @@ -60,7 +52,6 @@ const ListModerator = (props) => id : 'room.stopAllVideo', defaultMessage : 'Stop all video' })} - className={classes.actionButton} variant='contained' color='secondary' disabled={room.stopAllVideoInProgress} @@ -77,7 +68,6 @@ const ListModerator = (props) => id : 'room.closeMeeting', defaultMessage : 'Close meeting' })} - className={classes.actionButton} variant='contained' color='secondary' disabled={room.closeMeetingInProgress} diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js index 7fd0383..1aa70a1 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js @@ -3,42 +3,39 @@ import { connect } from 'react-redux'; import { makePeerConsumerSelector } from '../../Selectors'; import { withStyles } from '@material-ui/core/styles'; import PropTypes from 'prop-types'; -import classnames from 'classnames'; import * as appPropTypes from '../../appPropTypes'; import { withRoomContext } from '../../../RoomContext'; import { useIntl } from 'react-intl'; import IconButton from '@material-ui/core/IconButton'; -import MicIcon from '@material-ui/icons/Mic'; -import MicOffIcon from '@material-ui/icons/MicOff'; +import Tooltip from '@material-ui/core/Tooltip'; +import VideocamIcon from '@material-ui/icons/Videocam'; +import VideocamOffIcon from '@material-ui/icons/VideocamOff'; +import VolumeUpIcon from '@material-ui/icons/VolumeUp'; +import VolumeOffIcon from '@material-ui/icons/VolumeOff'; 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'; +import PanIcon from '@material-ui/icons/PanTool'; const styles = (theme) => ({ root : { - padding : theme.spacing(1), width : '100%', overflow : 'hidden', cursor : 'auto', display : 'flex' }, - listPeer : - { - display : 'flex' - }, avatar : { borderRadius : '50%', - height : '2rem' + height : '2rem', + marginTop : theme.spacing(1) }, peerInfo : { fontSize : '1rem', - border : 'none', display : 'flex', paddingLeft : theme.spacing(1), flexGrow : 1, @@ -46,52 +43,12 @@ const styles = (theme) => }, indicators : { - left : 0, - top : 0, - display : 'flex', - flexDirection : 'row', - justifyContent : 'flex-start', - alignItems : 'center', - transition : 'opacity 0.3s' + display : 'flex', + padding : theme.spacing(1.5) }, - icon : + green : { - flex : '0 0 auto', - margin : '0.3rem', - borderRadius : 2, - backgroundPosition : 'center', - backgroundSize : '75%', - backgroundRepeat : 'no-repeat', - backgroundColor : 'rgba(0, 0, 0, 0.5)', - 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 - }, - '&.on' : - { - opacity : 1 - }, - '&.off' : - { - opacity : 0.2 - }, - '&.raise-hand' : - { - backgroundImage : `url(${HandIcon})` - } - }, - controls : - { - float : 'right', - display : 'flex', - flexDirection : 'row', - justifyContent : 'flex-start', - alignItems : 'center' + color : 'rgba(0, 153, 0, 1)' } }); @@ -104,11 +61,18 @@ const ListPeer = (props) => isModerator, peer, micConsumer, + webcamConsumer, screenConsumer, children, classes } = props; + const webcamEnabled = ( + Boolean(webcamConsumer) && + !webcamConsumer.locallyPaused && + !webcamConsumer.remotelyPaused + ); + const micEnabled = ( Boolean(micConsumer) && !micConsumer.locallyPaused && @@ -131,21 +95,18 @@ const ListPeer = (props) => {peer.displayName}
    - { peer.raiseHandState && -
    + { peer.raisedHand && + }
    - {children} -
    - { screenConsumer && + { screenConsumer && + })} color={screenVisible ? 'primary' : 'secondary'} disabled={peer.peerScreenInProgress} - onClick={() => + onClick={(e) => { + e.stopPropagation(); + screenVisible ? roomClient.modifyPeerConsumer(peer.id, 'screen', true) : roomClient.modifyPeerConsumer(peer.id, 'screen', false); @@ -166,7 +129,45 @@ const ListPeer = (props) => } - } + + } + + + { + e.stopPropagation(); + + webcamEnabled ? + roomClient.modifyPeerConsumer(peer.id, 'webcam', true) : + roomClient.modifyPeerConsumer(peer.id, 'webcam', false); + }} + > + { webcamEnabled ? + + : + + } + + + })} color={micEnabled ? 'primary' : 'secondary'} disabled={peer.peerAudioInProgress} - onClick={() => + onClick={(e) => { + e.stopPropagation(); + micEnabled ? roomClient.modifyPeerConsumer(peer.id, 'mic', true) : roomClient.modifyPeerConsumer(peer.id, 'mic', false); }} > { micEnabled ? - + : - + } - { isModerator && + + { isModerator && + + color='secondary' + onClick={(e) => { + e.stopPropagation(); + roomClient.kickPeer(peer.id); }} > - } -
    + + } + {children}
    ); }; diff --git a/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js b/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js index dbf5491..af35dbd 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js @@ -2,7 +2,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { passivePeersSelector, - spotlightPeersSelector + spotlightSortedPeersSelector } from '../../Selectors'; import classNames from 'classnames'; import { withStyles } from '@material-ui/core/styles'; @@ -13,7 +13,6 @@ 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) => ({ @@ -32,12 +31,10 @@ const styles = (theme) => }, listheader : { - padding : theme.spacing(1), fontWeight : 'bolder' }, listItem : { - padding : theme.spacing(1), width : '100%', overflow : 'hidden', cursor : 'pointer', @@ -114,16 +111,20 @@ class ParticipantList extends React.PureComponent defaultMessage='Participants in Spotlight' /> - { spotlightPeers.map((peerId) => ( + { spotlightPeers.map((peer) => (
  • roomClient.setSelectedPeer(peerId)} + onClick={() => roomClient.setSelectedPeer(peer.id)} > - - + +
  • ))} @@ -135,16 +136,16 @@ class ParticipantList extends React.PureComponent defaultMessage='Passive Participants' /> - { passivePeers.map((peerId) => ( + { passivePeers.map((peer) => (
  • roomClient.setSelectedPeer(peerId)} + onClick={() => roomClient.setSelectedPeer(peer.id)} > @@ -170,11 +171,12 @@ ParticipantList.propTypes = const mapStateToProps = (state) => { return { - isModerator : state.me.roles.includes(userRoles.MODERATOR) || - state.me.roles.includes(userRoles.ADMIN), + isModerator : + state.me.roles.some((role) => + state.room.permissionsFromRoles.MODERATE_ROOM.includes(role)), passivePeers : passivePeersSelector(state), selectedPeerId : state.room.selectedPeerId, - spotlightPeers : spotlightPeersSelector(state) + spotlightPeers : spotlightSortedPeersSelector(state) }; }; @@ -186,6 +188,7 @@ const ParticipantListContainer = withRoomContext(connect( areStatesEqual : (next, prev) => { return ( + prev.room.permissionsFromRoles === next.room.permissionsFromRoles && prev.me.roles === next.me.roles && prev.peers === next.peers && prev.room.spotlights === next.room.spotlights && diff --git a/app/src/components/MeetingViews/Filmstrip.js b/app/src/components/MeetingViews/Filmstrip.js index 2ed11c6..d1cfba6 100644 --- a/app/src/components/MeetingViews/Filmstrip.js +++ b/app/src/components/MeetingViews/Filmstrip.js @@ -12,6 +12,12 @@ import Peer from '../Containers/Peer'; import SpeakerPeer from '../Containers/SpeakerPeer'; import Grid from '@material-ui/core/Grid'; +const RATIO = 1.334; +const PADDING_V = 40; +const PADDING_H = 0; +const FILMSTRING_PADDING_V = 10; +const FILMSTRING_PADDING_H = 0; + const styles = () => ({ root : @@ -20,24 +26,22 @@ const styles = () => width : '100%', display : 'grid', gridTemplateColumns : '1fr', - gridTemplateRows : '1.6fr minmax(0, 0.4fr)' + gridTemplateRows : '1fr 0.25fr' }, speaker : { - gridArea : '1 / 1 / 2 / 2', + gridArea : '1 / 1 / 1 / 1', display : 'flex', justifyContent : 'center', - alignItems : 'center', - paddingTop : 40 + alignItems : 'center' }, filmStrip : { - gridArea : '2 / 1 / 3 / 2' + gridArea : '2 / 1 / 2 / 1' }, filmItem : { display : 'flex', - marginLeft : '6px', border : 'var(--peer-border)', '&.selected' : { @@ -45,8 +49,18 @@ const styles = () => }, '&.active' : { - opacity : '0.6' + borderColor : 'var(--selected-peer-border-color)' } + }, + hiddenToolBar : + { + paddingTop : 0, + transition : 'padding .5s' + }, + showingToolBar : + { + paddingTop : 60, + transition : 'padding .5s' } }); @@ -58,6 +72,8 @@ class Filmstrip extends React.PureComponent this.resizeTimeout = null; + this.rootContainer = React.createRef(); + this.activePeerContainer = React.createRef(); this.filmStripContainer = React.createRef(); @@ -105,24 +121,38 @@ class Filmstrip extends React.PureComponent { const newState = {}; + const root = this.rootContainer.current; + + if (!root) + return; + + const availableWidth = root.clientWidth; + // Grid is: + // 4/5 speaker + // 1/5 filmstrip + const availableSpeakerHeight = (root.clientHeight * 0.8) - + (this.props.toolbarsVisible || this.props.permanentTopBar ? PADDING_V : PADDING_H); + + const availableFilmstripHeight = root.clientHeight * 0.2; + const speaker = this.activePeerContainer.current; if (speaker) { - let speakerWidth = (speaker.clientWidth - 100); + let speakerWidth = (availableWidth - PADDING_H); - let speakerHeight = (speakerWidth / 4) * 3; + let speakerHeight = speakerWidth / RATIO; if (this.isSharingCamera(this.getActivePeerId())) { speakerWidth /= 2; - speakerHeight = (speakerWidth / 4) * 3; + speakerHeight = speakerWidth / RATIO; } - if (speakerHeight > (speaker.clientHeight - 60)) + if (speakerHeight > (availableSpeakerHeight - PADDING_V)) { - speakerHeight = (speaker.clientHeight - 60); - speakerWidth = (speakerHeight / 3) * 4; + speakerHeight = (availableSpeakerHeight - PADDING_V); + speakerWidth = speakerHeight * RATIO; } newState.speakerWidth = speakerWidth; @@ -133,14 +163,18 @@ class Filmstrip extends React.PureComponent if (filmStrip) { - let filmStripHeight = filmStrip.clientHeight - 10; + let filmStripHeight = availableFilmstripHeight - FILMSTRING_PADDING_V; - let filmStripWidth = (filmStripHeight / 3) * 4; + let filmStripWidth = filmStripHeight * RATIO; - if (filmStripWidth * this.props.boxes > (filmStrip.clientWidth - 50)) + if ( + (filmStripWidth * this.props.boxes) > + (availableWidth - FILMSTRING_PADDING_H) + ) { - filmStripWidth = (filmStrip.clientWidth - 50) / this.props.boxes; - filmStripHeight = (filmStripWidth / 4) * 3; + filmStripWidth = (availableWidth - FILMSTRING_PADDING_H) / + this.props.boxes; + filmStripHeight = filmStripWidth / RATIO; } newState.filmStripWidth = filmStripWidth; @@ -172,27 +206,21 @@ class Filmstrip extends React.PureComponent window.removeEventListener('resize', this.updateDimensions); } - componentWillUpdate(nextProps) - { - if (nextProps !== this.props) - { - if ( - nextProps.activeSpeakerId != null && - nextProps.activeSpeakerId !== this.props.myId - ) - { - // eslint-disable-next-line react/no-did-update-set-state - this.setState({ - lastSpeaker : nextProps.activeSpeakerId - }); - } - } - } - componentDidUpdate(prevProps) { if (prevProps !== this.props) { + if ( + this.props.activeSpeakerId != null && + this.props.activeSpeakerId !== this.props.myId + ) + { + // eslint-disable-next-line react/no-did-update-set-state + this.setState({ + lastSpeaker : this.props.activeSpeakerId + }); + } + this.updateDimensions(); } } @@ -205,6 +233,8 @@ class Filmstrip extends React.PureComponent myId, advancedMode, spotlights, + toolbarsVisible, + permanentTopBar, classes } = this.props; @@ -223,7 +253,14 @@ class Filmstrip extends React.PureComponent }; return ( -
    +
    { peers[activePeerId] &&
    @@ -268,7 +305,7 @@ class Filmstrip extends React.PureComponent advancedMode={advancedMode} id={peerId} style={peerStyle} - smallButtons + smallContainer />
    @@ -296,6 +333,8 @@ Filmstrip.propTypes = { selectedPeerId : PropTypes.string, spotlights : PropTypes.array.isRequired, boxes : PropTypes.number, + toolbarsVisible : PropTypes.bool.isRequired, + permanentTopBar : PropTypes.bool, classes : PropTypes.object.isRequired }; @@ -308,7 +347,9 @@ const mapStateToProps = (state) => consumers : state.consumers, myId : state.me.id, spotlights : state.room.spotlights, - boxes : videoBoxesSelector(state) + boxes : videoBoxesSelector(state), + toolbarsVisible : state.room.toolbarsVisible, + permanentTopBar : state.settings.permanentTopBar }; }; @@ -322,6 +363,8 @@ export default withRoomContext(connect( return ( prev.room.activeSpeakerId === next.room.activeSpeakerId && prev.room.selectedPeerId === next.room.selectedPeerId && + prev.room.toolbarsVisible === next.room.toolbarsVisible && + prev.settings.permanentTopBar === next.settings.permanentTopBar && prev.peers === next.peers && prev.consumers === next.consumers && prev.room.spotlights === next.room.spotlights && diff --git a/app/src/components/PeerAudio/AudioPeers.js b/app/src/components/PeerAudio/AudioPeers.js index 671f4a2..6e9921e 100644 --- a/app/src/components/PeerAudio/AudioPeers.js +++ b/app/src/components/PeerAudio/AudioPeers.js @@ -1,13 +1,14 @@ import React from 'react'; import { connect } from 'react-redux'; -import { micConsumerSelector } from '../Selectors'; +import { passiveMicConsumerSelector } from '../Selectors'; import PropTypes from 'prop-types'; import PeerAudio from './PeerAudio'; const AudioPeers = (props) => { const { - micConsumers + micConsumers, + audioOutputDevice } = props; return ( @@ -19,6 +20,7 @@ const AudioPeers = (props) => ); }) @@ -29,12 +31,14 @@ const AudioPeers = (props) => AudioPeers.propTypes = { - micConsumers : PropTypes.array + micConsumers : PropTypes.array, + audioOutputDevice : PropTypes.string }; const mapStateToProps = (state) => ({ - micConsumers : micConsumerSelector(state) + micConsumers : passiveMicConsumerSelector(state), + audioOutputDevice : state.settings.selectedAudioOutputDevice }); const AudioPeersContainer = connect( @@ -45,7 +49,10 @@ const AudioPeersContainer = connect( areStatesEqual : (next, prev) => { return ( - prev.consumers === next.consumers + prev.consumers === next.consumers && + prev.room.spotlights === next.room.spotlights && + prev.settings.selectedAudioOutputDevice === + next.settings.selectedAudioOutputDevice ); } } diff --git a/app/src/components/PeerAudio/PeerAudio.js b/app/src/components/PeerAudio/PeerAudio.js index 38d7faf..b4be0f7 100644 --- a/app/src/components/PeerAudio/PeerAudio.js +++ b/app/src/components/PeerAudio/PeerAudio.js @@ -10,6 +10,7 @@ export default class PeerAudio extends React.PureComponent // Latest received audio track. // @type {MediaStreamTrack} this._audioTrack = null; + this._audioOutputDevice = null; } render() @@ -24,17 +25,21 @@ export default class PeerAudio extends React.PureComponent componentDidMount() { - const { audioTrack } = this.props; + const { audioTrack, audioOutputDevice } = this.props; this._setTrack(audioTrack); + this._setOutputDevice(audioOutputDevice); } - // eslint-disable-next-line camelcase - UNSAFE_componentWillReceiveProps(nextProps) + componentDidUpdate(prevProps) { - const { audioTrack } = nextProps; - - this._setTrack(audioTrack); + if (prevProps !== this.props) + { + const { audioTrack, audioOutputDevice } = this.props; + + this._setTrack(audioTrack); + this._setOutputDevice(audioOutputDevice); + } } _setTrack(audioTrack) @@ -60,9 +65,23 @@ export default class PeerAudio extends React.PureComponent audio.srcObject = null; } } + + _setOutputDevice(audioOutputDevice) + { + if (this._audioOutputDevice === audioOutputDevice) + return; + + this._audioOutputDevice = audioOutputDevice; + + const { audio } = this.refs; + + if (audioOutputDevice && typeof audio.setSinkId === 'function') + audio.setSinkId(audioOutputDevice); + } } PeerAudio.propTypes = { - audioTrack : PropTypes.any + audioTrack : PropTypes.any, + audioOutputDevice : PropTypes.string }; diff --git a/app/src/components/Room.js b/app/src/components/Room.js index aef3987..cdda66f 100644 --- a/app/src/components/Room.js +++ b/app/src/components/Room.js @@ -23,7 +23,8 @@ import VideoWindow from './VideoWindow/VideoWindow'; import LockDialog from './AccessControl/LockDialog/LockDialog'; import Settings from './Settings/Settings'; import TopBar from './Controls/TopBar'; -import MobileControls from './Controls/MobileControls'; +import WakeLock from 'react-wakelock-react16'; +import ExtraVideo from './Controls/ExtraVideo'; const TIMEOUT = 5 * 1000; @@ -139,9 +140,9 @@ class Room extends React.PureComponent { const { room, + browser, advancedMode, toolAreaOpen, - isMobile, toggleToolArea, classes, theme @@ -204,12 +205,12 @@ class Room extends React.PureComponent - - - { isMobile && - + { browser.platform === 'mobile' && browser.os !== 'ios' && + } + + { room.lockDialogOpen && } @@ -217,6 +218,10 @@ class Room extends React.PureComponent { room.settingsOpen && } + + { room.extraVideoOpen && + + }
    ); } @@ -225,10 +230,10 @@ class Room extends React.PureComponent Room.propTypes = { room : appPropTypes.Room.isRequired, + browser : PropTypes.object.isRequired, advancedMode : PropTypes.bool.isRequired, toolAreaOpen : PropTypes.bool.isRequired, setToolbarsVisible : PropTypes.func.isRequired, - isMobile : PropTypes.bool, toggleToolArea : PropTypes.func.isRequired, classes : PropTypes.object.isRequired, theme : PropTypes.object.isRequired @@ -237,9 +242,9 @@ Room.propTypes = const mapStateToProps = (state) => ({ room : state.room, + browser : state.me.browser, advancedMode : state.settings.advancedMode, - toolAreaOpen : state.toolarea.toolAreaOpen, - isMobile : state.me.isMobile + toolAreaOpen : state.toolarea.toolAreaOpen }); const mapDispatchToProps = (dispatch) => @@ -263,9 +268,9 @@ export default connect( { return ( prev.room === next.room && + prev.me.browser === next.me.browser && prev.settings.advancedMode === next.settings.advancedMode && - prev.toolarea.toolAreaOpen === next.toolarea.toolAreaOpen && - prev.me.isMobile === next.me.isMobile + prev.toolarea.toolAreaOpen === next.toolarea.toolAreaOpen ); } } diff --git a/app/src/components/Selectors.js b/app/src/components/Selectors.js index 7a9cbfc..fd22aff 100644 --- a/app/src/components/Selectors.js +++ b/app/src/components/Selectors.js @@ -12,6 +12,10 @@ const peersKeySelector = createSelector( peersSelector, (peers) => Object.keys(peers) ); +const peersValueSelector = createSelector( + peersSelector, + (peers) => Object.values(peers) +); export const lobbyPeersKeySelector = createSelector( lobbyPeersSelector, @@ -33,6 +37,11 @@ export const screenProducersSelector = createSelector( (producers) => Object.values(producers).filter((producer) => producer.source === 'screen') ); +export const extraVideoProducersSelector = createSelector( + producersSelect, + (producers) => Object.values(producers).filter((producer) => producer.source === 'extravideo') +); + export const micProducerSelector = createSelector( producersSelect, (producers) => Object.values(producers).find((producer) => producer.source === 'mic') @@ -63,6 +72,33 @@ export const screenConsumerSelector = createSelector( (consumers) => Object.values(consumers).filter((consumer) => consumer.source === 'screen') ); +export const spotlightScreenConsumerSelector = createSelector( + spotlightsSelector, + consumersSelect, + (spotlights, consumers) => + Object.values(consumers).filter( + (consumer) => consumer.source === 'screen' && spotlights.includes(consumer.peerId) + ) +); + +export const spotlightExtraVideoConsumerSelector = createSelector( + spotlightsSelector, + consumersSelect, + (spotlights, consumers) => + Object.values(consumers).filter( + (consumer) => consumer.source === 'extravideo' && spotlights.includes(consumer.peerId) + ) +); + +export const passiveMicConsumerSelector = createSelector( + spotlightsSelector, + consumersSelect, + (spotlights, consumers) => + Object.values(consumers).filter( + (consumer) => consumer.source === 'mic' && !spotlights.includes(consumer.peerId) + ) +); + export const spotlightsLengthSelector = createSelector( spotlightsSelector, (spotlights) => spotlights.length @@ -74,35 +110,60 @@ export const spotlightPeersSelector = createSelector( (spotlights, peers) => peers.filter((peerId) => spotlights.includes(peerId)) ); +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 || ''))) +); + export const peersLengthSelector = createSelector( peersSelector, (peers) => Object.values(peers).length ); export const passivePeersSelector = createSelector( - peersKeySelector, + peersValueSelector, spotlightsSelector, - (peers, spotlights) => peers.filter((peerId) => !spotlights.includes(peerId)) + (peers, spotlights) => peers.filter((peer) => !spotlights.includes(peer.id)) + .sort((a, b) => String(a.displayName || '').localeCompare(String(b.displayName || ''))) +); + +export const raisedHandsSelector = createSelector( + peersValueSelector, + (peers) => peers.reduce((a, b) => (a + (b.raisedHand ? 1 : 0)), 0) ); export const videoBoxesSelector = createSelector( spotlightsLengthSelector, screenProducersSelector, - screenConsumerSelector, - (spotlightsLength, screenProducers, screenConsumers) => - spotlightsLength + 1 + screenProducers.length + screenConsumers.length + spotlightScreenConsumerSelector, + extraVideoProducersSelector, + spotlightExtraVideoConsumerSelector, + ( + spotlightsLength, + screenProducers, + screenConsumers, + extraVideoProducers, + extraVideoConsumers + ) => + spotlightsLength + 1 + screenProducers.length + + screenConsumers.length + extraVideoProducers.length + + extraVideoConsumers.length ); export const meProducersSelector = createSelector( micProducerSelector, webcamProducerSelector, screenProducerSelector, - (micProducer, webcamProducer, screenProducer) => + extraVideoProducersSelector, + (micProducer, webcamProducer, screenProducer, extraVideoProducers) => { return { micProducer, webcamProducer, - screenProducer + screenProducer, + extraVideoProducers }; } ); @@ -125,8 +186,10 @@ export const makePeerConsumerSelector = () => consumersArray.find((consumer) => consumer.source === 'webcam'); const screenConsumer = consumersArray.find((consumer) => consumer.source === 'screen'); + const extraVideoConsumers = + consumersArray.filter((consumer) => consumer.source === 'extravideo'); - return { micConsumer, webcamConsumer, screenConsumer }; + return { micConsumer, webcamConsumer, screenConsumer, extraVideoConsumers }; } ); }; diff --git a/app/src/components/Settings/AdvancedSettings.js b/app/src/components/Settings/AdvancedSettings.js new file mode 100644 index 0000000..520dc0f --- /dev/null +++ b/app/src/components/Settings/AdvancedSettings.js @@ -0,0 +1,125 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { withStyles } from '@material-ui/core/styles'; +import { withRoomContext } from '../../RoomContext'; +import * as settingsActions from '../../actions/settingsActions'; +import PropTypes from 'prop-types'; +import { useIntl, FormattedMessage } from 'react-intl'; +import MenuItem from '@material-ui/core/MenuItem'; +import FormHelperText from '@material-ui/core/FormHelperText'; +import FormControl from '@material-ui/core/FormControl'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Select from '@material-ui/core/Select'; +import Checkbox from '@material-ui/core/Checkbox'; + +const styles = (theme) => + ({ + setting : + { + padding : theme.spacing(2) + }, + formControl : + { + display : 'flex' + } + }); + +const AdvancedSettings = ({ + roomClient, + settings, + onToggleAdvancedMode, + onToggleNotificationSounds, + classes +}) => +{ + const intl = useIntl(); + + return ( + + } + label={intl.formatMessage({ + id : 'settings.advancedMode', + defaultMessage : 'Advanced mode' + })} + /> + } + label={intl.formatMessage({ + id : 'settings.notificationSounds', + defaultMessage : 'Notification sounds' + })} + /> + { !window.config.lockLastN && +
    + + + + + + +
    + } +
    + ); +}; + +AdvancedSettings.propTypes = +{ + roomClient : PropTypes.any.isRequired, + settings : PropTypes.object.isRequired, + onToggleAdvancedMode : PropTypes.func.isRequired, + onToggleNotificationSounds : PropTypes.func.isRequired, + classes : PropTypes.object.isRequired +}; + +const mapStateToProps = (state) => + ({ + settings : state.settings + }); + +const mapDispatchToProps = { + onToggleAdvancedMode : settingsActions.toggleAdvancedMode, + onToggleNotificationSounds : settingsActions.toggleNotificationSounds +}; + +export default withRoomContext(connect( + mapStateToProps, + mapDispatchToProps, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.settings === next.settings + ); + } + } +)(withStyles(styles)(AdvancedSettings))); \ No newline at end of file diff --git a/app/src/components/Settings/AppearenceSettings.js b/app/src/components/Settings/AppearenceSettings.js new file mode 100644 index 0000000..705b2f6 --- /dev/null +++ b/app/src/components/Settings/AppearenceSettings.js @@ -0,0 +1,143 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import * as appPropTypes from '../appPropTypes'; +import { withStyles } from '@material-ui/core/styles'; +import * as roomActions from '../../actions/roomActions'; +import * as settingsActions from '../../actions/settingsActions'; +import PropTypes from 'prop-types'; +import { useIntl, FormattedMessage } from 'react-intl'; +import MenuItem from '@material-ui/core/MenuItem'; +import FormHelperText from '@material-ui/core/FormHelperText'; +import FormControl from '@material-ui/core/FormControl'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Select from '@material-ui/core/Select'; +import Checkbox from '@material-ui/core/Checkbox'; + +const styles = (theme) => + ({ + setting : + { + padding : theme.spacing(2) + }, + formControl : + { + display : 'flex' + } + }); + +const AppearenceSettings = ({ + room, + settings, + onTogglePermanentTopBar, + onToggleHiddenControls, + handleChangeMode, + classes +}) => +{ + const intl = useIntl(); + + const modes = [ { + value : 'democratic', + label : intl.formatMessage({ + id : 'label.democratic', + defaultMessage : 'Democratic view' + }) + }, { + value : 'filmstrip', + label : intl.formatMessage({ + id : 'label.filmstrip', + defaultMessage : 'Filmstrip view' + }) + } ]; + + return ( + +
    + + + + + + +
    + } + label={intl.formatMessage({ + id : 'settings.permanentTopBar', + defaultMessage : 'Permanent top bar' + })} + /> + } + label={intl.formatMessage({ + id : 'settings.hiddenControls', + defaultMessage : 'Hidden media controls' + })} + /> +
    + ); +}; + +AppearenceSettings.propTypes = +{ + room : appPropTypes.Room.isRequired, + settings : PropTypes.object.isRequired, + onTogglePermanentTopBar : PropTypes.func.isRequired, + onToggleHiddenControls : PropTypes.func.isRequired, + handleChangeMode : PropTypes.func.isRequired, + classes : PropTypes.object.isRequired +}; + +const mapStateToProps = (state) => + ({ + room : state.room, + settings : state.settings + }); + +const mapDispatchToProps = { + onTogglePermanentTopBar : settingsActions.togglePermanentTopBar, + onToggleHiddenControls : settingsActions.toggleHiddenControls, + handleChangeMode : roomActions.setDisplayMode +}; + +export default connect( + mapStateToProps, + mapDispatchToProps, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.room === next.room && + prev.settings === next.settings + ); + } + } +)(withStyles(styles)(AppearenceSettings)); \ No newline at end of file diff --git a/app/src/components/Settings/MediaSettings.js b/app/src/components/Settings/MediaSettings.js new file mode 100644 index 0000000..392df88 --- /dev/null +++ b/app/src/components/Settings/MediaSettings.js @@ -0,0 +1,341 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import * as appPropTypes from '../appPropTypes'; +import { withStyles } from '@material-ui/core/styles'; +import { withRoomContext } from '../../RoomContext'; +import * as settingsActions from '../../actions/settingsActions'; +import PropTypes from 'prop-types'; +import { useIntl, FormattedMessage } from 'react-intl'; +import MenuItem from '@material-ui/core/MenuItem'; +import FormHelperText from '@material-ui/core/FormHelperText'; +import FormControl from '@material-ui/core/FormControl'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Select from '@material-ui/core/Select'; +import Checkbox from '@material-ui/core/Checkbox'; + +const styles = (theme) => + ({ + setting : + { + padding : theme.spacing(2) + }, + formControl : + { + display : 'flex' + } + }); + +const MediaSettings = ({ + setEchoCancellation, + setAutoGainControl, + setNoiseSuppression, + roomClient, + me, + settings, + classes +}) => +{ + const intl = useIntl(); + + const resolutions = [ { + value : 'low', + label : intl.formatMessage({ + id : 'label.low', + defaultMessage : 'Low' + }) + }, + { + value : 'medium', + label : intl.formatMessage({ + id : 'label.medium', + defaultMessage : 'Medium' + }) + }, + { + value : 'high', + label : intl.formatMessage({ + id : 'label.high', + defaultMessage : 'High (HD)' + }) + }, + { + value : 'veryhigh', + label : intl.formatMessage({ + id : 'label.veryHigh', + defaultMessage : 'Very high (FHD)' + }) + }, + { + value : 'ultra', + label : intl.formatMessage({ + id : 'label.ultra', + defaultMessage : 'Ultra (UHD)' + }) + } ]; + + let webcams; + + if (me.webcamDevices) + webcams = Object.values(me.webcamDevices); + else + webcams = []; + + let audioDevices; + + if (me.audioDevices) + audioDevices = Object.values(me.audioDevices); + else + audioDevices = []; + + let audioOutputDevices; + + if (me.audioOutputDevices) + audioOutputDevices = Object.values(me.audioOutputDevices); + else + audioOutputDevices = []; + + return ( + +
    + + + + { webcams.length > 0 ? + intl.formatMessage({ + id : 'settings.selectCamera', + defaultMessage : 'Select video device' + }) + : + intl.formatMessage({ + id : 'settings.cantSelectCamera', + defaultMessage : 'Unable to select video device' + }) + } + + +
    +
    + + + + { audioDevices.length > 0 ? + intl.formatMessage({ + id : 'settings.selectAudio', + defaultMessage : 'Select audio device' + }) + : + intl.formatMessage({ + id : 'settings.cantSelectAudio', + defaultMessage : 'Unable to select audio device' + }) + } + + +
    + { 'audioOutputSupportedBrowsers' in window.config && + window.config.audioOutputSupportedBrowsers.includes(me.browser.name) && +
    + + + + { audioOutputDevices.length > 0 ? + intl.formatMessage({ + id : 'settings.selectAudioOutput', + defaultMessage : 'Select audio output device' + }) + : + intl.formatMessage({ + id : 'settings.cantSelectAudioOutput', + defaultMessage : 'Unable to select audio output device' + }) + } + + +
    + } +
    + + + + + + + { + setEchoCancellation(event.target.checked); + roomClient.changeAudioDevice(settings.selectedAudioDevice); + }} + />} + label={intl.formatMessage({ + id : 'settings.echoCancellation', + defaultMessage : 'Echo Cancellation' + })} + /> + { + setAutoGainControl(event.target.checked); + roomClient.changeAudioDevice(settings.selectedAudioDevice); + }} + />} + label={intl.formatMessage({ + id : 'settings.autoGainControl', + defaultMessage : 'Auto Gain Control' + })} + /> + { + setNoiseSuppression(event.target.checked); + roomClient.changeAudioDevice(settings.selectedAudioDevice); + }} + />} + label={intl.formatMessage({ + id : 'settings.noiseSuppression', + defaultMessage : 'Noise Suppression' + })} + /> + +
    + ); +}; + +MediaSettings.propTypes = +{ + roomClient : PropTypes.any.isRequired, + setEchoCancellation : PropTypes.func.isRequired, + setAutoGainControl : PropTypes.func.isRequired, + setNoiseSuppression : PropTypes.func.isRequired, + me : appPropTypes.Me.isRequired, + settings : PropTypes.object.isRequired, + classes : PropTypes.object.isRequired +}; + +const mapStateToProps = (state) => +{ + return { + me : state.me, + settings : state.settings + }; +}; + +const mapDispatchToProps = { + setEchoCancellation : settingsActions.setEchoCancellation, + setAutoGainControl : settingsActions.toggleAutoGainControl, + setNoiseSuppression : settingsActions.toggleNoiseSuppression +}; + +export default withRoomContext(connect( + mapStateToProps, + mapDispatchToProps, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.me === next.me && + prev.settings === next.settings + ); + } + } +)(withStyles(styles)(MediaSettings))); \ No newline at end of file diff --git a/app/src/components/Settings/Settings.js b/app/src/components/Settings/Settings.js index 3c56be4..6633829 100644 --- a/app/src/components/Settings/Settings.js +++ b/app/src/components/Settings/Settings.js @@ -1,22 +1,25 @@ import React from 'react'; import { connect } from 'react-redux'; -import * as appPropTypes from '../appPropTypes'; import { withStyles } from '@material-ui/core/styles'; -import { withRoomContext } from '../../RoomContext'; import * as roomActions from '../../actions/roomActions'; -import * as settingsActions from '../../actions/settingsActions'; import PropTypes from 'prop-types'; import { useIntl, FormattedMessage } from 'react-intl'; +import Tabs from '@material-ui/core/Tabs'; +import Tab from '@material-ui/core/Tab'; +import MediaSettings from './MediaSettings'; +import AppearenceSettings from './AppearenceSettings'; +import AdvancedSettings from './AdvancedSettings'; import Dialog from '@material-ui/core/Dialog'; import DialogTitle from '@material-ui/core/DialogTitle'; import DialogActions from '@material-ui/core/DialogActions'; import Button from '@material-ui/core/Button'; -import MenuItem from '@material-ui/core/MenuItem'; -import FormHelperText from '@material-ui/core/FormHelperText'; -import FormControl from '@material-ui/core/FormControl'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; -import Select from '@material-ui/core/Select'; -import Checkbox from '@material-ui/core/Checkbox'; + +const tabs = +[ + 'media', + 'appearence', + 'advanced' +]; const styles = (theme) => ({ @@ -43,102 +46,27 @@ const styles = (theme) => width : '90vw' } }, - setting : + tabsHeader : { - padding : theme.spacing(2) - }, - formControl : - { - display : 'flex' + flexGrow : 1 } }); const Settings = ({ - roomClient, - room, - me, - settings, - onToggleAdvancedMode, - onTogglePermanentTopBar, - setEchoCancellation, - setAutoGainControl, - setNoiseSuppression, + currentSettingsTab, + settingsOpen, handleCloseSettings, - handleChangeMode, + setSettingsTab, classes }) => { const intl = useIntl(); - const modes = [ { - value : 'democratic', - label : intl.formatMessage({ - id : 'label.democratic', - defaultMessage : 'Democratic view' - }) - }, { - value : 'filmstrip', - label : intl.formatMessage({ - id : 'label.filmstrip', - defaultMessage : 'Filmstrip view' - }) - } ]; - - const resolutions = [ { - value : 'low', - label : intl.formatMessage({ - id : 'label.low', - defaultMessage : 'Low' - }) - }, - { - value : 'medium', - label : intl.formatMessage({ - id : 'label.medium', - defaultMessage : 'Medium' - }) - }, - { - value : 'high', - label : intl.formatMessage({ - id : 'label.high', - defaultMessage : 'High (HD)' - }) - }, - { - value : 'veryhigh', - label : intl.formatMessage({ - id : 'label.veryHigh', - defaultMessage : 'Very high (FHD)' - }) - }, - { - value : 'ultra', - label : intl.formatMessage({ - id : 'label.ultra', - defaultMessage : 'Ultra (UHD)' - }) - } ]; - - let webcams; - - if (me.webcamDevices) - webcams = Object.values(me.webcamDevices); - else - webcams = []; - - let audioDevices; - - if (me.audioDevices) - audioDevices = Object.values(me.audioDevices); - else - audioDevices = []; - return ( handleCloseSettings({ settingsOpen: false })} + open={settingsOpen} + onClose={() => handleCloseSettings(false)} classes={{ paper : classes.dialogPaper }} @@ -149,244 +77,40 @@ const Settings = ({ defaultMessage='Settings' /> -
    - - - - { webcams.length > 0 ? - intl.formatMessage({ - id : 'settings.selectCamera', - defaultMessage : 'Select video device' - }) - : - intl.formatMessage({ - id : 'settings.cantSelectCamera', - defaultMessage : 'Unable to select video device' - }) - } - - -
    -
    - - - - { audioDevices.length > 0 ? - intl.formatMessage({ - id : 'settings.selectAudio', - defaultMessage : 'Select audio device' - }) - : - intl.formatMessage({ - id : 'settings.cantSelectAudio', - defaultMessage : 'Unable to select audio device' - }) - } - - -
    -
    - - - - - - -
    -
    - - - - - - -
    - } - label={intl.formatMessage({ - id : 'settings.advancedMode', - defaultMessage : 'Advanced mode' - })} - /> - { settings.advancedMode && - -
    - - - - - - -
    - - { - setEchoCancellation(event.target.checked); - roomClient.changeAudioDevice(settings.selectedAudioDevice); - }} - />} - label={intl.formatMessage({ - id : 'settings.echoCancellation', - defaultMessage : 'Echo Cancellation' - })} - /> - { - setAutoGainControl(event.target.checked); - roomClient.changeAudioDevice(settings.selectedAudioDevice); - }} - />} - label={intl.formatMessage({ - id: 'settings.autoGainControl', - defaultMessage: 'Auto Gain Control' - })} - /> - { - setNoiseSuppression(event.target.checked); - roomClient.changeAudioDevice(settings.selectedAudioDevice); - }} - />} - label={intl.formatMessage({ - id: 'settings.noiseSuppression', - defaultMessage: 'Noise Suppression' - })} - /> - } - label={intl.formatMessage({ - id : 'settings.permanentTopBar', - defaultMessage : 'Permanent top bar' - })} - /> -
    - } + setSettingsTab(tabs[value])} + indicatorColor='primary' + textColor='primary' + variant='fullWidth' + > + + + + + {currentSettingsTab === 'media' && } + {currentSettingsTab === 'appearence' && } + {currentSettingsTab === 'advanced' && } -
  • - { participants.map((peer) => ( -
  • roomClient.setSelectedPeer(peer.id)} - > - { spotlights.includes(peer.id) ? - + { participants.map((peer) => ( + +
  • roomClient.setSelectedPeer(peer.id)} > - - - : - - } -
  • - ))} + { spotlights.includes(peer.id) ? + + + + : + + } + + + ))} +
    ); From 45a89b9f1acb9f7a7db87fa8064f172d989ea89b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Wed, 6 May 2020 13:50:29 +0200 Subject: [PATCH 43/88] Only keep one self destruct timeout, ref #255 --- server/lib/Room.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/server/lib/Room.js b/server/lib/Room.js index 1b207fa..69e3e31 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; From 26874877ba5ea4df4db57898006a3541b848cad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Wed, 6 May 2020 14:03:47 +0200 Subject: [PATCH 44/88] Provide roomId to logout for load balanced scenarios, fixes #275 --- app/src/RoomClient.js | 4 ++-- app/src/components/JoinDialog.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index 1c93794..9f7dd10 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -475,9 +475,9 @@ export default class RoomClient window.open(url, 'loginWindow'); } - logout() + logout(roomId = this._roomId) { - window.open('/auth/logout', 'logoutWindow'); + window.open(`/auth/logout?peerId=${this._peerId}&roomId=${roomId}`, 'logoutWindow'); } receiveLoginChildWindow(data) diff --git a/app/src/components/JoinDialog.js b/app/src/components/JoinDialog.js index 3d2596f..0ad8ef8 100644 --- a/app/src/components/JoinDialog.js +++ b/app/src/components/JoinDialog.js @@ -224,7 +224,7 @@ const JoinDialog = ({ myPicture={myPicture} onLogin={() => { - loggedIn ? roomClient.logout() : roomClient.login(roomId); + loggedIn ? roomClient.logout(roomId) : roomClient.login(roomId); }} loggedIn={loggedIn} > From 3f1d102c5901e95c6d0e6d339b3bade5989d3571 Mon Sep 17 00:00:00 2001 From: Stefan Otto Date: Wed, 6 May 2020 18:47:14 +0200 Subject: [PATCH 45/88] fix my errors in gigantic merge + linting --- app/src/RoomClient.js | 99 ++++---------------- app/src/components/Selectors.js | 4 - app/src/components/Settings/MediaSettings.js | 9 +- 3 files changed, 22 insertions(+), 90 deletions(-) diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index 7fd9cb5..df276ae 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -953,20 +953,24 @@ 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(); @@ -979,7 +983,8 @@ export default class RoomClient this._hark = hark(this._harkStream, { play: false }); // eslint-disable-next-line no-unused-vars - this._hark.on('volume_change', (dBs, threshold) => { + 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 @@ -992,21 +997,23 @@ export default class RoomClient volume = Math.round(volume); - if (this._micProducer && volume !== this._micProducer.volume) { + if (this._micProducer && volume !== this._micProducer.volume) + { this._micProducer.volume = volume; store.dispatch(peerVolumeActions.setPeerVolume(this._peerId, volume)); } }); - this._hark.on('speaking', function () { + this._hark.on('speaking', function() + { store.dispatch(meActions.setIsSpeaking(true)); }); - this._hark.on('stopped_speaking', function () { + this._hark.on('stopped_speaking', function() + { store.dispatch(meActions.setIsSpeaking(false)); }); } - async changeAudioDevice(deviceId) { logger.debug('changeAudioDevice() [deviceId: %s]', deviceId); @@ -1106,37 +1113,6 @@ 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); @@ -1826,49 +1802,6 @@ 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')); }); diff --git a/app/src/components/Selectors.js b/app/src/components/Selectors.js index 8f59d5d..b8e5e41 100644 --- a/app/src/components/Selectors.js +++ b/app/src/components/Selectors.js @@ -12,10 +12,6 @@ const peersKeySelector = createSelector( peersSelector, (peers) => Object.keys(peers) ); -const peersValueSelector = createSelector( - peersSelector, - (peers) => Object.values(peers) -); export const peersValueSelector = createSelector( peersSelector, diff --git a/app/src/components/Settings/MediaSettings.js b/app/src/components/Settings/MediaSettings.js index 392df88..d215181 100644 --- a/app/src/components/Settings/MediaSettings.js +++ b/app/src/components/Settings/MediaSettings.js @@ -257,7 +257,8 @@ const MediaSettings = ({ className={classes.setting} control={ { + (event) => + { setEchoCancellation(event.target.checked); roomClient.changeAudioDevice(settings.selectedAudioDevice); }} @@ -271,7 +272,8 @@ const MediaSettings = ({ className={classes.setting} control={ { + (event) => + { setAutoGainControl(event.target.checked); roomClient.changeAudioDevice(settings.selectedAudioDevice); }} @@ -285,7 +287,8 @@ const MediaSettings = ({ className={classes.setting} control={ { + (event) => + { setNoiseSuppression(event.target.checked); roomClient.changeAudioDevice(settings.selectedAudioDevice); }} From 1cbf2d5b38badd9d9305b2087a80164a25356d9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Wed, 6 May 2020 21:05:34 +0200 Subject: [PATCH 46/88] Remove unused imports, fixes #305 --- app/src/components/VideoContainers/VideoView.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/src/components/VideoContainers/VideoView.js b/app/src/components/VideoContainers/VideoView.js index 9afcd3b..f0e407b 100644 --- a/app/src/components/VideoContainers/VideoView.js +++ b/app/src/components/VideoContainers/VideoView.js @@ -4,13 +4,12 @@ import classnames from 'classnames'; import { withStyles } from '@material-ui/core/styles'; import EditableInput from '../Controls/EditableInput'; import Logger from '../../Logger'; -import { green, yellow, orange, red } from '@material-ui/core/colors'; +import { yellow, orange, red } from '@material-ui/core/colors'; import SignalCellularOffIcon from '@material-ui/icons/SignalCellularOff'; import SignalCellular0BarIcon from '@material-ui/icons/SignalCellular0Bar'; import SignalCellular1BarIcon from '@material-ui/icons/SignalCellular1Bar'; import SignalCellular2BarIcon from '@material-ui/icons/SignalCellular2Bar'; import SignalCellular3BarIcon from '@material-ui/icons/SignalCellular3Bar'; -import SignalCellularAltIcon from '@material-ui/icons/SignalCellularAlt'; const logger = new Logger('VideoView'); @@ -162,8 +161,6 @@ class VideoView extends React.PureComponent videoMultiLayer, audioScore, videoScore, - // consumerSpatialLayers, - // consumerTemporalLayers, consumerCurrentSpatialLayer, consumerCurrentTemporalLayer, consumerPreferredSpatialLayer, @@ -224,7 +221,7 @@ class VideoView extends React.PureComponent case 10: { - quality = null; // ; + quality = null; break; } From c1aa62d22cccd5a185e2393c73194c4c24d258bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Wed, 6 May 2020 22:22:57 +0200 Subject: [PATCH 47/88] Don't send lobbypeers to client if they don't have PROMOTE_PEER role, fixes #208 --- server/lib/Room.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/lib/Room.js b/server/lib/Room.js index 69e3e31..02ba41c 100644 --- a/server/lib/Room.js +++ b/server/lib/Room.js @@ -669,7 +669,14 @@ class Room extends EventEmitter .filter((joinedPeer) => joinedPeer.id !== peer.id) .map((joinedPeer) => (joinedPeer.peerInfo)); - const lobbyPeers = this._lobby.peerList(); + let lobbyPeers = []; + + if ( // Allowed to promote peers, notify about lobbypeers + peer.roles.some((role) => + permissionsFromRoles.PROMOTE_PEER.includes(role) + ) + ) + lobbyPeers = this._lobby.peerList(); cb(null, { roles : peer.roles, From 457d679382caffcd20f71232adff7897b8290377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Wed, 6 May 2020 23:08:51 +0200 Subject: [PATCH 48/88] Setting to disable notifications, fixes #306 --- app/src/actions/settingsActions.js | 5 ++++ app/src/components/Room.js | 16 ++++++---- .../components/Settings/AppearenceSettings.js | 29 +++++++++++++------ app/src/reducers/settings.js | 8 +++++ app/src/translations/cn.json | 1 + app/src/translations/cs.json | 1 + app/src/translations/de.json | 1 + app/src/translations/dk.json | 1 + app/src/translations/el.json | 1 + app/src/translations/en.json | 1 + app/src/translations/es.json | 1 + app/src/translations/fr.json | 1 + app/src/translations/hr.json | 1 + app/src/translations/hu.json | 1 + app/src/translations/it.json | 1 + app/src/translations/lv.json | 1 + app/src/translations/nb.json | 1 + app/src/translations/pl.json | 1 + app/src/translations/pt.json | 1 + app/src/translations/ro.json | 1 + app/src/translations/tr.json | 1 + app/src/translations/uk.json | 1 + 22 files changed, 62 insertions(+), 14 deletions(-) diff --git a/app/src/actions/settingsActions.js b/app/src/actions/settingsActions.js index 5416b02..63c12bf 100644 --- a/app/src/actions/settingsActions.js +++ b/app/src/actions/settingsActions.js @@ -38,6 +38,11 @@ export const togglePermanentTopBar = () => type : 'TOGGLE_PERMANENT_TOPBAR' }); +export const toggleShowNotifications = () => + ({ + type : 'TOGGLE_SHOW_NOTIFICATIONS' + }); + export const setEchoCancellation = (echoCancellation) => ({ type : 'SET_ECHO_CANCELLATION', diff --git a/app/src/components/Room.js b/app/src/components/Room.js index cdda66f..7562ee4 100644 --- a/app/src/components/Room.js +++ b/app/src/components/Room.js @@ -142,6 +142,7 @@ class Room extends React.PureComponent room, browser, advancedMode, + showNotifications, toolAreaOpen, toggleToolArea, classes, @@ -178,7 +179,9 @@ class Room extends React.PureComponent - + { showNotifications && + + } @@ -232,6 +235,7 @@ Room.propTypes = room : appPropTypes.Room.isRequired, browser : PropTypes.object.isRequired, advancedMode : PropTypes.bool.isRequired, + showNotifications : PropTypes.bool.isRequired, toolAreaOpen : PropTypes.bool.isRequired, setToolbarsVisible : PropTypes.func.isRequired, toggleToolArea : PropTypes.func.isRequired, @@ -241,10 +245,11 @@ Room.propTypes = const mapStateToProps = (state) => ({ - room : state.room, - browser : state.me.browser, - advancedMode : state.settings.advancedMode, - toolAreaOpen : state.toolarea.toolAreaOpen + room : state.room, + browser : state.me.browser, + advancedMode : state.settings.advancedMode, + showNotifications : state.settings.showNotifications, + toolAreaOpen : state.toolarea.toolAreaOpen }); const mapDispatchToProps = (dispatch) => @@ -270,6 +275,7 @@ export default connect( prev.room === next.room && prev.me.browser === next.me.browser && prev.settings.advancedMode === next.settings.advancedMode && + prev.settings.showNotifications === next.settings.showNotifications && prev.toolarea.toolAreaOpen === next.toolarea.toolAreaOpen ); } diff --git a/app/src/components/Settings/AppearenceSettings.js b/app/src/components/Settings/AppearenceSettings.js index 705b2f6..a34a5e1 100644 --- a/app/src/components/Settings/AppearenceSettings.js +++ b/app/src/components/Settings/AppearenceSettings.js @@ -30,6 +30,7 @@ const AppearenceSettings = ({ settings, onTogglePermanentTopBar, onToggleHiddenControls, + onToggleShowNotifications, handleChangeMode, classes }) => @@ -101,18 +102,27 @@ const AppearenceSettings = ({ defaultMessage : 'Hidden media controls' })} /> + } + label={intl.formatMessage({ + id : 'settings.showNotifications', + defaultMessage : 'Show notifications' + })} + /> ); }; AppearenceSettings.propTypes = { - room : appPropTypes.Room.isRequired, - settings : PropTypes.object.isRequired, - onTogglePermanentTopBar : PropTypes.func.isRequired, - onToggleHiddenControls : PropTypes.func.isRequired, - handleChangeMode : PropTypes.func.isRequired, - classes : PropTypes.object.isRequired + room : appPropTypes.Room.isRequired, + settings : PropTypes.object.isRequired, + onTogglePermanentTopBar : PropTypes.func.isRequired, + onToggleHiddenControls : PropTypes.func.isRequired, + onToggleShowNotifications : PropTypes.func.isRequired, + handleChangeMode : PropTypes.func.isRequired, + classes : PropTypes.object.isRequired }; const mapStateToProps = (state) => @@ -122,9 +132,10 @@ const mapStateToProps = (state) => }); const mapDispatchToProps = { - onTogglePermanentTopBar : settingsActions.togglePermanentTopBar, - onToggleHiddenControls : settingsActions.toggleHiddenControls, - handleChangeMode : roomActions.setDisplayMode + onTogglePermanentTopBar : settingsActions.togglePermanentTopBar, + onToggleHiddenControls : settingsActions.toggleHiddenControls, + onToggleShowNotifications : settingsActions.toggleShowNotifications, + handleChangeMode : roomActions.setDisplayMode }; export default connect( diff --git a/app/src/reducers/settings.js b/app/src/reducers/settings.js index da96ecc..7186fdc 100644 --- a/app/src/reducers/settings.js +++ b/app/src/reducers/settings.js @@ -16,6 +16,7 @@ const initialState = lastN : 4, permanentTopBar : true, hiddenControls : false, + showNotifications : true, notificationSounds : true, ...window.config.defaultAudio }; @@ -158,6 +159,13 @@ const settings = (state = initialState, action) => return { ...state, notificationSounds }; } + case 'TOGGLE_SHOW_NOTIFICATIONS': + { + const showNotifications = !state.showNotifications; + + return { ...state, showNotifications }; + } + case 'SET_VIDEO_RESOLUTION': { const { resolution } = action.payload; diff --git a/app/src/translations/cn.json b/app/src/translations/cn.json index 097e9b7..26724a4 100644 --- a/app/src/translations/cn.json +++ b/app/src/translations/cn.json @@ -130,6 +130,7 @@ "settings.lastn": "可见视频数量", "settings.hiddenControls": null, "settings.notificationSounds": null, + "settings.showNotifications": null, "filesharing.saveFileError": "无法保存文件", "filesharing.startingFileShare": "正在尝试共享文件", diff --git a/app/src/translations/cs.json b/app/src/translations/cs.json index 66e72b2..b80ab14 100644 --- a/app/src/translations/cs.json +++ b/app/src/translations/cs.json @@ -129,6 +129,7 @@ "settings.lastn": null, "settings.hiddenControls": null, "settings.notificationSounds": null, + "settings.showNotifications": null, "filesharing.saveFileError": "Není možné uložit soubor", "filesharing.startingFileShare": "Pokouším se sdílet soubor", diff --git a/app/src/translations/de.json b/app/src/translations/de.json index 6d72d54..3eabb28 100644 --- a/app/src/translations/de.json +++ b/app/src/translations/de.json @@ -130,6 +130,7 @@ "settings.lastn": "Anzahl der sichtbaren Videos", "settings.hiddenControls": null, "settings.notificationSounds": null, + "settings.showNotifications": null, "filesharing.saveFileError": "Fehler beim Speichern der Datei", "filesharing.startingFileShare": "Starte Teilen der Datei", diff --git a/app/src/translations/dk.json b/app/src/translations/dk.json index 351907c..cab3586 100644 --- a/app/src/translations/dk.json +++ b/app/src/translations/dk.json @@ -130,6 +130,7 @@ "settings.lastn": "Antal synlige videoer", "settings.hiddenControls": null, "settings.notificationSounds": null, + "settings.showNotifications": null, "filesharing.saveFileError": "Kan ikke gemme fil", "filesharing.startingFileShare": "Forsøger at dele filen", diff --git a/app/src/translations/el.json b/app/src/translations/el.json index 8f34eb1..5a83427 100644 --- a/app/src/translations/el.json +++ b/app/src/translations/el.json @@ -130,6 +130,7 @@ "settings.lastn": "Αριθμός ορατών βίντεο", "settings.hiddenControls": null, "settings.notificationSounds": null, + "settings.showNotifications": null, "filesharing.saveFileError": "Αδυναμία αποθήκευσης του αρχείου", "filesharing.startingFileShare": "Προσπάθεια διαμοιρασμού αρχείου", diff --git a/app/src/translations/en.json b/app/src/translations/en.json index 660585d..2b40a30 100644 --- a/app/src/translations/en.json +++ b/app/src/translations/en.json @@ -130,6 +130,7 @@ "settings.lastn": "Number of visible videos", "settings.hiddenControls": "Hidden media controls", "settings.notificationSounds": "Notification sounds", + "settings.showNotifications": "Show notifications", "filesharing.saveFileError": "Unable to save file", "filesharing.startingFileShare": "Attempting to share file", diff --git a/app/src/translations/es.json b/app/src/translations/es.json index f685b8f..63c2f7d 100644 --- a/app/src/translations/es.json +++ b/app/src/translations/es.json @@ -130,6 +130,7 @@ "settings.lastn": "Cantidad de videos visibles", "settings.hiddenControls": null, "settings.notificationSounds": null, + "settings.showNotifications": null, "filesharing.saveFileError": "No ha sido posible guardar el fichero", "filesharing.startingFileShare": "Intentando compartir el fichero", diff --git a/app/src/translations/fr.json b/app/src/translations/fr.json index d1a67cf..4471861 100644 --- a/app/src/translations/fr.json +++ b/app/src/translations/fr.json @@ -130,6 +130,7 @@ "settings.lastn": "Nombre de vidéos visibles", "settings.hiddenControls": null, "settings.notificationSounds": null, + "settings.showNotifications": null, "filesharing.saveFileError": "Impossible d'enregistrer le fichier", "filesharing.startingFileShare": "Début du transfert de fichier", diff --git a/app/src/translations/hr.json b/app/src/translations/hr.json index f08f516..682dfbe 100644 --- a/app/src/translations/hr.json +++ b/app/src/translations/hr.json @@ -130,6 +130,7 @@ "settings.lastn": "Broj vidljivih videozapisa", "settings.hiddenControls": "Skrivene kontrole medija", "settings.notificationSounds": "Zvuk obavijesti", + "settings.showNotifications": null, "filesharing.saveFileError": "Nije moguće spremiti datoteku", "filesharing.startingFileShare": "Pokušaj dijeljenja datoteke", diff --git a/app/src/translations/hu.json b/app/src/translations/hu.json index c3dd780..262f117 100644 --- a/app/src/translations/hu.json +++ b/app/src/translations/hu.json @@ -130,6 +130,7 @@ "settings.lastn": "A látható videók száma", "settings.hiddenControls": "Média Gombok automatikus elrejtése", "settings.notificationSounds": "Értesítések hangjelzéssel", + "settings.showNotifications": null, "filesharing.saveFileError": "A file-t nem sikerült elmenteni", "filesharing.startingFileShare": "Fájl megosztása", diff --git a/app/src/translations/it.json b/app/src/translations/it.json index 472ce91..27b3194 100644 --- a/app/src/translations/it.json +++ b/app/src/translations/it.json @@ -129,6 +129,7 @@ "settings.lastn": "Numero di video visibili", "settings.hiddenControls": "Controlli media nascosti", "settings.notificationSounds": "Suoni di notifica", + "settings.showNotifications": null, "filesharing.saveFileError": "Impossibile salvare file", "filesharing.startingFileShare": "Tentativo di condivisione file", diff --git a/app/src/translations/lv.json b/app/src/translations/lv.json index dd8fac4..d355c84 100644 --- a/app/src/translations/lv.json +++ b/app/src/translations/lv.json @@ -124,6 +124,7 @@ "settings.lastn": "Jums redzamo video/kameru skaits", "settings.hiddenControls": "Slēpto mediju vadība", "settings.notificationSounds": "Paziņojumu skaņas", + "settings.showNotifications": null, "filesharing.saveFileError": "Nav iespējams saglabāt failu", "filesharing.startingFileShare": "Tiek mēģināts kopīgot failu", diff --git a/app/src/translations/nb.json b/app/src/translations/nb.json index 58e3b08..59edd6c 100644 --- a/app/src/translations/nb.json +++ b/app/src/translations/nb.json @@ -130,6 +130,7 @@ "settings.lastn": "Antall videoer synlig", "settings.hiddenControls": "Skjul media knapper", "settings.notificationSounds": "Varslingslyder", + "settings.showNotifications": "Vis varslinger", "filesharing.saveFileError": "Klarte ikke å lagre fil", "filesharing.startingFileShare": "Starter fildeling", diff --git a/app/src/translations/pl.json b/app/src/translations/pl.json index 923de79..1413c73 100644 --- a/app/src/translations/pl.json +++ b/app/src/translations/pl.json @@ -130,6 +130,7 @@ "settings.lastn": "Liczba widocznych uczestników (zdalnych)", "settings.hiddenControls": null, "settings.notificationSounds": null, + "settings.showNotifications": null, "filesharing.saveFileError": "Nie można zapisać pliku", "filesharing.startingFileShare": "Próba udostępnienia pliku", diff --git a/app/src/translations/pt.json b/app/src/translations/pt.json index b863098..3b0a078 100644 --- a/app/src/translations/pt.json +++ b/app/src/translations/pt.json @@ -130,6 +130,7 @@ "settings.lastn": "Número de vídeos visíveis", "settings.hiddenControls": null, "settings.notificationSounds": null, + "settings.showNotifications": null, "filesharing.saveFileError": "Impossível de gravar o ficheiro", "filesharing.startingFileShare": "Tentando partilha de ficheiro", diff --git a/app/src/translations/ro.json b/app/src/translations/ro.json index 624c231..e666ddf 100644 --- a/app/src/translations/ro.json +++ b/app/src/translations/ro.json @@ -130,6 +130,7 @@ "settings.lastn": "Numărul de videoclipuri vizibile", "settings.hiddenControls": null, "settings.notificationSounds": null, + "settings.showNotifications": null, "filesharing.saveFileError": "Încercarea de a salva fișierul a eșuat", "filesharing.startingFileShare": "Partajarea fișierului", diff --git a/app/src/translations/tr.json b/app/src/translations/tr.json index 8f9c29f..16aaae0 100644 --- a/app/src/translations/tr.json +++ b/app/src/translations/tr.json @@ -127,6 +127,7 @@ "settings.lastn": "İzlenebilir video sayısı", "settings.hiddenControls": null, "settings.notificationSounds": null, + "settings.showNotifications": null, "filesharing.saveFileError": "Dosya kaydedilemiyor", "filesharing.startingFileShare": "Paylaşılan dosyaya erişiliyor", diff --git a/app/src/translations/uk.json b/app/src/translations/uk.json index db4a082..fe9dd52 100644 --- a/app/src/translations/uk.json +++ b/app/src/translations/uk.json @@ -130,6 +130,7 @@ "settings.lastn": "Кількість видимих ​​відео", "settings.hiddenControls": null, "settings.notificationSounds": null, + "settings.showNotifications": null, "filesharing.saveFileError": "Неможливо зберегти файл", "filesharing.startingFileShare": "Спроба поділитися файлом", From 7c134039212fd0b60eb7a84e5663ead46ce4f800 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9sz=C3=A1ros=20Mih=C3=A1ly?= Date: Wed, 6 May 2020 23:39:40 +0200 Subject: [PATCH 49/88] Add help and about modal --- app/src/actions/roomActions.js | 12 +++ app/src/components/Controls/About.js | 133 ++++++++++++++++++++++++++ app/src/components/Controls/Help.js | 99 +++++++++++++++++++ app/src/components/Controls/TopBar.js | 54 +++++++++++ app/src/components/Room.js | 9 ++ app/src/reducers/room.js | 16 ++++ 6 files changed, 323 insertions(+) create mode 100644 app/src/components/Controls/About.js create mode 100644 app/src/components/Controls/Help.js diff --git a/app/src/actions/roomActions.js b/app/src/actions/roomActions.js index b90bf1b..7c44835 100644 --- a/app/src/actions/roomActions.js +++ b/app/src/actions/roomActions.js @@ -70,6 +70,18 @@ export const setExtraVideoOpen = (extraVideoOpen) => payload : { extraVideoOpen } }); +export const setHelpOpen = (helpOpen) => + ({ + type : 'SET_HELP_OPEN', + payload : { helpOpen } + }); + +export const setAboutOpen = (aboutOpen) => + ({ + type : 'SET_ABOUT_OPEN', + payload : { aboutOpen } + }); + export const setSettingsTab = (tab) => ({ type : 'SET_SETTINGS_TAB', diff --git a/app/src/components/Controls/About.js b/app/src/components/Controls/About.js new file mode 100644 index 0000000..d361a8c --- /dev/null +++ b/app/src/components/Controls/About.js @@ -0,0 +1,133 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { withStyles } from '@material-ui/core/styles'; +import { withRoomContext } from '../../RoomContext'; +import * as roomActions from '../../actions/roomActions'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogContentText from '@material-ui/core/DialogContentText'; +import Link from '@material-ui/core/Link'; +import Button from '@material-ui/core/Button'; + +const styles = (theme) => + ({ + dialogPaper : + { + width : '30vw', + [theme.breakpoints.down('lg')] : + { + width : '40vw' + }, + [theme.breakpoints.down('md')] : + { + width : '50vw' + }, + [theme.breakpoints.down('sm')] : + { + width : '70vw' + }, + [theme.breakpoints.down('xs')] : + { + width : '90vw' + } + }, + logo : + { + marginRight : 'auto' + }, + link : + { + display : 'block', + textAlign : 'center' + } + }); + +const About = ({ + aboutOpen, + handleCloseAbout, + classes +}) => +{ + return ( + handleCloseAbout(false)} + classes={{ + paper : classes.dialogPaper + }} + > + + + + + + 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.
    +
    + 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 + Vereniging. Registered office: Hoekenrode 3, 1102BR + Amsterdam, The Netherlands. UK branch address: City House, + 126-130 Hills Road, Cambridge CB2 1PQ, UK. +
    + + https://edumeet.org + +
    + + { window.config.logo && Logo } + + +
    + ); +}; + +About.propTypes = +{ + roomClient : PropTypes.object.isRequired, + aboutOpen : PropTypes.bool.isRequired, + handleCloseAbout : PropTypes.func.isRequired, + classes : PropTypes.object.isRequired +}; + +const mapStateToProps = (state) => + ({ + aboutOpen : state.room.aboutOpen + }); + +const mapDispatchToProps = { + handleCloseAbout : roomActions.setAboutOpen +}; + +export default withRoomContext(connect( + mapStateToProps, + mapDispatchToProps, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.room.aboutOpen === next.room.aboutOpen + ); + } + } +)(withStyles(styles)(About))); \ No newline at end of file diff --git a/app/src/components/Controls/Help.js b/app/src/components/Controls/Help.js new file mode 100644 index 0000000..67be03f --- /dev/null +++ b/app/src/components/Controls/Help.js @@ -0,0 +1,99 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { withStyles } from '@material-ui/core/styles'; +import { withRoomContext } from '../../RoomContext'; +import * as roomActions from '../../actions/roomActions'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +import Dialog from '@material-ui/core/Dialog'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogActions from '@material-ui/core/DialogActions'; +import Button from '@material-ui/core/Button'; + +const styles = (theme) => + ({ + dialogPaper : + { + width : '30vw', + [theme.breakpoints.down('lg')] : + { + width : '40vw' + }, + [theme.breakpoints.down('md')] : + { + width : '50vw' + }, + [theme.breakpoints.down('sm')] : + { + width : '70vw' + }, + [theme.breakpoints.down('xs')] : + { + width : '90vw' + } + } + }); + +const Help = ({ + helpOpen, + handleCloseHelp, + classes +}) => +{ + return ( + { handleCloseHelp(false); }} + classes={{ + paper : classes.dialogPaper + }} + > + + + + + + + + ); +}; + +Help.propTypes = +{ + roomClient : PropTypes.object.isRequired, + helpOpen : PropTypes.bool.isRequired, + handleCloseHelp : PropTypes.func.isRequired, + classes : PropTypes.object.isRequired +}; + +const mapStateToProps = (state) => + ({ + helpOpen : state.room.helpOpen + }); + +const mapDispatchToProps = { + handleCloseHelp : roomActions.setHelpOpen +}; + +export default withRoomContext(connect( + mapStateToProps, + mapDispatchToProps, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.room.helpOpen === next.room.helpOpen + ); + } + } +)(withStyles(styles)(Help))); \ No newline at end of file diff --git a/app/src/components/Controls/TopBar.js b/app/src/components/Controls/TopBar.js index 6422788..028b883 100644 --- a/app/src/components/Controls/TopBar.js +++ b/app/src/components/Controls/TopBar.js @@ -36,6 +36,8 @@ import VideoCallIcon from '@material-ui/icons/VideoCall'; import Button from '@material-ui/core/Button'; import Tooltip from '@material-ui/core/Tooltip'; import MoreIcon from '@material-ui/icons/MoreVert'; +import HelpIcon from '@material-ui/icons/Help'; +import InfoIcon from '@material-ui/icons/Info'; const styles = (theme) => ({ @@ -192,6 +194,8 @@ const TopBar = (props) => onFullscreen, setSettingsOpen, setExtraVideoOpen, + setHelpOpen, + setAboutOpen, setLockDialogOpen, toggleToolArea, openUsersTab, @@ -483,6 +487,46 @@ const TopBar = (props) => />

    + + { + handleMenuClose(); + setHelpOpen(!room.helpOpen); + }} + > + +

    + +

    +
    + + { + handleMenuClose(); + setAboutOpen(!room.aboutOpen); + }} + > + +

    + +

    +
    } @@ -694,6 +738,8 @@ TopBar.propTypes = setToolbarsVisible : PropTypes.func.isRequired, setSettingsOpen : PropTypes.func.isRequired, setExtraVideoOpen : PropTypes.func.isRequired, + setHelpOpen : PropTypes.func.isRequired, + setAboutOpen : PropTypes.func.isRequired, setLockDialogOpen : PropTypes.func.isRequired, toggleToolArea : PropTypes.func.isRequired, openUsersTab : PropTypes.func.isRequired, @@ -741,6 +787,14 @@ const mapDispatchToProps = (dispatch) => { dispatch(roomActions.setExtraVideoOpen(extraVideoOpen)); }, + setHelpOpen : (helpOpen) => + { + dispatch(roomActions.setHelpOpen(helpOpen)); + }, + setAboutOpen : (aboutOpen) => + { + dispatch(roomActions.setAboutOpen(aboutOpen)); + }, setLockDialogOpen : (lockDialogOpen) => { dispatch(roomActions.setLockDialogOpen(lockDialogOpen)); diff --git a/app/src/components/Room.js b/app/src/components/Room.js index cdda66f..505fc69 100644 --- a/app/src/components/Room.js +++ b/app/src/components/Room.js @@ -25,6 +25,8 @@ import Settings from './Settings/Settings'; import TopBar from './Controls/TopBar'; import WakeLock from 'react-wakelock-react16'; import ExtraVideo from './Controls/ExtraVideo'; +import Help from './Controls/Help'; +import About from './Controls/About'; const TIMEOUT = 5 * 1000; @@ -222,6 +224,13 @@ class Room extends React.PureComponent { room.extraVideoOpen && } + { room.helpOpen && + + } + { room.aboutOpen && + + } +
    ); } diff --git a/app/src/reducers/room.js b/app/src/reducers/room.js index 6d47d42..1a4b99e 100644 --- a/app/src/reducers/room.js +++ b/app/src/reducers/room.js @@ -22,6 +22,8 @@ const initialState = spotlights : [], settingsOpen : false, extraVideoOpen : false, + helpOpen : false, + aboutOpen : false, currentSettingsTab : 'media', // media, appearence, advanced lockDialogOpen : false, joined : false, @@ -130,6 +132,20 @@ const room = (state = initialState, action) => return { ...state, extraVideoOpen }; } + case 'SET_HELP_OPEN': + { + const { helpOpen } = action.payload; + + return { ...state, helpOpen }; + } + + case 'SET_ABOUT_OPEN': + { + const { aboutOpen } = action.payload; + + return { ...state, aboutOpen }; + } + case 'SET_SETTINGS_TAB': { const { tab } = action.payload; From 7e9160927684e85d73ab41dcf8359b1ce7f42617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Thu, 7 May 2020 12:20:24 +0200 Subject: [PATCH 50/88] Have a global try-catch in the server --- server/package.json | 2 +- server/server.js | 101 ++++++++++++++++++++------------------------ 2 files changed, 46 insertions(+), 57 deletions(-) diff --git a/server/package.json b/server/package.json index ef96f36..d407d03 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,7 @@ "license": "MIT", "main": "lib/index.js", "scripts": { - "start": "DEBUG=${DEBUG:='*mediasoup* *INFO* *WARN* *ERROR*'} INTERACTIVE=${INTERACTIVE:='true'} node server.js", + "start": "node server.js", "connect": "node connect.js", "lint": "eslint -c .eslintrc.json --ext .js *.js lib/" }, diff --git a/server/server.js b/server/server.js index 5820413..fcde0b1 100755 --- a/server/server.js +++ b/server/server.js @@ -127,69 +127,58 @@ let oidcStrategy; async function run() { - // Open the interactive server. - await interactiveServer(rooms, peers); - - // start Prometheus exporter - if (config.prometheus) + try { - await promExporter(rooms, peers, config.prometheus); - } + // Open the interactive server. + await interactiveServer(rooms, peers); - if (typeof(config.auth) === 'undefined') - { - logger.warn('Auth is not configured properly!'); - } - else - { - await setupAuth(); - } - - // Run a mediasoup Worker. - await runMediasoupWorkers(); - - // Run HTTPS server. - await runHttpsServer(); - - // Run WebSocketServer. - await runWebSocketServer(); - - // eslint-disable-next-line no-unused-vars - function errorHandler(err, req, res, next) - { - const trackingId = uuidv4(); - - res.status(500).send( - `

    Internal Server Error

    -

    If you report this error, please also report this - tracking ID which makes it possible to locate your session - in the logs which are available to the system administrator: - ${trackingId}

    ` - ); - logger.error( - 'Express error handler dump with tracking ID: %s, error dump: %o', - trackingId, err); - } - - app.use(errorHandler); - - // Log rooms status every 30 seconds. - setInterval(() => - { - for (const room of rooms.values()) + // start Prometheus exporter + if (config.prometheus) { - room.logStatus(); + await promExporter(rooms, peers, config.prometheus); } - }, 120000); - // check for deserted rooms - setInterval(() => - { - for (const room of rooms.values()) + if (typeof(config.auth) === 'undefined') { - room.checkEmpty(); + logger.warn('Auth is not configured properly!'); } - }, 10000); + else + { + await setupAuth(); + } + + // Run a mediasoup Worker. + await runMediasoupWorkers(); + + // Run HTTPS server. + await runHttpsServer(); + + // Run WebSocketServer. + await runWebSocketServer(); + + const errorHandler = (err, req, res, next) => + { + const trackingId = uuidv4(); + + res.status(500).send( + `

    Internal Server Error

    +

    If you report this error, please also report this + tracking ID which makes it possible to locate your session + in the logs which are available to the system administrator: + ${trackingId}

    ` + ); + logger.error( + 'Express error handler dump with tracking ID: %s, error dump: %o', + trackingId, err); + }; + + // eslint-disable-next-line no-unused-vars + app.use(errorHandler); + } + catch (error) + { + logger.error('run() [error:"%o"]', error); + } } function statusLog() From cfdaceed223039a4d9a1d727610ae9b7f1ef1a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Thu, 7 May 2020 13:35:29 +0200 Subject: [PATCH 51/88] Add posibility to have separate media buttons, fixes #309 --- app/public/config/config.example.js | 19 +- app/src/actions/settingsActions.js | 5 + app/src/components/Containers/Me.js | 212 ++++---- app/src/components/Containers/Peer.js | 470 +++++++++--------- .../components/Controls/ButtonControlBar.js | 256 ++++++++++ app/src/components/MeetingViews/Democratic.js | 52 +- app/src/components/Room.js | 9 + .../components/Settings/AppearenceSettings.js | 11 + app/src/components/Settings/MediaSettings.js | 6 +- app/src/reducers/settings.js | 8 + app/src/translations/cn.json | 4 + app/src/translations/cs.json | 4 + app/src/translations/de.json | 4 + app/src/translations/dk.json | 4 + app/src/translations/el.json | 4 + app/src/translations/en.json | 4 + app/src/translations/es.json | 4 + app/src/translations/fr.json | 4 + app/src/translations/hr.json | 4 + app/src/translations/hu.json | 4 + app/src/translations/it.json | 4 + app/src/translations/lv.json | 4 + app/src/translations/nb.json | 4 + app/src/translations/pl.json | 4 + app/src/translations/pt.json | 4 + app/src/translations/ro.json | 4 + app/src/translations/tr.json | 4 + app/src/translations/uk.json | 4 + 28 files changed, 739 insertions(+), 381 deletions(-) create mode 100644 app/src/components/Controls/ButtonControlBar.js diff --git a/app/public/config/config.example.js b/app/public/config/config.example.js index b591c16..38334f3 100644 --- a/app/public/config/config.example.js +++ b/app/public/config/config.example.js @@ -52,18 +52,21 @@ var config = noiseSuppression : true, sampleSize : 16 }, - background : 'images/background.jpg', - defaultLayout : 'democratic', // democratic, filmstrip - lastN : 4, - mobileLastN : 1, + background : 'images/background.jpg', + defaultLayout : 'democratic', // democratic, filmstrip + // If true, will show media control buttons in separate + // control bar, not in the ME container. + buttonControlBar : false, + lastN : 4, + mobileLastN : 1, // Highest number of speakers user can select - maxLastN : 5, + maxLastN : 5, // If truthy, users can NOT change number of speakers visible - lockLastN : false, + lockLastN : false, // Add file and uncomment for adding logo to appbar // logo : 'images/logo.svg', - title : 'Multiparty meeting', - theme : + title : 'Multiparty meeting', + theme : { palette : { diff --git a/app/src/actions/settingsActions.js b/app/src/actions/settingsActions.js index 63c12bf..4b7dc6d 100644 --- a/app/src/actions/settingsActions.js +++ b/app/src/actions/settingsActions.js @@ -38,6 +38,11 @@ export const togglePermanentTopBar = () => type : 'TOGGLE_PERMANENT_TOPBAR' }); +export const toggleButtonControlBar = () => + ({ + type : 'TOGGLE_BUTTON_CONTROL_BAR' + }); + export const toggleShowNotifications = () => ({ type : 'TOGGLE_SHOW_NOTIFICATIONS' diff --git a/app/src/components/Containers/Me.js b/app/src/components/Containers/Me.js index f368089..8279a61 100644 --- a/app/src/components/Containers/Me.js +++ b/app/src/components/Containers/Me.js @@ -77,7 +77,29 @@ const styles = (theme) => { position : 'relative', width : '100%', - height : '100%' + height : '100%', + '& p' : + { + position : 'absolute', + float : 'left', + top : '50%', + left : '50%', + transform : 'translate(-50%, -50%)', + color : 'rgba(255, 255, 255, 0.5)', + fontSize : '7em', + zIndex : 30, + margin : 0, + opacity : 0, + transition : 'opacity 0.1s ease-in-out', + '&.hover' : + { + opacity : 1 + }, + '&.smallContainer' : + { + fontSize : '3em' + } + } }, controls : { @@ -100,27 +122,6 @@ const styles = (theme) => '&.hover' : { opacity : 1 - }, - '& p' : - { - position : 'absolute', - float : 'left', - top : '50%', - left : '50%', - transform : 'translate(-50%, -50%)', - color : 'rgba(255, 255, 255, 0.5)', - fontSize : '7em', - margin : 0, - opacity : 0, - transition : 'opacity 0.1s ease-in-out', - '&.hover' : - { - opacity : 1 - }, - '&.smallContainer' : - { - fontSize : '3em' - } } }, ptt : @@ -330,47 +331,46 @@ const Me = (props) => />
    } -
    setHover(true)} - onMouseOut={() => setHover(false)} - onTouchStart={() => - { - if (touchTimeout) - clearTimeout(touchTimeout); - - setHover(true); - }} - onTouchEnd={() => - { - if (touchTimeout) - clearTimeout(touchTimeout); - - touchTimeout = setTimeout(() => - { - setHover(false); - }, 2000); - }} > -

    +

    + { !settings.buttonControlBar && +
    - -

    + onMouseOver={() => setHover(true)} + onMouseOut={() => setHover(false)} + onTouchStart={() => + { + if (touchTimeout) + clearTimeout(touchTimeout); - - -
    + setHover(true); + }} + onTouchEnd={() => + { + if (touchTimeout) + clearTimeout(touchTimeout); + + touchTimeout = setTimeout(() => + { + setHover(false); + }, 2000); + }} + > + + { smallContainer ? } } -
    -
    - -
    + + { smallContainer ? } } -
    -
    - { me.browser.platform !== 'mobile' && - -
    + + { me.browser.platform !== 'mobile' && + { smallContainer ? } } -
    -
    - } -
    -
    + + } + +
    + }

    -
    - { smallContainer ? - - { - roomClient.disableExtraVideo(producer.id); - }} - > - + { smallContainer ? + + { + roomClient.disableExtraVideo(producer.id); + }} + > + - - : - - { - roomClient.disableExtraVideo(producer.id); - }} - > - - - } -
    + + : + + { + roomClient.disableExtraVideo(producer.id); + }} + > + + + }
    diff --git a/app/src/components/Containers/Peer.js b/app/src/components/Containers/Peer.js index 4fc495e..ab90a13 100644 --- a/app/src/components/Containers/Peer.js +++ b/app/src/components/Containers/Peer.js @@ -249,55 +249,53 @@ const Peer = (props) => })} placement={smallScreen ? 'top' : 'left'} > -
    - { smallContainer ? - - { - micEnabled ? - roomClient.modifyPeerConsumer(peer.id, 'mic', true) : - roomClient.modifyPeerConsumer(peer.id, 'mic', false); - }} - > - { micEnabled ? - - : - - } - - : - - { - micEnabled ? - roomClient.modifyPeerConsumer(peer.id, 'mic', true) : - roomClient.modifyPeerConsumer(peer.id, 'mic', false); - }} - > - { micEnabled ? - - : - - } - - } -
    + { smallContainer ? + + { + micEnabled ? + roomClient.modifyPeerConsumer(peer.id, 'mic', true) : + roomClient.modifyPeerConsumer(peer.id, 'mic', false); + }} + > + { micEnabled ? + + : + + } + + : + + { + micEnabled ? + roomClient.modifyPeerConsumer(peer.id, 'mic', true) : + roomClient.modifyPeerConsumer(peer.id, 'mic', false); + }} + > + { micEnabled ? + + : + + } + + } { browser.platform !== 'mobile' && @@ -308,48 +306,46 @@ const Peer = (props) => })} placement={smallScreen ? 'top' : 'left'} > -
    - { smallContainer ? - - { - toggleConsumerWindow(webcamConsumer); - }} - > - - - : - - { - toggleConsumerWindow(webcamConsumer); - }} - > - - - } -
    + { smallContainer ? + + { + toggleConsumerWindow(webcamConsumer); + }} + > + + + : + + { + toggleConsumerWindow(webcamConsumer); + }} + > + + + } } @@ -360,42 +356,40 @@ const Peer = (props) => })} placement={smallScreen ? 'top' : 'left'} > -
    - { smallContainer ? - - { - toggleConsumerFullscreen(webcamConsumer); - }} - > - - - : - - { - toggleConsumerFullscreen(webcamConsumer); - }} - > - - - } -
    + { smallContainer ? + + { + toggleConsumerFullscreen(webcamConsumer); + }} + > + + + : + + { + toggleConsumerFullscreen(webcamConsumer); + }} + > + + + } @@ -507,48 +501,46 @@ const Peer = (props) => })} placement={smallScreen ? 'top' : 'left'} > -
    - { smallContainer ? - - { - toggleConsumerWindow(consumer); - }} - > - - - : - - { - toggleConsumerWindow(consumer); - }} - > - - - } -
    + { smallContainer ? + + { + toggleConsumerWindow(consumer); + }} + > + + + : + + { + toggleConsumerWindow(consumer); + }} + > + + + } } @@ -559,42 +551,40 @@ const Peer = (props) => })} placement={smallScreen ? 'top' : 'left'} > -
    - { smallContainer ? - - { - toggleConsumerFullscreen(consumer); - }} - > - - - : - - { - toggleConsumerFullscreen(consumer); - }} - > - - - } -
    + { smallContainer ? + + { + toggleConsumerFullscreen(consumer); + }} + > + + + : + + { + toggleConsumerFullscreen(consumer); + }} + > + + + } @@ -694,26 +684,24 @@ const Peer = (props) => })} placement={smallScreen ? 'top' : 'left'} > -
    - - { - toggleConsumerWindow(screenConsumer); - }} - > - - -
    + + { + toggleConsumerWindow(screenConsumer); + }} + > + + } @@ -724,23 +712,21 @@ const Peer = (props) => })} placement={smallScreen ? 'top' : 'left'} > -
    - - { - toggleConsumerFullscreen(screenConsumer); - }} - > - - -
    + + { + toggleConsumerFullscreen(screenConsumer); + }} + > + + + ({ + root : + { + position : 'fixed', + display : 'flex', + [theme.breakpoints.up('md')] : + { + top : '50%', + transform : 'translate(0%, -50%)', + flexDirection : 'column', + justifyContent : 'center', + alignItems : 'center', + left : theme.spacing(1) + }, + [theme.breakpoints.down('sm')] : + { + flexDirection : 'row', + bottom : theme.spacing(1), + left : '50%', + transform : 'translate(-50%, -0%)' + } + }, + fab : + { + margin : theme.spacing(1) + }, + show : + { + opacity : 1, + transition : 'opacity .5s' + }, + hide : + { + opacity : 0, + transition : 'opacity .5s' + } + }); + +const ButtonControlBar = (props) => +{ + const { + roomClient, + toolbarsVisible, + me, + micProducer, + webcamProducer, + screenProducer, + classes, + theme + } = props; + + let micState; + + let micTip; + + if (!me.canSendMic || !micProducer) + { + micState = 'unsupported'; + micTip = 'Audio unsupported'; + } + else if (!micProducer.locallyPaused && !micProducer.remotelyPaused) + { + micState = 'on'; + micTip = 'Mute audio'; + } + else + { + micState = 'off'; + micTip = 'Unmute audio'; + } + + let webcamState; + + let webcamTip; + + if (!me.canSendWebcam) + { + webcamState = 'unsupported'; + webcamTip = 'Video unsupported'; + } + else if (webcamProducer) + { + webcamState = 'on'; + webcamTip = 'Stop video'; + } + else + { + webcamState = 'off'; + webcamTip = 'Start video'; + } + + let screenState; + + let screenTip; + + if (!me.canShareScreen) + { + screenState = 'unsupported'; + screenTip = 'Screen sharing not supported'; + } + else if (screenProducer) + { + screenState = 'on'; + screenTip = 'Stop screen sharing'; + } + else + { + screenState = 'off'; + screenTip = 'Start screen sharing'; + } + + const smallScreen = useMediaQuery(theme.breakpoints.down('sm')); + + return ( +
    + + + { + micState === 'on' ? + roomClient.muteMic() : + roomClient.unmuteMic(); + }} + > + { micState === 'on' ? + + : + + } + + + + + { + webcamState === 'on' ? + roomClient.disableWebcam() : + roomClient.enableWebcam(); + }} + > + { webcamState === 'on' ? + + : + + } + + + + + { + switch (screenState) + { + case 'on': + { + roomClient.disableScreenSharing(); + break; + } + case 'off': + { + roomClient.enableScreenSharing(); + break; + } + default: + { + break; + } + } + }} + > + { screenState === 'on' || screenState === 'unsupported' ? + + :null + } + { screenState === 'off' ? + + :null + } + + +
    + ); +}; + +ButtonControlBar.propTypes = +{ + roomClient : PropTypes.any.isRequired, + toolbarsVisible : PropTypes.bool.isRequired, + me : appPropTypes.Me.isRequired, + micProducer : appPropTypes.Producer, + webcamProducer : appPropTypes.Producer, + screenProducer : appPropTypes.Producer, + classes : PropTypes.object.isRequired, + theme : PropTypes.object.isRequired +}; + +const mapStateToProps = (state) => + ({ + toolbarsVisible : state.room.toolbarsVisible, + ...meProducersSelector(state), + me : state.me + }); + +export default withRoomContext(connect( + mapStateToProps, + null, + null, + { + areStatesEqual : (next, prev) => + { + return ( + prev.room.toolbarsVisible === next.room.toolbarsVisible && + prev.producers === next.producers && + prev.me === next.me + ); + } + } +)(withStyles(styles, { withTheme: true })(ButtonControlBar))); \ No newline at end of file diff --git a/app/src/components/MeetingViews/Democratic.js b/app/src/components/MeetingViews/Democratic.js index e2a703e..f2fff1a 100644 --- a/app/src/components/MeetingViews/Democratic.js +++ b/app/src/components/MeetingViews/Democratic.js @@ -11,10 +11,9 @@ import Peer from '../Containers/Peer'; import Me from '../Containers/Me'; const RATIO = 1.334; -const PADDING_V = 50; -const PADDING_H = 0; +const PADDING = 60; -const styles = () => +const styles = (theme) => ({ root : { @@ -36,6 +35,14 @@ const styles = () => { paddingTop : 60, transition : 'padding .5s' + }, + buttonControlBar : + { + paddingLeft : 60, + [theme.breakpoints.down('sm')] : + { + paddingLeft : 0 + } } }); @@ -66,9 +73,11 @@ class Democratic extends React.PureComponent return; } - const width = this.peersRef.current.clientWidth - PADDING_H; - const height = this.peersRef.current.clientHeight - - (this.props.toolbarsVisible || this.props.permanentTopBar ? PADDING_V : PADDING_H); + const width = + this.peersRef.current.clientWidth - (this.props.buttonControlBar ? PADDING : 0); + const height = + this.peersRef.current.clientHeight - + (this.props.toolbarsVisible || this.props.permanentTopBar ? PADDING : 0); let x, y, space; @@ -130,6 +139,7 @@ class Democratic extends React.PureComponent spotlightsPeers, toolbarsVisible, permanentTopBar, + buttonControlBar, classes } = this.props; @@ -144,7 +154,8 @@ class Democratic extends React.PureComponent className={classnames( classes.root, toolbarsVisible || permanentTopBar ? - classes.showingToolBar : classes.hiddenToolBar + classes.showingToolBar : classes.hiddenToolBar, + buttonControlBar ? classes.buttonControlBar : null )} ref={this.peersRef} > @@ -172,21 +183,23 @@ class Democratic extends React.PureComponent Democratic.propTypes = { - advancedMode : PropTypes.bool, - boxes : PropTypes.number, - spotlightsPeers : PropTypes.array.isRequired, - toolbarsVisible : PropTypes.bool.isRequired, - permanentTopBar : PropTypes.bool, - classes : PropTypes.object.isRequired + advancedMode : PropTypes.bool, + boxes : PropTypes.number, + spotlightsPeers : PropTypes.array.isRequired, + toolbarsVisible : PropTypes.bool.isRequired, + permanentTopBar : PropTypes.bool.isRequired, + buttonControlBar : PropTypes.bool.isRequired, + classes : PropTypes.object.isRequired }; const mapStateToProps = (state) => { return { - boxes : videoBoxesSelector(state), - spotlightsPeers : spotlightPeersSelector(state), - toolbarsVisible : state.room.toolbarsVisible, - permanentTopBar : state.settings.permanentTopBar + boxes : videoBoxesSelector(state), + spotlightsPeers : spotlightPeersSelector(state), + toolbarsVisible : state.room.toolbarsVisible, + permanentTopBar : state.settings.permanentTopBar, + buttonControlBar : state.settings.buttonControlBar }; }; @@ -203,8 +216,9 @@ export default connect( prev.consumers === next.consumers && prev.room.spotlights === next.room.spotlights && prev.room.toolbarsVisible === next.room.toolbarsVisible && - prev.settings.permanentTopBar === next.settings.permanentTopBar + prev.settings.permanentTopBar === next.settings.permanentTopBar && + prev.settings.buttonControlBar === next.settings.buttonControlBar ); } } -)(withStyles(styles)(Democratic)); +)(withStyles(styles, { withTheme: true })(Democratic)); diff --git a/app/src/components/Room.js b/app/src/components/Room.js index 7562ee4..c216f46 100644 --- a/app/src/components/Room.js +++ b/app/src/components/Room.js @@ -25,6 +25,7 @@ import Settings from './Settings/Settings'; import TopBar from './Controls/TopBar'; import WakeLock from 'react-wakelock-react16'; import ExtraVideo from './Controls/ExtraVideo'; +import ButtonControlBar from './Controls/ButtonControlBar'; const TIMEOUT = 5 * 1000; @@ -143,6 +144,7 @@ class Room extends React.PureComponent browser, advancedMode, showNotifications, + buttonControlBar, toolAreaOpen, toggleToolArea, classes, @@ -214,6 +216,10 @@ class Room extends React.PureComponent + { buttonControlBar && + + } + { room.lockDialogOpen && } @@ -236,6 +242,7 @@ Room.propTypes = browser : PropTypes.object.isRequired, advancedMode : PropTypes.bool.isRequired, showNotifications : PropTypes.bool.isRequired, + buttonControlBar : PropTypes.bool.isRequired, toolAreaOpen : PropTypes.bool.isRequired, setToolbarsVisible : PropTypes.func.isRequired, toggleToolArea : PropTypes.func.isRequired, @@ -249,6 +256,7 @@ const mapStateToProps = (state) => browser : state.me.browser, advancedMode : state.settings.advancedMode, showNotifications : state.settings.showNotifications, + buttonControlBar : state.settings.buttonControlBar, toolAreaOpen : state.toolarea.toolAreaOpen }); @@ -276,6 +284,7 @@ export default connect( prev.me.browser === next.me.browser && prev.settings.advancedMode === next.settings.advancedMode && prev.settings.showNotifications === next.settings.showNotifications && + prev.settings.buttonControlBar === next.settings.buttonControlBar && prev.toolarea.toolAreaOpen === next.toolarea.toolAreaOpen ); } diff --git a/app/src/components/Settings/AppearenceSettings.js b/app/src/components/Settings/AppearenceSettings.js index a34a5e1..a859ab4 100644 --- a/app/src/components/Settings/AppearenceSettings.js +++ b/app/src/components/Settings/AppearenceSettings.js @@ -30,6 +30,7 @@ const AppearenceSettings = ({ settings, onTogglePermanentTopBar, onToggleHiddenControls, + onToggleButtonControlBar, onToggleShowNotifications, handleChangeMode, classes @@ -102,6 +103,14 @@ const AppearenceSettings = ({ defaultMessage : 'Hidden media controls' })} /> + } + label={intl.formatMessage({ + id : 'settings.buttonControlBar', + defaultMessage : 'Separate media controls' + })} + /> } @@ -120,6 +129,7 @@ AppearenceSettings.propTypes = settings : PropTypes.object.isRequired, onTogglePermanentTopBar : PropTypes.func.isRequired, onToggleHiddenControls : PropTypes.func.isRequired, + onToggleButtonControlBar : PropTypes.func.isRequired, onToggleShowNotifications : PropTypes.func.isRequired, handleChangeMode : PropTypes.func.isRequired, classes : PropTypes.object.isRequired @@ -135,6 +145,7 @@ const mapDispatchToProps = { onTogglePermanentTopBar : settingsActions.togglePermanentTopBar, onToggleHiddenControls : settingsActions.toggleHiddenControls, onToggleShowNotifications : settingsActions.toggleShowNotifications, + onToggleButtonControlBar : settingsActions.toggleButtonControlBar, handleChangeMode : roomActions.setDisplayMode }; diff --git a/app/src/components/Settings/MediaSettings.js b/app/src/components/Settings/MediaSettings.js index d215181..28a69a2 100644 --- a/app/src/components/Settings/MediaSettings.js +++ b/app/src/components/Settings/MediaSettings.js @@ -265,7 +265,7 @@ const MediaSettings = ({ />} label={intl.formatMessage({ id : 'settings.echoCancellation', - defaultMessage : 'Echo Cancellation' + defaultMessage : 'Echo cancellation' })} /> } label={intl.formatMessage({ id : 'settings.autoGainControl', - defaultMessage : 'Auto Gain Control' + defaultMessage : 'Auto gain control' })} /> } label={intl.formatMessage({ id : 'settings.noiseSuppression', - defaultMessage : 'Noise Suppression' + defaultMessage : 'Noise suppression' })} /> diff --git a/app/src/reducers/settings.js b/app/src/reducers/settings.js index 7186fdc..f306726 100644 --- a/app/src/reducers/settings.js +++ b/app/src/reducers/settings.js @@ -18,6 +18,7 @@ const initialState = hiddenControls : false, showNotifications : true, notificationSounds : true, + buttonControlBar : window.config.buttonControlBar || false, ...window.config.defaultAudio }; @@ -145,6 +146,13 @@ const settings = (state = initialState, action) => return { ...state, permanentTopBar }; } + case 'TOGGLE_BUTTON_CONTROL_BAR': + { + const buttonControlBar = !state.buttonControlBar; + + return { ...state, buttonControlBar }; + } + case 'TOGGLE_HIDDEN_CONTROLS': { const hiddenControls = !state.hiddenControls; diff --git a/app/src/translations/cn.json b/app/src/translations/cn.json index 26724a4..7da15eb 100644 --- a/app/src/translations/cn.json +++ b/app/src/translations/cn.json @@ -131,6 +131,10 @@ "settings.hiddenControls": null, "settings.notificationSounds": null, "settings.showNotifications": null, + "settings.buttonControlBar": null, + "settings.echoCancellation": null, + "settings.autoGainControl": null, + "settings.noiseSuppression": null, "filesharing.saveFileError": "无法保存文件", "filesharing.startingFileShare": "正在尝试共享文件", diff --git a/app/src/translations/cs.json b/app/src/translations/cs.json index b80ab14..ee859d2 100644 --- a/app/src/translations/cs.json +++ b/app/src/translations/cs.json @@ -130,6 +130,10 @@ "settings.hiddenControls": null, "settings.notificationSounds": null, "settings.showNotifications": null, + "settings.buttonControlBar": null, + "settings.echoCancellation": null, + "settings.autoGainControl": null, + "settings.noiseSuppression": null, "filesharing.saveFileError": "Není možné uložit soubor", "filesharing.startingFileShare": "Pokouším se sdílet soubor", diff --git a/app/src/translations/de.json b/app/src/translations/de.json index 3eabb28..428d104 100644 --- a/app/src/translations/de.json +++ b/app/src/translations/de.json @@ -131,6 +131,10 @@ "settings.hiddenControls": null, "settings.notificationSounds": null, "settings.showNotifications": null, + "settings.buttonControlBar": null, + "settings.echoCancellation": null, + "settings.autoGainControl": null, + "settings.noiseSuppression": null, "filesharing.saveFileError": "Fehler beim Speichern der Datei", "filesharing.startingFileShare": "Starte Teilen der Datei", diff --git a/app/src/translations/dk.json b/app/src/translations/dk.json index cab3586..c74026f 100644 --- a/app/src/translations/dk.json +++ b/app/src/translations/dk.json @@ -131,6 +131,10 @@ "settings.hiddenControls": null, "settings.notificationSounds": null, "settings.showNotifications": null, + "settings.buttonControlBar": null, + "settings.echoCancellation": null, + "settings.autoGainControl": null, + "settings.noiseSuppression": null, "filesharing.saveFileError": "Kan ikke gemme fil", "filesharing.startingFileShare": "Forsøger at dele filen", diff --git a/app/src/translations/el.json b/app/src/translations/el.json index 5a83427..10047cf 100644 --- a/app/src/translations/el.json +++ b/app/src/translations/el.json @@ -131,6 +131,10 @@ "settings.hiddenControls": null, "settings.notificationSounds": null, "settings.showNotifications": null, + "settings.buttonControlBar": null, + "settings.echoCancellation": null, + "settings.autoGainControl": null, + "settings.noiseSuppression": null, "filesharing.saveFileError": "Αδυναμία αποθήκευσης του αρχείου", "filesharing.startingFileShare": "Προσπάθεια διαμοιρασμού αρχείου", diff --git a/app/src/translations/en.json b/app/src/translations/en.json index 2b40a30..475acd2 100644 --- a/app/src/translations/en.json +++ b/app/src/translations/en.json @@ -131,6 +131,10 @@ "settings.hiddenControls": "Hidden media controls", "settings.notificationSounds": "Notification sounds", "settings.showNotifications": "Show notifications", + "settings.buttonControlBar": "Separate media controls", + "settings.echoCancellation": "Echo cancellation", + "settings.autoGainControl": "Auto gain control", + "settings.noiseSuppression": "Noise suppression", "filesharing.saveFileError": "Unable to save file", "filesharing.startingFileShare": "Attempting to share file", diff --git a/app/src/translations/es.json b/app/src/translations/es.json index 63c2f7d..6c757cd 100644 --- a/app/src/translations/es.json +++ b/app/src/translations/es.json @@ -131,6 +131,10 @@ "settings.hiddenControls": null, "settings.notificationSounds": null, "settings.showNotifications": null, + "settings.buttonControlBar": null, + "settings.echoCancellation": null, + "settings.autoGainControl": null, + "settings.noiseSuppression": null, "filesharing.saveFileError": "No ha sido posible guardar el fichero", "filesharing.startingFileShare": "Intentando compartir el fichero", diff --git a/app/src/translations/fr.json b/app/src/translations/fr.json index 4471861..73493f5 100644 --- a/app/src/translations/fr.json +++ b/app/src/translations/fr.json @@ -131,6 +131,10 @@ "settings.hiddenControls": null, "settings.notificationSounds": null, "settings.showNotifications": null, + "settings.buttonControlBar": null, + "settings.echoCancellation": null, + "settings.autoGainControl": null, + "settings.noiseSuppression": null, "filesharing.saveFileError": "Impossible d'enregistrer le fichier", "filesharing.startingFileShare": "Début du transfert de fichier", diff --git a/app/src/translations/hr.json b/app/src/translations/hr.json index 682dfbe..22db8fa 100644 --- a/app/src/translations/hr.json +++ b/app/src/translations/hr.json @@ -131,6 +131,10 @@ "settings.hiddenControls": "Skrivene kontrole medija", "settings.notificationSounds": "Zvuk obavijesti", "settings.showNotifications": null, + "settings.buttonControlBar": null, + "settings.echoCancellation": null, + "settings.autoGainControl": null, + "settings.noiseSuppression": null, "filesharing.saveFileError": "Nije moguće spremiti datoteku", "filesharing.startingFileShare": "Pokušaj dijeljenja datoteke", diff --git a/app/src/translations/hu.json b/app/src/translations/hu.json index 262f117..8c68590 100644 --- a/app/src/translations/hu.json +++ b/app/src/translations/hu.json @@ -131,6 +131,10 @@ "settings.hiddenControls": "Média Gombok automatikus elrejtése", "settings.notificationSounds": "Értesítések hangjelzéssel", "settings.showNotifications": null, + "settings.buttonControlBar": null, + "settings.echoCancellation": null, + "settings.autoGainControl": null, + "settings.noiseSuppression": null, "filesharing.saveFileError": "A file-t nem sikerült elmenteni", "filesharing.startingFileShare": "Fájl megosztása", diff --git a/app/src/translations/it.json b/app/src/translations/it.json index 27b3194..ba849d9 100644 --- a/app/src/translations/it.json +++ b/app/src/translations/it.json @@ -130,6 +130,10 @@ "settings.hiddenControls": "Controlli media nascosti", "settings.notificationSounds": "Suoni di notifica", "settings.showNotifications": null, + "settings.buttonControlBar": null, + "settings.echoCancellation": null, + "settings.autoGainControl": null, + "settings.noiseSuppression": null, "filesharing.saveFileError": "Impossibile salvare file", "filesharing.startingFileShare": "Tentativo di condivisione file", diff --git a/app/src/translations/lv.json b/app/src/translations/lv.json index d355c84..6d4e97b 100644 --- a/app/src/translations/lv.json +++ b/app/src/translations/lv.json @@ -125,6 +125,10 @@ "settings.hiddenControls": "Slēpto mediju vadība", "settings.notificationSounds": "Paziņojumu skaņas", "settings.showNotifications": null, + "settings.buttonControlBar": null, + "settings.echoCancellation": null, + "settings.autoGainControl": null, + "settings.noiseSuppression": null, "filesharing.saveFileError": "Nav iespējams saglabāt failu", "filesharing.startingFileShare": "Tiek mēģināts kopīgot failu", diff --git a/app/src/translations/nb.json b/app/src/translations/nb.json index 59edd6c..ea9b0b8 100644 --- a/app/src/translations/nb.json +++ b/app/src/translations/nb.json @@ -131,6 +131,10 @@ "settings.hiddenControls": "Skjul media knapper", "settings.notificationSounds": "Varslingslyder", "settings.showNotifications": "Vis varslinger", + "settings.buttonControlBar": "Separate media knapper", + "settings.echoCancellation": "Echokansellering", + "settings.autoGainControl": "Auto gain kontroll", + "settings.noiseSuppression": "Støy reduksjon", "filesharing.saveFileError": "Klarte ikke å lagre fil", "filesharing.startingFileShare": "Starter fildeling", diff --git a/app/src/translations/pl.json b/app/src/translations/pl.json index 1413c73..859a29f 100644 --- a/app/src/translations/pl.json +++ b/app/src/translations/pl.json @@ -131,6 +131,10 @@ "settings.hiddenControls": null, "settings.notificationSounds": null, "settings.showNotifications": null, + "settings.buttonControlBar": null, + "settings.echoCancellation": null, + "settings.autoGainControl": null, + "settings.noiseSuppression": null, "filesharing.saveFileError": "Nie można zapisać pliku", "filesharing.startingFileShare": "Próba udostępnienia pliku", diff --git a/app/src/translations/pt.json b/app/src/translations/pt.json index 3b0a078..7faed7c 100644 --- a/app/src/translations/pt.json +++ b/app/src/translations/pt.json @@ -131,6 +131,10 @@ "settings.hiddenControls": null, "settings.notificationSounds": null, "settings.showNotifications": null, + "settings.buttonControlBar": null, + "settings.echoCancellation": null, + "settings.autoGainControl": null, + "settings.noiseSuppression": null, "filesharing.saveFileError": "Impossível de gravar o ficheiro", "filesharing.startingFileShare": "Tentando partilha de ficheiro", diff --git a/app/src/translations/ro.json b/app/src/translations/ro.json index e666ddf..3188e91 100644 --- a/app/src/translations/ro.json +++ b/app/src/translations/ro.json @@ -131,6 +131,10 @@ "settings.hiddenControls": null, "settings.notificationSounds": null, "settings.showNotifications": null, + "settings.buttonControlBar": null, + "settings.echoCancellation": null, + "settings.autoGainControl": null, + "settings.noiseSuppression": null, "filesharing.saveFileError": "Încercarea de a salva fișierul a eșuat", "filesharing.startingFileShare": "Partajarea fișierului", diff --git a/app/src/translations/tr.json b/app/src/translations/tr.json index 16aaae0..aa6a8cb 100644 --- a/app/src/translations/tr.json +++ b/app/src/translations/tr.json @@ -128,6 +128,10 @@ "settings.hiddenControls": null, "settings.notificationSounds": null, "settings.showNotifications": null, + "settings.buttonControlBar": null, + "settings.echoCancellation": null, + "settings.autoGainControl": null, + "settings.noiseSuppression": null, "filesharing.saveFileError": "Dosya kaydedilemiyor", "filesharing.startingFileShare": "Paylaşılan dosyaya erişiliyor", diff --git a/app/src/translations/uk.json b/app/src/translations/uk.json index fe9dd52..4932fc4 100644 --- a/app/src/translations/uk.json +++ b/app/src/translations/uk.json @@ -131,6 +131,10 @@ "settings.hiddenControls": null, "settings.notificationSounds": null, "settings.showNotifications": null, + "settings.buttonControlBar": null, + "settings.echoCancellation": null, + "settings.autoGainControl": null, + "settings.noiseSuppression": null, "filesharing.saveFileError": "Неможливо зберегти файл", "filesharing.startingFileShare": "Спроба поділитися файлом", From 4c6d9291bf767384a67fea6b449eb05d942e7511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Thu, 7 May 2020 14:57:42 +0200 Subject: [PATCH 52/88] Fix regression on advanced mode, ref #309 --- app/src/components/Containers/Me.js | 43 +++++++++++++++-------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/app/src/components/Containers/Me.js b/app/src/components/Containers/Me.js index 8279a61..ead95b1 100644 --- a/app/src/components/Containers/Me.js +++ b/app/src/components/Containers/Me.js @@ -77,28 +77,28 @@ const styles = (theme) => { position : 'relative', width : '100%', - height : '100%', - '& p' : + height : '100%' + }, + meTag : + { + position : 'absolute', + float : 'left', + top : '50%', + left : '50%', + transform : 'translate(-50%, -50%)', + color : 'rgba(255, 255, 255, 0.5)', + fontSize : '7em', + zIndex : 30, + margin : 0, + opacity : 0, + transition : 'opacity 0.1s ease-in-out', + '&.hover' : { - position : 'absolute', - float : 'left', - top : '50%', - left : '50%', - transform : 'translate(-50%, -50%)', - color : 'rgba(255, 255, 255, 0.5)', - fontSize : '7em', - zIndex : 30, - margin : 0, - opacity : 0, - transition : 'opacity 0.1s ease-in-out', - '&.hover' : - { - opacity : 1 - }, - '&.smallContainer' : - { - fontSize : '3em' - } + opacity : 1 + }, + '&.smallContainer' : + { + fontSize : '3em' } }, controls : @@ -333,6 +333,7 @@ const Me = (props) => }

    Date: Thu, 7 May 2020 15:15:44 +0200 Subject: [PATCH 53/88] Respect hide media buttons setting on button control bar, ref #309 --- app/public/config/config.example.js | 2 ++ app/src/components/Controls/ButtonControlBar.js | 10 +++++++++- app/src/components/Room.js | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/public/config/config.example.js b/app/public/config/config.example.js index 38334f3..4bbc13f 100644 --- a/app/public/config/config.example.js +++ b/app/public/config/config.example.js @@ -57,6 +57,8 @@ var config = // If true, will show media control buttons in separate // control bar, not in the ME container. buttonControlBar : false, + // Timeout for autohiding topbar and button control bar + hideTimeout : 3000, lastN : 4, mobileLastN : 1, // Highest number of speakers user can select diff --git a/app/src/components/Controls/ButtonControlBar.js b/app/src/components/Controls/ButtonControlBar.js index a70c43e..2ee5b7e 100644 --- a/app/src/components/Controls/ButtonControlBar.js +++ b/app/src/components/Controls/ButtonControlBar.js @@ -60,6 +60,7 @@ const ButtonControlBar = (props) => const { roomClient, toolbarsVisible, + hiddenControls, me, micProducer, webcamProducer, @@ -133,7 +134,11 @@ const ButtonControlBar = (props) => return (

    @@ -224,6 +229,7 @@ ButtonControlBar.propTypes = { roomClient : PropTypes.any.isRequired, toolbarsVisible : PropTypes.bool.isRequired, + hiddenControls : PropTypes.bool.isRequired, me : appPropTypes.Me.isRequired, micProducer : appPropTypes.Producer, webcamProducer : appPropTypes.Producer, @@ -235,6 +241,7 @@ ButtonControlBar.propTypes = const mapStateToProps = (state) => ({ toolbarsVisible : state.room.toolbarsVisible, + hiddenControls : state.settings.hiddenControls, ...meProducersSelector(state), me : state.me }); @@ -248,6 +255,7 @@ export default withRoomContext(connect( { return ( prev.room.toolbarsVisible === next.room.toolbarsVisible && + prev.settings.hiddenControls === next.settings.hiddenControls && prev.producers === next.producers && prev.me === next.me ); diff --git a/app/src/components/Room.js b/app/src/components/Room.js index c216f46..b6027ba 100644 --- a/app/src/components/Room.js +++ b/app/src/components/Room.js @@ -27,7 +27,7 @@ import WakeLock from 'react-wakelock-react16'; import ExtraVideo from './Controls/ExtraVideo'; import ButtonControlBar from './Controls/ButtonControlBar'; -const TIMEOUT = 5 * 1000; +const TIMEOUT = window.config.hideTimeout || 5000; const styles = (theme) => ({ From 2373bf44d8733e101d0f8cec4c948290d893cc91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Thu, 7 May 2020 21:03:07 +0200 Subject: [PATCH 54/88] Allow serving other than root of site, fixes #249 --- app/src/RoomClient.js | 8 ++++++-- app/src/index.js | 12 ++++++++++-- server/server.js | 2 +- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index df276ae..a48a428 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -129,7 +129,8 @@ export default class RoomClient produce, forceTcp, displayName, - muted + muted, + basePath } = {}) { if (!peerId) @@ -152,6 +153,9 @@ export default class RoomClient // Whether we force TCP this._forceTcp = forceTcp; + // URL basepath + this._basePath = basePath; + // Use displayName if (displayName) store.dispatch(settingsActions.setDisplayName(displayName)); @@ -1719,7 +1723,7 @@ export default class RoomClient this._screenSharing = ScreenShare.create(this._device); - this._signalingSocket = io(this._signalingUrl); + this._signalingSocket = io(this._signalingUrl, { path: this._basePath }); this._spotlights = new Spotlights(this._maxSpotlights, this._signalingSocket); diff --git a/app/src/index.js b/app/src/index.js index cdefb40..e89d06d 100644 --- a/app/src/index.js +++ b/app/src/index.js @@ -115,6 +115,13 @@ function run() const forceTcp = parameters.get('forceTcp') === 'true'; const displayName = parameters.get('displayName'); const muted = parameters.get('muted') === 'true'; + + const { pathname } = window.location; + + let basePath = pathname.substring(0, pathname.lastIndexOf('/')); + + if (!basePath) + basePath = '/'; // Get current device. const device = deviceInfo(); @@ -134,7 +141,8 @@ function run() produce, forceTcp, displayName, - muted + muted, + basePath }); global.CLIENT = roomClient; @@ -146,7 +154,7 @@ function run() } persistor={persistor}> - + }> diff --git a/server/server.js b/server/server.js index fcde0b1..9fb775b 100755 --- a/server/server.js +++ b/server/server.js @@ -494,7 +494,7 @@ function isPathAlreadyTaken(url) */ async function runWebSocketServer() { - io = require('socket.io')(mainListener); + io = require('socket.io')(mainListener, { path: '/' }); io.use( sharedSession(session, { From ce8141eed7f5a97326df79806c184e45cc8b8bae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Thu, 7 May 2020 21:03:22 +0200 Subject: [PATCH 55/88] Cleanup --- server/lib/Peer.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/lib/Peer.js b/server/lib/Peer.js index 163e648..8e11ca2 100644 --- a/server/lib/Peer.js +++ b/server/lib/Peer.js @@ -64,10 +64,10 @@ class Peer extends EventEmitter // Iterate and close all mediasoup Transport associated to this Peer, so all // its Producers and Consumers will also be closed. - this.transports.forEach((transport) => + for (const transport of this.transports.values()) { transport.close(); - }); + } if (this.socket) this.socket.disconnect(true); From 16a59f1167aee0b561281b3dfb889d0f253b7e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Thu, 7 May 2020 22:14:48 +0200 Subject: [PATCH 56/88] Don't show video mute icon for peer that does not have video visible. Fixes #302 --- .../MeetingDrawer/ParticipantList/ListPeer.js | 58 ++++++++++--------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js index d8b3fb3..7e163f0 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js @@ -126,7 +126,7 @@ const ListPeer = (props) => } - { screenConsumer && + { screenConsumer && spotlight && } - - - { - e.stopPropagation(); - - webcamEnabled ? - roomClient.modifyPeerConsumer(peer.id, 'webcam', true) : - roomClient.modifyPeerConsumer(peer.id, 'webcam', false); - }} + placement='bottom' > - { webcamEnabled ? - - : - - } - - + + { + e.stopPropagation(); + + webcamEnabled ? + roomClient.modifyPeerConsumer(peer.id, 'webcam', true) : + roomClient.modifyPeerConsumer(peer.id, 'webcam', false); + }} + > + { webcamEnabled ? + + : + + } + + + } Date: Thu, 7 May 2020 23:22:46 +0200 Subject: [PATCH 57/88] Revert server change, seems to be a problem with it. Ref #249 --- server/server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/server.js b/server/server.js index 9fb775b..fcde0b1 100755 --- a/server/server.js +++ b/server/server.js @@ -494,7 +494,7 @@ function isPathAlreadyTaken(url) */ async function runWebSocketServer() { - io = require('socket.io')(mainListener, { path: '/' }); + io = require('socket.io')(mainListener); io.use( sharedSession(session, { From 837aa1ace26b86b377d5394f9517a20ca9cfb60a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Fri, 8 May 2020 00:11:39 +0200 Subject: [PATCH 58/88] Revert change for now, ref #249 --- app/src/RoomClient.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index a48a428..dba7c64 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -1723,7 +1723,7 @@ export default class RoomClient this._screenSharing = ScreenShare.create(this._device); - this._signalingSocket = io(this._signalingUrl, { path: this._basePath }); + this._signalingSocket = io(this._signalingUrl); this._spotlights = new Spotlights(this._maxSpotlights, this._signalingSocket); From 3b779bd81d1a2c61400667618c5bb90f9255c8f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9sz=C3=A1ros=20Mih=C3=A1ly?= Date: Fri, 8 May 2020 00:20:35 +0200 Subject: [PATCH 59/88] Add shotcuts to help and add labels --- app/src/components/Controls/Help.js | 75 +++++++++++++++++++++++++-- app/src/components/Controls/TopBar.js | 8 +-- app/src/translations/cn.json | 5 +- app/src/translations/cs.json | 3 ++ app/src/translations/de.json | 3 ++ app/src/translations/dk.json | 3 ++ app/src/translations/el.json | 3 ++ app/src/translations/en.json | 3 ++ app/src/translations/es.json | 3 ++ app/src/translations/fr.json | 3 ++ app/src/translations/hr.json | 3 ++ app/src/translations/hu.json | 3 ++ app/src/translations/it.json | 5 +- app/src/translations/lv.json | 3 ++ app/src/translations/nb.json | 3 ++ app/src/translations/pl.json | 3 ++ app/src/translations/pt.json | 3 ++ app/src/translations/ro.json | 3 ++ app/src/translations/tr.json | 3 ++ app/src/translations/uk.json | 3 ++ 20 files changed, 132 insertions(+), 9 deletions(-) diff --git a/app/src/components/Controls/Help.js b/app/src/components/Controls/Help.js index 67be03f..3091ec4 100644 --- a/app/src/components/Controls/Help.js +++ b/app/src/components/Controls/Help.js @@ -4,13 +4,26 @@ import { withStyles } from '@material-ui/core/styles'; import { withRoomContext } from '../../RoomContext'; import * as roomActions from '../../actions/roomActions'; import PropTypes from 'prop-types'; -import { FormattedMessage } from 'react-intl'; +import { useIntl, FormattedMessage } from 'react-intl'; import Dialog from '@material-ui/core/Dialog'; import DialogTitle from '@material-ui/core/DialogTitle'; import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogContentText from '@material-ui/core/DialogContentText'; import Button from '@material-ui/core/Button'; +import Paper from '@material-ui/core/Paper'; +import Tabs from '@material-ui/core/Tabs'; +import Tab from '@material-ui/core/Tab'; +const shortcuts=[ + { key: 'm', label: 'device.muteAudio', defaultMessage: 'Mute Audio' }, + { key: 'v', label: 'device.stopVideo', defaultMessage: 'Mute Video' }, + { key: '1', label: 'label.democratic', defaultMessage: 'Democratic View' }, + { key: '2', label: 'label.filmstrip', defaultMessage: 'Filmstrip View' }, + { key: 'space', label: 'me.mutedPTT', defaultMessage: 'Push SPACE to talk' }, + { key: 'a', label: 'label.advanced', defaultMessage: 'Show advanced information' } +]; const styles = (theme) => ({ dialogPaper : @@ -31,7 +44,27 @@ const styles = (theme) => [theme.breakpoints.down('xs')] : { width : '90vw' - } + }, + display : 'flex', + flexDirection : 'column' + }, + paper : { + padding : theme.spacing(1), + textAlign : 'center', + color : theme.palette.text.secondary, + whiteSpace : 'nowrap', + marginRight : theme.spacing(3), + marginBottom : theme.spacing(1), + minWidth : theme.spacing(8) + }, + shortcuts : { + display : 'flex', + flexDirection : 'row', + alignItems : 'center' + }, + tabsHeader : + { + flexGrow : 1 } }); @@ -41,6 +74,8 @@ const Help = ({ classes }) => { + const intl = useIntl(); + return ( + + + + + + {shortcuts.map((value, index) => + { + return ( +
    + + {value.key} + + +
    + ); + })} + +
    +
    ); diff --git a/app/src/reducers/peers.js b/app/src/reducers/peers.js index 3e2c6a0..6c4fd1f 100644 --- a/app/src/reducers/peers.js +++ b/app/src/reducers/peers.js @@ -68,6 +68,18 @@ const peer = (state = {}, action) => return { ...state, roles }; } + case 'STOP_PEER_AUDIO_IN_PROGRESS': + return { + ...state, + stopPeerAudioInProgress : action.payload.flag + }; + + case 'STOP_PEER_VIDEO_IN_PROGRESS': + return { + ...state, + stopPeerVideoInProgress : action.payload.flag + }; + default: return state; } @@ -102,6 +114,8 @@ const peers = (state = {}, action) => case 'ADD_CONSUMER': case 'ADD_PEER_ROLE': case 'REMOVE_PEER_ROLE': + case 'STOP_PEER_AUDIO_IN_PROGRESS': + case 'STOP_PEER_VIDEO_IN_PROGRESS': { const oldPeer = state[action.payload.peerId]; diff --git a/server/lib/Room.js b/server/lib/Room.js index 02ba41c..77f00ee 100644 --- a/server/lib/Room.js +++ b/server/lib/Room.js @@ -1369,6 +1369,29 @@ class Room extends EventEmitter break; } + case 'moderator:mute': + { + if ( + !peer.roles.some( + (role) => permissionsFromRoles.MODERATE_ROOM.includes(role) + ) + ) + throw new Error('peer not authorized'); + + const { peerId } = request.data; + + const mutePeer = this._peers[peerId]; + + if (!mutePeer) + throw new Error(`peer with id "${peerId}" not found`); + + this._notification(mutePeer.socket, 'moderator:mute'); + + cb(); + + break; + } + case 'moderator:muteAll': { if ( @@ -1386,6 +1409,29 @@ class Room extends EventEmitter break; } + case 'moderator:stopVideo': + { + if ( + !peer.roles.some( + (role) => permissionsFromRoles.MODERATE_ROOM.includes(role) + ) + ) + throw new Error('peer not authorized'); + + const { peerId } = request.data; + + const stopVideoPeer = this._peers[peerId]; + + if (!stopVideoPeer) + throw new Error(`peer with id "${peerId}" not found`); + + this._notification(stopVideoPeer.socket, 'moderator:stopVideo'); + + cb(); + + break; + } + case 'moderator:stopAllVideo': { if ( From 0de99d6da8a4cec323a6cd8dcfb63b79bbe181ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9sz=C3=A1ros=20Mih=C3=A1ly?= Date: Fri, 8 May 2020 13:24:39 +0200 Subject: [PATCH 61/88] Update Hungarian language --- app/src/translations/hu.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/translations/hu.json b/app/src/translations/hu.json index 1b2db12..6ed3eac 100644 --- a/app/src/translations/hu.json +++ b/app/src/translations/hu.json @@ -133,11 +133,11 @@ "settings.lastn": "A látható videók száma", "settings.hiddenControls": "Média Gombok automatikus elrejtése", "settings.notificationSounds": "Értesítések hangjelzéssel", - "settings.showNotifications": null, - "settings.buttonControlBar": null, - "settings.echoCancellation": null, - "settings.autoGainControl": null, - "settings.noiseSuppression": null, + "settings.showNotifications": "Értesítések megjelenítése", + "settings.buttonControlBar": "Médiavezérlő gombok leválasztása", + "settings.echoCancellation": "Visszhangelnyomás", + "settings.autoGainControl": "Automatikus hangerősítés", + "settings.noiseSuppression": "Zajelnyomás", "filesharing.saveFileError": "A file-t nem sikerült elmenteni", "filesharing.startingFileShare": "Fájl megosztása", From 220d4dd99dbcb4c821aab3351431d0921304c67d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9sz=C3=A1ros=20Mih=C3=A1ly?= Date: Fri, 8 May 2020 14:46:30 +0200 Subject: [PATCH 62/88] fix in lang hu --- app/src/translations/hu.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/translations/hu.json b/app/src/translations/hu.json index 6ed3eac..be0342d 100644 --- a/app/src/translations/hu.json +++ b/app/src/translations/hu.json @@ -136,7 +136,7 @@ "settings.showNotifications": "Értesítések megjelenítése", "settings.buttonControlBar": "Médiavezérlő gombok leválasztása", "settings.echoCancellation": "Visszhangelnyomás", - "settings.autoGainControl": "Automatikus hangerősítés", + "settings.autoGainControl": "Automatikus hangerő", "settings.noiseSuppression": "Zajelnyomás", "filesharing.saveFileError": "A file-t nem sikerült elmenteni", From a49258e840b1813bf8aa21736d90db3241722381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Fri, 8 May 2020 16:19:55 +0200 Subject: [PATCH 63/88] New option for handling permissions in rooms. Set allowWhenRoleMissing to permit actions before a peer with that permission joins. Ref #303 --- app/src/RoomClient.js | 10 +- app/src/actions/roomActions.js | 12 +- .../AccessControl/LockDialog/ListLobbyPeer.js | 28 ++- .../AccessControl/LockDialog/LockDialog.js | 26 +- app/src/components/Containers/Me.js | 37 +-- app/src/components/Controls/TopBar.js | 56 +++-- .../MeetingDrawer/Chat/ChatInput.js | 28 ++- .../MeetingDrawer/Chat/ChatModerator.js | 26 +- .../MeetingDrawer/FileSharing/FileSharing.js | 28 ++- .../FileSharing/FileSharingModerator.js | 26 +- .../ParticipantList/ParticipantList.js | 33 +-- app/src/components/Selectors.js | 53 ++++ app/src/permissions.js | 20 ++ app/src/reducers/room.js | 26 +- server/access.js | 11 + server/config/config.example.js | 52 ++-- server/lib/Room.js | 233 ++++++++---------- server/permissions.js | 20 ++ 18 files changed, 437 insertions(+), 288 deletions(-) create mode 100644 app/src/permissions.js create mode 100644 server/access.js create mode 100644 server/permissions.js diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index cffdd2a..2b2b387 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -2847,8 +2847,8 @@ export default class RoomClient roles, peers, tracker, - permissionsFromRoles, - userRoles, + roomPermissions, + allowWhenRoleMissing, chatHistory, fileHistory, lastNHistory, @@ -2874,8 +2874,10 @@ export default class RoomClient store.dispatch(meActions.loggedIn(authenticated)); - store.dispatch(roomActions.setUserRoles(userRoles)); - store.dispatch(roomActions.setPermissionsFromRoles(permissionsFromRoles)); + store.dispatch(roomActions.setRoomPermissions(roomPermissions)); + + if (allowWhenRoleMissing) + store.dispatch(roomActions.setAllowWhenRoleMissing(allowWhenRoleMissing)); const myRoles = store.getState().me.roles; diff --git a/app/src/actions/roomActions.js b/app/src/actions/roomActions.js index 7c44835..cbfde37 100644 --- a/app/src/actions/roomActions.js +++ b/app/src/actions/roomActions.js @@ -177,14 +177,14 @@ export const setClearFileSharingInProgress = (flag) => payload : { flag } }); -export const setUserRoles = (userRoles) => +export const setRoomPermissions = (roomPermissions) => ({ - type : 'SET_USER_ROLES', - payload : { userRoles } + type : 'SET_ROOM_PERMISSIONS', + payload : { roomPermissions } }); -export const setPermissionsFromRoles = (permissionsFromRoles) => +export const setAllowWhenRoleMissing = (allowWhenRoleMissing) => ({ - type : 'SET_PERMISSIONS_FROM_ROLES', - payload : { permissionsFromRoles } + type : 'SET_ALLOW_WHEN_ROLE_MISSING', + payload : { allowWhenRoleMissing } }); diff --git a/app/src/components/AccessControl/LockDialog/ListLobbyPeer.js b/app/src/components/AccessControl/LockDialog/ListLobbyPeer.js index 9e73e82..050994d 100644 --- a/app/src/components/AccessControl/LockDialog/ListLobbyPeer.js +++ b/app/src/components/AccessControl/LockDialog/ListLobbyPeer.js @@ -5,6 +5,8 @@ import PropTypes from 'prop-types'; import classnames from 'classnames'; import { withRoomContext } from '../../../RoomContext'; import { useIntl } from 'react-intl'; +import { permissions } from '../../../permissions'; +import { makePermissionSelector } from '../../Selectors'; import ListItem from '@material-ui/core/ListItem'; import ListItemText from '@material-ui/core/ListItemText'; import IconButton from '@material-ui/core/IconButton'; @@ -85,28 +87,32 @@ ListLobbyPeer.propTypes = classes : PropTypes.object.isRequired }; -const mapStateToProps = (state, { id }) => +const makeMapStateToProps = (initialState, { id }) => { - return { - peer : state.lobbyPeers[id], - promotionInProgress : state.room.lobbyPeersPromotionInProgress, - canPromote : - state.me.roles.some((role) => - state.room.permissionsFromRoles.PROMOTE_PEER.includes(role)) + const hasPermission = makePermissionSelector(permissions.PROMOTE_PEER); + + const mapStateToProps = (state) => + { + return { + peer : state.lobbyPeers[id], + promotionInProgress : state.room.lobbyPeersPromotionInProgress, + canPromote : hasPermission(state) + }; }; + + return mapStateToProps; }; export default withRoomContext(connect( - mapStateToProps, + makeMapStateToProps, null, null, { areStatesEqual : (next, prev) => { return ( - prev.room.permissionsFromRoles === next.room.permissionsFromRoles && - prev.room.lobbyPeersPromotionInProgress === - next.room.lobbyPeersPromotionInProgress && + prev.room === next.room && + prev.peers === next.peers && // For checking permissions prev.me.roles === next.me.roles && prev.lobbyPeers === next.lobbyPeers ); diff --git a/app/src/components/AccessControl/LockDialog/LockDialog.js b/app/src/components/AccessControl/LockDialog/LockDialog.js index 4d6cd24..c3dbd6a 100644 --- a/app/src/components/AccessControl/LockDialog/LockDialog.js +++ b/app/src/components/AccessControl/LockDialog/LockDialog.js @@ -1,8 +1,10 @@ import React from 'react'; import { connect } from 'react-redux'; import { - lobbyPeersKeySelector + lobbyPeersKeySelector, + makePermissionSelector } from '../../Selectors'; +import { permissions } from '../../../permissions'; import * as appPropTypes from '../../appPropTypes'; import { withStyles } from '@material-ui/core/styles'; import { withRoomContext } from '../../../RoomContext'; @@ -140,15 +142,20 @@ LockDialog.propTypes = classes : PropTypes.object.isRequired }; -const mapStateToProps = (state) => +const makeMapStateToProps = () => { - return { - room : state.room, - lobbyPeers : lobbyPeersKeySelector(state), - canPromote : - state.me.roles.some((role) => - state.room.permissionsFromRoles.PROMOTE_PEER.includes(role)) + const hasPermission = makePermissionSelector(permissions.PROMOTE_PEER); + + const mapStateToProps = (state) => + { + return { + room : state.room, + lobbyPeers : lobbyPeersKeySelector(state), + canPromote : hasPermission(state) + }; }; + + return mapStateToProps; }; const mapDispatchToProps = { @@ -157,7 +164,7 @@ const mapDispatchToProps = { }; export default withRoomContext(connect( - mapStateToProps, + makeMapStateToProps, mapDispatchToProps, null, { @@ -166,6 +173,7 @@ export default withRoomContext(connect( return ( prev.room === next.room && prev.me.roles === next.me.roles && + prev.peers === next.peers && prev.lobbyPeers === next.lobbyPeers ); } diff --git a/app/src/components/Containers/Me.js b/app/src/components/Containers/Me.js index ead95b1..aa3a9a3 100644 --- a/app/src/components/Containers/Me.js +++ b/app/src/components/Containers/Me.js @@ -1,6 +1,10 @@ import React, { useState } from 'react'; import { connect } from 'react-redux'; -import { meProducersSelector } from '../Selectors'; +import { + meProducersSelector, + makePermissionSelector +} from '../Selectors'; +import { permissions } from '../../permissions'; import { withRoomContext } from '../../RoomContext'; import { withStyles } from '@material-ui/core/styles'; import PropTypes from 'prop-types'; @@ -807,32 +811,37 @@ Me.propTypes = theme : PropTypes.object.isRequired }; -const mapStateToProps = (state) => +const makeMapStateToProps = () => { - return { - me : state.me, - ...meProducersSelector(state), - settings : state.settings, - activeSpeaker : state.me.id === state.room.activeSpeakerId, - canShareScreen : - state.me.roles.some((role) => - state.room.permissionsFromRoles.SHARE_SCREEN.includes(role)) + const hasPermission = makePermissionSelector(permissions.SHARE_SCREEN); + + const mapStateToProps = (state) => + { + return { + me : state.me, + ...meProducersSelector(state), + settings : state.settings, + activeSpeaker : state.me.id === state.room.activeSpeakerId, + canShareScreen : hasPermission(state) + }; }; + + return mapStateToProps; }; export default withRoomContext(connect( - mapStateToProps, + makeMapStateToProps, null, null, { areStatesEqual : (next, prev) => { return ( - prev.room.permissionsFromRoles === next.room.permissionsFromRoles && + prev.room === next.room && prev.me === next.me && + prev.peers === next.peers && prev.producers === next.producers && - prev.settings === next.settings && - prev.room.activeSpeakerId === next.room.activeSpeakerId + prev.settings === next.settings ); } } diff --git a/app/src/components/Controls/TopBar.js b/app/src/components/Controls/TopBar.js index 43ee13f..b880c36 100644 --- a/app/src/components/Controls/TopBar.js +++ b/app/src/components/Controls/TopBar.js @@ -4,8 +4,10 @@ import PropTypes from 'prop-types'; import { lobbyPeersKeySelector, peersLengthSelector, - raisedHandsSelector + raisedHandsSelector, + makePermissionSelector } from '../Selectors'; +import { permissions } from '../../permissions'; import * as appPropTypes from '../appPropTypes'; import { withRoomContext } from '../../RoomContext'; import { withStyles } from '@material-ui/core/styles'; @@ -751,27 +753,35 @@ TopBar.propTypes = theme : PropTypes.object.isRequired }; -const mapStateToProps = (state) => - ({ - room : state.room, - peersLength : peersLengthSelector(state), - lobbyPeers : lobbyPeersKeySelector(state), - permanentTopBar : state.settings.permanentTopBar, - loggedIn : state.me.loggedIn, - loginEnabled : state.me.loginEnabled, - myPicture : state.me.picture, - unread : state.toolarea.unreadMessages + - state.toolarea.unreadFiles + raisedHandsSelector(state), - canProduceExtraVideo : - state.me.roles.some((role) => - state.room.permissionsFromRoles.EXTRA_VIDEO.includes(role)), - canLock : - state.me.roles.some((role) => - state.room.permissionsFromRoles.CHANGE_ROOM_LOCK.includes(role)), - canPromote : - state.me.roles.some((role) => - state.room.permissionsFromRoles.PROMOTE_PEER.includes(role)) - }); +const makeMapStateToProps = () => +{ + const hasExtraVideoPermission = + makePermissionSelector(permissions.EXTRA_VIDEO); + + const hasLockPermission = + makePermissionSelector(permissions.CHANGE_ROOM_LOCK); + + const hasPromotionPermission = + makePermissionSelector(permissions.PROMOTE_PEER); + + const mapStateToProps = (state) => + ({ + room : state.room, + peersLength : peersLengthSelector(state), + lobbyPeers : lobbyPeersKeySelector(state), + permanentTopBar : state.settings.permanentTopBar, + loggedIn : state.me.loggedIn, + loginEnabled : state.me.loginEnabled, + myPicture : state.me.picture, + unread : state.toolarea.unreadMessages + + state.toolarea.unreadFiles + raisedHandsSelector(state), + canProduceExtraVideo : hasExtraVideoPermission(state), + canLock : hasLockPermission(state), + canPromote : hasPromotionPermission(state) + }); + + return mapStateToProps; +}; const mapDispatchToProps = (dispatch) => ({ @@ -811,7 +821,7 @@ const mapDispatchToProps = (dispatch) => }); export default withRoomContext(connect( - mapStateToProps, + makeMapStateToProps, mapDispatchToProps, null, { diff --git a/app/src/components/MeetingDrawer/Chat/ChatInput.js b/app/src/components/MeetingDrawer/Chat/ChatInput.js index 480bb26..bb07f98 100644 --- a/app/src/components/MeetingDrawer/Chat/ChatInput.js +++ b/app/src/components/MeetingDrawer/Chat/ChatInput.js @@ -4,6 +4,8 @@ import PropTypes from 'prop-types'; import { withStyles } from '@material-ui/core/styles'; import { withRoomContext } from '../../../RoomContext'; import { useIntl } from 'react-intl'; +import { permissions } from '../../../permissions'; +import { makePermissionSelector } from '../../Selectors'; import Paper from '@material-ui/core/Paper'; import InputBase from '@material-ui/core/InputBase'; import IconButton from '@material-ui/core/IconButton'; @@ -119,26 +121,32 @@ ChatInput.propTypes = classes : PropTypes.object.isRequired }; -const mapStateToProps = (state) => - ({ - displayName : state.settings.displayName, - picture : state.me.picture, - canChat : - state.me.roles.some((role) => - state.room.permissionsFromRoles.SEND_CHAT.includes(role)) - }); +const makeMapStateToProps = () => +{ + const hasPermission = makePermissionSelector(permissions.SEND_CHAT); + + const mapStateToProps = (state) => + ({ + displayName : state.settings.displayName, + picture : state.me.picture, + canChat : hasPermission(state) + }); + + return mapStateToProps; +}; export default withRoomContext( connect( - mapStateToProps, + makeMapStateToProps, null, null, { areStatesEqual : (next, prev) => { return ( - prev.room.permissionsFromRoles === next.room.permissionsFromRoles && + prev.room === next.room && prev.me.roles === next.me.roles && + prev.peers === next.peers && prev.settings.displayName === next.settings.displayName && prev.me.picture === next.me.picture ); diff --git a/app/src/components/MeetingDrawer/Chat/ChatModerator.js b/app/src/components/MeetingDrawer/Chat/ChatModerator.js index a35675b..d7b1cad 100644 --- a/app/src/components/MeetingDrawer/Chat/ChatModerator.js +++ b/app/src/components/MeetingDrawer/Chat/ChatModerator.js @@ -4,6 +4,8 @@ import PropTypes from 'prop-types'; import { withRoomContext } from '../../../RoomContext'; import { withStyles } from '@material-ui/core/styles'; import { useIntl, FormattedMessage } from 'react-intl'; +import { permissions } from '../../../permissions'; +import { makePermissionSelector } from '../../Selectors'; import Button from '@material-ui/core/Button'; const styles = (theme) => @@ -76,16 +78,21 @@ ChatModerator.propTypes = classes : PropTypes.object.isRequired }; -const mapStateToProps = (state) => - ({ - isChatModerator : - state.me.roles.some((role) => - state.room.permissionsFromRoles.MODERATE_CHAT.includes(role)), - room : state.room - }); +const makeMapStateToProps = () => +{ + const hasPermission = makePermissionSelector(permissions.MODERATE_CHAT); + + const mapStateToProps = (state) => + ({ + isChatModerator : hasPermission(state), + room : state.room + }); + + return mapStateToProps; +}; export default withRoomContext(connect( - mapStateToProps, + makeMapStateToProps, null, null, { @@ -93,7 +100,8 @@ export default withRoomContext(connect( { return ( prev.room === next.room && - prev.me === next.me + prev.me === next.me && + prev.peers === next.peers ); } } diff --git a/app/src/components/MeetingDrawer/FileSharing/FileSharing.js b/app/src/components/MeetingDrawer/FileSharing/FileSharing.js index 78ba569..8af1d8b 100644 --- a/app/src/components/MeetingDrawer/FileSharing/FileSharing.js +++ b/app/src/components/MeetingDrawer/FileSharing/FileSharing.js @@ -4,6 +4,8 @@ import { connect } from 'react-redux'; import { withStyles } from '@material-ui/core/styles'; import { withRoomContext } from '../../../RoomContext'; import { useIntl } from 'react-intl'; +import { permissions } from '../../../permissions'; +import { makePermissionSelector } from '../../Selectors'; import FileList from './FileList'; import FileSharingModerator from './FileSharingModerator'; import Paper from '@material-ui/core/Paper'; @@ -131,30 +133,36 @@ FileSharing.propTypes = { classes : PropTypes.object.isRequired }; -const mapStateToProps = (state) => +const makeMapStateToProps = () => { - return { - canShareFiles : state.me.canShareFiles, - browser : state.me.browser, - tabOpen : state.toolarea.currentToolTab === 'files', - canShare : - state.me.roles.some((role) => - state.room.permissionsFromRoles.SHARE_FILE.includes(role)) + const hasPermission = makePermissionSelector(permissions.SHARE_FILE); + + const mapStateToProps = (state) => + { + return { + canShareFiles : state.me.canShareFiles, + browser : state.me.browser, + tabOpen : state.toolarea.currentToolTab === 'files', + canShare : hasPermission(state) + }; }; + + return mapStateToProps; }; export default withRoomContext(connect( - mapStateToProps, + makeMapStateToProps, null, null, { areStatesEqual : (next, prev) => { return ( - prev.room.permissionsFromRoles === next.room.permissionsFromRoles && + prev.room === next.room && prev.me.browser === next.me.browser && prev.me.roles === next.me.roles && prev.me.canShareFiles === next.me.canShareFiles && + prev.peers === next.peers && prev.toolarea.currentToolTab === next.toolarea.currentToolTab ); } diff --git a/app/src/components/MeetingDrawer/FileSharing/FileSharingModerator.js b/app/src/components/MeetingDrawer/FileSharing/FileSharingModerator.js index e38e54c..05f35e8 100644 --- a/app/src/components/MeetingDrawer/FileSharing/FileSharingModerator.js +++ b/app/src/components/MeetingDrawer/FileSharing/FileSharingModerator.js @@ -4,6 +4,8 @@ import PropTypes from 'prop-types'; import { withRoomContext } from '../../../RoomContext'; import { withStyles } from '@material-ui/core/styles'; import { useIntl, FormattedMessage } from 'react-intl'; +import { permissions } from '../../../permissions'; +import { makePermissionSelector } from '../../Selectors'; import Button from '@material-ui/core/Button'; const styles = (theme) => @@ -76,16 +78,21 @@ FileSharingModerator.propTypes = classes : PropTypes.object.isRequired }; -const mapStateToProps = (state) => - ({ - isFileSharingModerator : - state.me.roles.some((role) => - state.room.permissionsFromRoles.MODERATE_FILES.includes(role)), - room : state.room - }); +const makeMapStateToProps = () => +{ + const hasPermission = makePermissionSelector(permissions.MODERATE_FILES); + + const mapStateToProps = (state) => + ({ + isFileSharingModerator : hasPermission(state), + room : state.room + }); + + return mapStateToProps; +}; export default withRoomContext(connect( - mapStateToProps, + makeMapStateToProps, null, null, { @@ -93,7 +100,8 @@ export default withRoomContext(connect( { return ( prev.room === next.room && - prev.me === next.me + prev.me === next.me && + prev.peers === next.peers ); } } diff --git a/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js b/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js index 07e5d24..a416a64 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ParticipantList.js @@ -1,8 +1,10 @@ import React from 'react'; import { connect } from 'react-redux'; import { - participantListSelector + participantListSelector, + makePermissionSelector } from '../../Selectors'; +import { permissions } from '../../../permissions'; import classnames from 'classnames'; import { withStyles } from '@material-ui/core/styles'; import { withRoomContext } from '../../../RoomContext'; @@ -160,31 +162,34 @@ ParticipantList.propTypes = classes : PropTypes.object.isRequired }; -const mapStateToProps = (state) => +const makeMapStateToProps = () => { - return { - isModerator : - state.me.roles.some((role) => - state.room.permissionsFromRoles.MODERATE_ROOM.includes(role)), - participants : participantListSelector(state), - spotlights : state.room.spotlights, - selectedPeerId : state.room.selectedPeerId + const hasPermission = makePermissionSelector(permissions.MODERATE_ROOM); + + const mapStateToProps = (state) => + { + return { + isModerator : hasPermission(state), + participants : participantListSelector(state), + spotlights : state.room.spotlights, + selectedPeerId : state.room.selectedPeerId + }; }; + + return mapStateToProps; }; const ParticipantListContainer = withRoomContext(connect( - mapStateToProps, + makeMapStateToProps, null, null, { areStatesEqual : (next, prev) => { return ( - prev.room.permissionsFromRoles === next.room.permissionsFromRoles && + prev.room === next.room && prev.me.roles === next.me.roles && - prev.peers === next.peers && - prev.room.spotlights === next.room.spotlights && - prev.room.selectedPeerId === next.room.selectedPeerId + prev.peers === next.peers ); } } diff --git a/app/src/components/Selectors.js b/app/src/components/Selectors.js index b8e5e41..8ac1176 100644 --- a/app/src/components/Selectors.js +++ b/app/src/components/Selectors.js @@ -1,5 +1,8 @@ import { createSelector } from 'reselect'; +const meRolesSelect = (state) => state.me.roles; +const roomPermissionsSelect = (state) => state.room.roomPermissions; +const roomAllowWhenRoleMissing = (state) => state.room.allowWhenRoleMissing; const producersSelect = (state) => state.producers; const consumersSelect = (state) => state.consumers; const spotlightsSelector = (state) => state.room.spotlights; @@ -217,3 +220,53 @@ export const makePeerConsumerSelector = () => } ); }; + +// Very important that the Components that use this +// selector need to check at least these state changes: +// +// areStatesEqual : (next, prev) => +// { +// return ( +// prev.room.roomPermissions === next.room.roomPermissions && +// prev.room.allowWhenRoleMissing === next.room.allowWhenRoleMissing && +// prev.peers === next.peers && +// prev.me.roles === next.me.roles +// ); +// } +export const makePermissionSelector = (permission) => +{ + return createSelector( + meRolesSelect, + roomPermissionsSelect, + roomAllowWhenRoleMissing, + peersValueSelector, + (roles, roomPermissions, allowWhenRoleMissing, peers) => + { + if (!roomPermissions) + return false; + + const permitted = roles.some((role) => + roomPermissions[permission].includes(role) + ); + + if (permitted) + return true; + + if (!allowWhenRoleMissing) + return false; + + // Allow if config is set, and no one is present + if (allowWhenRoleMissing.includes(permission) && + peers.filter( + (peer) => + peer.roles.some( + (role) => roomPermissions[permission].includes(role) + ) + ).length === 0 + ) + return true; + + return false; + } + ); +}; \ No newline at end of file diff --git a/app/src/permissions.js b/app/src/permissions.js new file mode 100644 index 0000000..864bdbd --- /dev/null +++ b/app/src/permissions.js @@ -0,0 +1,20 @@ +export const permissions = { + // The role(s) have permission to lock/unlock a room + CHANGE_ROOM_LOCK : 'CHANGE_ROOM_LOCK', + // The role(s) have permission to promote a peer from the lobby + PROMOTE_PEER : 'PROMOTE_PEER', + // The role(s) have permission to send chat messages + SEND_CHAT : 'SEND_CHAT', + // The role(s) have permission to moderate chat + MODERATE_CHAT : 'MODERATE_CHAT', + // The role(s) have permission to share screen + SHARE_SCREEN : 'SHARE_SCREEN', + // The role(s) have permission to produce extra video + EXTRA_VIDEO : 'EXTRA_VIDEO', + // The role(s) have permission to share files + SHARE_FILE : 'SHARE_FILE', + // The role(s) have permission to moderate files + MODERATE_FILES : 'MODERATE_FILES', + // The role(s) have permission to moderate room (e.g. kick user) + MODERATE_ROOM : 'MODERATE_ROOM' +}; \ No newline at end of file diff --git a/app/src/reducers/room.js b/app/src/reducers/room.js index 1a4b99e..595ca4e 100644 --- a/app/src/reducers/room.js +++ b/app/src/reducers/room.js @@ -33,18 +33,8 @@ const initialState = closeMeetingInProgress : false, clearChatInProgress : false, clearFileSharingInProgress : false, - userRoles : { NORMAL: 'normal' }, // Default role - permissionsFromRoles : { - CHANGE_ROOM_LOCK : [], - PROMOTE_PEER : [], - SEND_CHAT : [], - MODERATE_CHAT : [], - SHARE_SCREEN : [], - EXTRA_VIDEO : [], - SHARE_FILE : [], - MODERATE_FILES : [], - MODERATE_ROOM : [] - } + roomPermissions : null, + allowWhenRoleMissing : null }; const room = (state = initialState, action) => @@ -240,18 +230,18 @@ const room = (state = initialState, action) => case 'CLEAR_FILE_SHARING_IN_PROGRESS': return { ...state, clearFileSharingInProgress: action.payload.flag }; - case 'SET_USER_ROLES': + case 'SET_ROOM_PERMISSIONS': { - const { userRoles } = action.payload; + const { roomPermissions } = action.payload; - return { ...state, userRoles }; + return { ...state, roomPermissions }; } - case 'SET_PERMISSIONS_FROM_ROLES': + case 'SET_ALLOW_WHEN_ROLE_MISSING': { - const { permissionsFromRoles } = action.payload; + const { allowWhenRoleMissing } = action.payload; - return { ...state, permissionsFromRoles }; + return { ...state, allowWhenRoleMissing }; } default: diff --git a/server/access.js b/server/access.js new file mode 100644 index 0000000..479e9e2 --- /dev/null +++ b/server/access.js @@ -0,0 +1,11 @@ +module.exports = { + // The role(s) will gain access to the room + // even if it is locked (!) + BYPASS_ROOM_LOCK : 'BYPASS_ROOM_LOCK', + // The role(s) will gain access to the room without + // going into the lobby. If you want to restrict access to your + // server to only directly allow authenticated users, you could + // add the userRoles.AUTHENTICATED to the user in the userMapping + // function, and change to BYPASS_LOBBY : [ userRoles.AUTHENTICATED ] + BYPASS_LOBBY : 'BYPASS_LOBBY' +}; \ No newline at end of file diff --git a/server/config/config.example.js b/server/config/config.example.js index dda9e2f..e08f910 100644 --- a/server/config/config.example.js +++ b/server/config/config.example.js @@ -1,5 +1,23 @@ const os = require('os'); const userRoles = require('../userRoles'); + +const { + BYPASS_ROOM_LOCK, + BYPASS_LOBBY +} = require('../access'); + +const { + CHANGE_ROOM_LOCK, + PROMOTE_PEER, + SEND_CHAT, + MODERATE_CHAT, + SHARE_SCREEN, + EXTRA_VIDEO, + SHARE_FILE, + MODERATE_FILES, + MODERATE_ROOM +} = require('../permissions'); + // const AwaitQueue = require('awaitqueue'); // const axios = require('axios'); @@ -216,44 +234,50 @@ module.exports = accessFromRoles : { // The role(s) will gain access to the room // even if it is locked (!) - BYPASS_ROOM_LOCK : [ userRoles.ADMIN ], + [BYPASS_ROOM_LOCK] : [ userRoles.ADMIN ], // The role(s) will gain access to the room without // going into the lobby. If you want to restrict access to your // server to only directly allow authenticated users, you could // add the userRoles.AUTHENTICATED to the user in the userMapping // function, and change to BYPASS_LOBBY : [ userRoles.AUTHENTICATED ] - BYPASS_LOBBY : [ userRoles.NORMAL ] + [BYPASS_LOBBY] : [ userRoles.NORMAL ] }, permissionsFromRoles : { // The role(s) have permission to lock/unlock a room - CHANGE_ROOM_LOCK : [ userRoles.NORMAL ], + [CHANGE_ROOM_LOCK] : [ userRoles.MODERATOR ], // The role(s) have permission to promote a peer from the lobby - PROMOTE_PEER : [ userRoles.NORMAL ], + [PROMOTE_PEER] : [ userRoles.NORMAL ], // The role(s) have permission to send chat messages - SEND_CHAT : [ userRoles.NORMAL ], + [SEND_CHAT] : [ userRoles.NORMAL ], // The role(s) have permission to moderate chat - MODERATE_CHAT : [ userRoles.MODERATOR ], + [MODERATE_CHAT] : [ userRoles.MODERATOR ], // The role(s) have permission to share screen - SHARE_SCREEN : [ userRoles.NORMAL ], + [SHARE_SCREEN] : [ userRoles.NORMAL ], // The role(s) have permission to produce extra video - EXTRA_VIDEO : [ userRoles.NORMAL ], + [EXTRA_VIDEO] : [ userRoles.NORMAL ], // The role(s) have permission to share files - SHARE_FILE : [ userRoles.NORMAL ], + [SHARE_FILE] : [ userRoles.NORMAL ], // The role(s) have permission to moderate files - MODERATE_FILES : [ userRoles.MODERATOR ], + [MODERATE_FILES] : [ userRoles.MODERATOR ], // The role(s) have permission to moderate room (e.g. kick user) - MODERATE_ROOM : [ userRoles.MODERATOR ] + [MODERATE_ROOM] : [ userRoles.MODERATOR ] }, + // Array of permissions. If no peer with the permission in question + // is in the room, all peers are permitted to do the action. The peers + // that are allowed because of this rule will not be able to do this + // action as soon as a peer with the permission joins. In this example + // everyone will be able to lock/unlock room until a MODERATOR joins. + allowWhenRoleMissing : [ CHANGE_ROOM_LOCK ], // When truthy, the room will be open to all users when as long as there // are allready users in the room - activateOnHostJoin : true, + activateOnHostJoin : true, // When set, maxUsersPerRoom defines how many users can join // a single room. If not set, there is no limit. // maxUsersPerRoom : 20, // Room size before spreading to new router - routerScaleSize : 40, + routerScaleSize : 40, // Mediasoup settings - mediasoup : + mediasoup : { numWorkers : Object.keys(os.cpus()).length, // mediasoup Worker settings. diff --git a/server/lib/Room.js b/server/lib/Room.js index 77f00ee..3ab281b 100644 --- a/server/lib/Room.js +++ b/server/lib/Room.js @@ -5,29 +5,47 @@ const Lobby = require('./Lobby'); const { v4: uuidv4 } = require('uuid'); const jwt = require('jsonwebtoken'); const userRoles = require('../userRoles'); + +const { + BYPASS_ROOM_LOCK, + BYPASS_LOBBY +} = require('../access'); + +const permissions = require('../permissions'), { + CHANGE_ROOM_LOCK, + PROMOTE_PEER, + SEND_CHAT, + MODERATE_CHAT, + SHARE_SCREEN, + EXTRA_VIDEO, + SHARE_FILE, + MODERATE_FILES, + MODERATE_ROOM +} = permissions; + const config = require('../config/config'); const logger = new Logger('Room'); // In case they are not configured properly -const accessFromRoles = +const roomAccess = { - BYPASS_ROOM_LOCK : [ userRoles.ADMIN ], - BYPASS_LOBBY : [ userRoles.NORMAL ], + [BYPASS_ROOM_LOCK] : [ userRoles.ADMIN ], + [BYPASS_LOBBY] : [ userRoles.NORMAL ], ...config.accessFromRoles }; -const permissionsFromRoles = +const roomPermissions = { - CHANGE_ROOM_LOCK : [ userRoles.NORMAL ], - PROMOTE_PEER : [ userRoles.NORMAL ], - SEND_CHAT : [ userRoles.NORMAL ], - MODERATE_CHAT : [ userRoles.MODERATOR ], - SHARE_SCREEN : [ userRoles.NORMAL ], - EXTRA_VIDEO : [ userRoles.NORMAL ], - SHARE_FILE : [ userRoles.NORMAL ], - MODERATE_FILES : [ userRoles.MODERATOR ], - MODERATE_ROOM : [ userRoles.MODERATOR ], + [CHANGE_ROOM_LOCK] : [ userRoles.NORMAL ], + [PROMOTE_PEER] : [ userRoles.NORMAL ], + [SEND_CHAT] : [ userRoles.NORMAL ], + [MODERATE_CHAT] : [ userRoles.MODERATOR ], + [SHARE_SCREEN] : [ userRoles.NORMAL ], + [EXTRA_VIDEO] : [ userRoles.NORMAL ], + [SHARE_FILE] : [ userRoles.NORMAL ], + [MODERATE_FILES] : [ userRoles.MODERATOR ], + [MODERATE_ROOM] : [ userRoles.MODERATOR ], ...config.permissionsFromRoles }; @@ -221,9 +239,8 @@ class Room extends EventEmitter // Returning user if (returning) this._peerJoining(peer, true); - else if ( // Has a role that is allowed to bypass room lock - peer.roles.some((role) => accessFromRoles.BYPASS_ROOM_LOCK.includes(role)) - ) + // Has a role that is allowed to bypass room lock + else if (this._hasAccess(peer, BYPASS_ROOM_LOCK)) this._peerJoining(peer); else if ( 'maxUsersPerRoom' in config && @@ -239,7 +256,7 @@ class Room extends EventEmitter else { // Has a role that is allowed to bypass lobby - peer.roles.some((role) => accessFromRoles.BYPASS_LOBBY.includes(role)) ? + this._hasAccess(peer, BYPASS_LOBBY) ? this._peerJoining(peer) : this._handleGuest(peer); } @@ -271,11 +288,7 @@ class Room extends EventEmitter this._peerJoining(promotedPeer); - for ( - const peer of this._getPeersWithPermission({ - permission : permissionsFromRoles.PROMOTE_PEER - }) - ) + for (const peer of this._getPeersWithPermission(PROMOTE_PEER)) { this._notification(peer.socket, 'lobby:promotedPeer', { peerId: id }); } @@ -283,9 +296,8 @@ class Room extends EventEmitter this._lobby.on('peerRolesChanged', (peer) => { - if ( // Has a role that is allowed to bypass room lock - peer.roles.some((role) => accessFromRoles.BYPASS_ROOM_LOCK.includes(role)) - ) + // Has a role that is allowed to bypass room lock + if (this._hasAccess(peer, BYPASS_ROOM_LOCK)) { this._lobby.promotePeer(peer.id); @@ -294,7 +306,7 @@ class Room extends EventEmitter if ( // Has a role that is allowed to bypass lobby !this._locked && - peer.roles.some((role) => accessFromRoles.BYPASS_LOBBY.includes(role)) + this._hasAccess(peer, BYPASS_LOBBY) ) { this._lobby.promotePeer(peer.id); @@ -307,11 +319,7 @@ class Room extends EventEmitter { const { id, displayName } = changedPeer; - for ( - const peer of this._getPeersWithPermission({ - permission : permissionsFromRoles.PROMOTE_PEER - }) - ) + for (const peer of this._getPeersWithPermission(PROMOTE_PEER)) { this._notification(peer.socket, 'lobby:changeDisplayName', { peerId: id, displayName }); } @@ -321,11 +329,7 @@ class Room extends EventEmitter { const { id, picture } = changedPeer; - for ( - const peer of this._getPeersWithPermission({ - permission : permissionsFromRoles.PROMOTE_PEER - }) - ) + for (const peer of this._getPeersWithPermission(PROMOTE_PEER)) { this._notification(peer.socket, 'lobby:changePicture', { peerId: id, picture }); } @@ -337,11 +341,7 @@ class Room extends EventEmitter const { id } = closedPeer; - for ( - const peer of this._getPeersWithPermission({ - permission : permissionsFromRoles.PROMOTE_PEER - }) - ) + for (const peer of this._getPeersWithPermission(PROMOTE_PEER)) { this._notification(peer.socket, 'lobby:peerClosed', { peerId: id }); } @@ -401,7 +401,7 @@ class Room extends EventEmitter ); } - async dump() + dump() { return { roomId : this._roomId, @@ -447,11 +447,7 @@ class Room extends EventEmitter { this._lobby.parkPeer(parkPeer); - for ( - const peer of this._getPeersWithPermission({ - permission : permissionsFromRoles.PROMOTE_PEER - }) - ) + for (const peer of this._getPeersWithPermission(PROMOTE_PEER)) { this._notification(peer.socket, 'parkedPeer', { peerId: parkPeer.id }); } @@ -602,7 +598,7 @@ class Room extends EventEmitter // Got permission to promote peers, notify peer of // peers in lobby - if (permissionsFromRoles.PROMOTE_PEER.includes(newRole)) + if (roomPermissions.PROMOTE_PEER.includes(newRole)) { const lobbyPeers = this._lobby.peerList(); @@ -670,12 +666,9 @@ class Room extends EventEmitter .map((joinedPeer) => (joinedPeer.peerInfo)); let lobbyPeers = []; - - if ( // Allowed to promote peers, notify about lobbypeers - peer.roles.some((role) => - permissionsFromRoles.PROMOTE_PEER.includes(role) - ) - ) + + // Allowed to promote peers, notify about lobbypeers + if (this._hasPermission(peer, PROMOTE_PEER)) lobbyPeers = this._lobby.peerList(); cb(null, { @@ -683,8 +676,9 @@ class Room extends EventEmitter peers : peerInfos, tracker : config.fileTracker, authenticated : peer.authenticated, - permissionsFromRoles : permissionsFromRoles, + roomPermissions : roomPermissions, userRoles : userRoles, + allowWhenRoleMissing : config.allowWhenRoleMissing, chatHistory : this._chatHistory, fileHistory : this._fileHistory, lastNHistory : this._lastN, @@ -711,7 +705,7 @@ class Room extends EventEmitter } // Notify the new Peer to all other Peers. - for (const otherPeer of this._getJoinedPeers({ excludePeer: peer })) + for (const otherPeer of this._getJoinedPeers(peer)) { this._notification( otherPeer.socket, @@ -821,15 +815,13 @@ class Room extends EventEmitter if ( appData.source === 'screen' && - !peer.roles.some( - (role) => permissionsFromRoles.SHARE_SCREEN.includes(role)) + !this._hasPermission(peer, SHARE_SCREEN) ) throw new Error('peer not authorized'); if ( appData.source === 'extravideo' && - !peer.roles.some( - (role) => permissionsFromRoles.EXTRA_VIDEO.includes(role)) + !this._hasPermission(peer, EXTRA_VIDEO) ) throw new Error('peer not authorized'); @@ -882,7 +874,7 @@ class Room extends EventEmitter cb(null, { id: producer.id }); // Optimization: Create a server-side Consumer for each Peer. - for (const otherPeer of this._getJoinedPeers({ excludePeer: peer })) + for (const otherPeer of this._getJoinedPeers(peer)) { this._createConsumer( { @@ -1144,9 +1136,7 @@ class Room extends EventEmitter case 'chatMessage': { - if ( - !peer.roles.some((role) => permissionsFromRoles.SEND_CHAT.includes(role)) - ) + if (!this._hasPermission(peer, SEND_CHAT)) throw new Error('peer not authorized'); const { chatMessage } = request.data; @@ -1167,11 +1157,7 @@ class Room extends EventEmitter case 'moderator:clearChat': { - if ( - !peer.roles.some( - (role) => permissionsFromRoles.MODERATE_CHAT.includes(role) - ) - ) + if (!this._hasPermission(peer, MODERATE_CHAT)) throw new Error('peer not authorized'); this._chatHistory = []; @@ -1187,11 +1173,7 @@ class Room extends EventEmitter case 'lockRoom': { - if ( - !peer.roles.some( - (role) => permissionsFromRoles.CHANGE_ROOM_LOCK.includes(role) - ) - ) + if (!this._hasPermission(peer, CHANGE_ROOM_LOCK)) throw new Error('peer not authorized'); this._locked = true; @@ -1209,11 +1191,7 @@ class Room extends EventEmitter case 'unlockRoom': { - if ( - !peer.roles.some( - (role) => permissionsFromRoles.CHANGE_ROOM_LOCK.includes(role) - ) - ) + if (!this._hasPermission(peer, CHANGE_ROOM_LOCK)) throw new Error('peer not authorized'); this._locked = false; @@ -1271,11 +1249,7 @@ class Room extends EventEmitter case 'promotePeer': { - if ( - !peer.roles.some( - (role) => permissionsFromRoles.PROMOTE_PEER.includes(role) - ) - ) + if (!this._hasPermission(peer, PROMOTE_PEER)) throw new Error('peer not authorized'); const { peerId } = request.data; @@ -1290,11 +1264,7 @@ class Room extends EventEmitter case 'promoteAllPeers': { - if ( - !peer.roles.some( - (role) => permissionsFromRoles.PROMOTE_PEER.includes(role) - ) - ) + if (!this._hasPermission(peer, PROMOTE_PEER)) throw new Error('peer not authorized'); this._lobby.promoteAllPeers(); @@ -1307,11 +1277,7 @@ class Room extends EventEmitter case 'sendFile': { - if ( - !peer.roles.some( - (role) => permissionsFromRoles.SHARE_FILE.includes(role) - ) - ) + if (!this._hasPermission(peer, SHARE_FILE)) throw new Error('peer not authorized'); const { magnetUri } = request.data; @@ -1332,11 +1298,7 @@ class Room extends EventEmitter case 'moderator:clearFileSharing': { - if ( - !peer.roles.some( - (role) => permissionsFromRoles.MODERATE_FILES.includes(role) - ) - ) + if (!this._hasPermission(peer, MODERATE_FILES)) throw new Error('peer not authorized'); this._fileHistory = []; @@ -1371,11 +1333,7 @@ class Room extends EventEmitter case 'moderator:mute': { - if ( - !peer.roles.some( - (role) => permissionsFromRoles.MODERATE_ROOM.includes(role) - ) - ) + if (!this._hasPermission(peer, MODERATE_ROOM)) throw new Error('peer not authorized'); const { peerId } = request.data; @@ -1394,11 +1352,7 @@ class Room extends EventEmitter case 'moderator:muteAll': { - if ( - !peer.roles.some( - (role) => permissionsFromRoles.MODERATE_ROOM.includes(role) - ) - ) + if (!this._hasPermission(peer, MODERATE_ROOM)) throw new Error('peer not authorized'); // Spread to others @@ -1411,11 +1365,7 @@ class Room extends EventEmitter case 'moderator:stopVideo': { - if ( - !peer.roles.some( - (role) => permissionsFromRoles.MODERATE_ROOM.includes(role) - ) - ) + if (!this._hasPermission(peer, MODERATE_ROOM)) throw new Error('peer not authorized'); const { peerId } = request.data; @@ -1434,11 +1384,7 @@ class Room extends EventEmitter case 'moderator:stopAllVideo': { - if ( - !peer.roles.some( - (role) => permissionsFromRoles.MODERATE_ROOM.includes(role) - ) - ) + if (!this._hasPermission(peer, MODERATE_ROOM)) throw new Error('peer not authorized'); // Spread to others @@ -1451,11 +1397,7 @@ class Room extends EventEmitter case 'moderator:closeMeeting': { - if ( - !peer.roles.some( - (role) => permissionsFromRoles.MODERATE_ROOM.includes(role) - ) - ) + if (!this._hasPermission(peer, MODERATE_ROOM)) throw new Error('peer not authorized'); this._notification(peer.socket, 'moderator:kick', null, true); @@ -1470,11 +1412,7 @@ class Room extends EventEmitter case 'moderator:kickPeer': { - if ( - !peer.roles.some( - (role) => permissionsFromRoles.MODERATE_ROOM.includes(role) - ) - ) + if (!this._hasPermission(peer, MODERATE_ROOM)) throw new Error('peer not authorized'); const { peerId } = request.data; @@ -1495,11 +1433,7 @@ class Room extends EventEmitter case 'moderator:lowerHand': { - if ( - !peer.roles.some( - (role) => permissionsFromRoles.MODERATE_ROOM.includes(role) - ) - ) + if (!this._hasPermission(peer, MODERATE_ROOM)) throw new Error('peer not authorized'); const { peerId } = request.data; @@ -1677,16 +1611,41 @@ class Room extends EventEmitter } } + _hasPermission(peer, permission) + { + const hasPermission = peer.roles.some((role) => + roomPermissions[permission].includes(role) + ); + + if (hasPermission) + return true; + + // Allow if config is set, and no one is present + if ( + 'allowWhenRoleMissing' in config && + config.allowWhenRoleMissing.includes(permission) && + this._getPeersWithPermission(permission).length === 0 + ) + return true; + + return false; + } + + _hasAccess(peer, access) + { + return peer.roles.some((role) => roomAccess[access].includes(role)); + } + /** * Helper to get the list of joined peers. */ - _getJoinedPeers({ excludePeer = undefined } = {}) + _getJoinedPeers(excludePeer = undefined) { return Object.values(this._peers) .filter((peer) => peer.joined && peer !== excludePeer); } - _getPeersWithPermission({ permission = null, excludePeer = undefined, joined = true }) + _getPeersWithPermission(permission = null, excludePeer = undefined, joined = true) { return Object.values(this._peers) .filter( @@ -1694,7 +1653,7 @@ class Room extends EventEmitter peer.joined === joined && peer !== excludePeer && peer.roles.some( - (role) => permission.includes(role) + (role) => roomPermissions[permission].includes(role) ) ); } diff --git a/server/permissions.js b/server/permissions.js new file mode 100644 index 0000000..dd3bdbb --- /dev/null +++ b/server/permissions.js @@ -0,0 +1,20 @@ +module.exports = { + // The role(s) have permission to lock/unlock a room + CHANGE_ROOM_LOCK : 'CHANGE_ROOM_LOCK', + // The role(s) have permission to promote a peer from the lobby + PROMOTE_PEER : 'PROMOTE_PEER', + // The role(s) have permission to send chat messages + SEND_CHAT : 'SEND_CHAT', + // The role(s) have permission to moderate chat + MODERATE_CHAT : 'MODERATE_CHAT', + // The role(s) have permission to share screen + SHARE_SCREEN : 'SHARE_SCREEN', + // The role(s) have permission to produce extra video + EXTRA_VIDEO : 'EXTRA_VIDEO', + // The role(s) have permission to share files + SHARE_FILE : 'SHARE_FILE', + // The role(s) have permission to moderate files + MODERATE_FILES : 'MODERATE_FILES', + // The role(s) have permission to moderate room (e.g. kick user) + MODERATE_ROOM : 'MODERATE_ROOM' +}; \ No newline at end of file From d09e7f5565c6aaf7d416b69e4a4ecee41374dbfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Fri, 8 May 2020 16:20:15 +0200 Subject: [PATCH 64/88] Some error checks --- server/server.js | 68 +++++++++++++++++++++++++++--------------------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/server/server.js b/server/server.js index fcde0b1..27dc12c 100755 --- a/server/server.js +++ b/server/server.js @@ -368,38 +368,45 @@ async function setupAuth() app.get( '/auth/callback', passport.authenticate('oidc', { failureRedirect: '/auth/login' }), - async (req, res) => + async (req, res, next) => { - const state = JSON.parse(base64.decode(req.query.state)); - - const { peerId, roomId } = state; - - req.session.peerId = peerId; - req.session.roomId = roomId; - - let peer = peers.get(peerId); - - if (!peer) // User has no socket session yet, make temporary - peer = new Peer({ id: peerId, roomId }); - - if (peer.roomId !== roomId) // The peer is mischievous - throw new Error('peer authenticated with wrong room'); - - if (typeof config.userMapping === 'function') + try { - await config.userMapping({ - peer, - roomId, - userinfo : req.user._userinfo - }); + const state = JSON.parse(base64.decode(req.query.state)); + + const { peerId, roomId } = state; + + req.session.peerId = peerId; + req.session.roomId = roomId; + + let peer = peers.get(peerId); + + if (!peer) // User has no socket session yet, make temporary + peer = new Peer({ id: peerId, roomId }); + + if (peer.roomId !== roomId) // The peer is mischievous + throw new Error('peer authenticated with wrong room'); + + if (typeof config.userMapping === 'function') + { + await config.userMapping({ + peer, + roomId, + userinfo : req.user._userinfo + }); + } + + peer.authenticated = true; + + res.send(loginHelper({ + displayName : peer.displayName, + picture : peer.picture + })); + } + catch (error) + { + return next(error); } - - peer.authenticated = true; - - res.send(loginHelper({ - displayName : peer.displayName, - picture : peer.picture - })); } ); } @@ -586,7 +593,8 @@ async function runWebSocketServer() { logger.error('room creation or room joining failed [error:"%o"]', error); - socket.disconnect(true); + if (socket) + socket.disconnect(true); return; }); From 717c0053e54a9e9964571e7a376b53ff04bb48a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Fri, 8 May 2020 22:01:28 +0200 Subject: [PATCH 65/88] Better handling of reconnect. Clear the state properly and handle spotlights. --- app/src/RoomClient.js | 17 ++++++----------- app/src/Spotlights.js | 9 +++++++++ app/src/actions/consumerActions.js | 5 +++++ app/src/actions/peerActions.js | 5 +++++ app/src/actions/roomActions.js | 5 +++++ app/src/reducers/consumers.js | 5 +++++ app/src/reducers/peers.js | 11 +++++++++-- app/src/reducers/room.js | 5 +++++ 8 files changed, 49 insertions(+), 13 deletions(-) diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index 2b2b387..afbfdc9 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -940,14 +940,10 @@ export default class RoomClient { if (consumer.kind === 'video') { - if (spotlights.indexOf(consumer.appData.peerId) > -1) - { + if (spotlights.includes(consumer.appData.peerId)) await this._resumeConsumer(consumer); - } else - { await this._pauseConsumer(consumer); - } } } } @@ -1516,9 +1512,7 @@ export default class RoomClient if (consumer.appData.peerId === peerId && consumer.appData.source === type) { if (mute) - { await this._pauseConsumer(consumer); - } else await this._resumeConsumer(consumer); } @@ -1846,6 +1840,11 @@ export default class RoomClient this._recvTransport = null; } + this._spotlights.clearSpotlights(); + + store.dispatch(peerActions.clearPeers()); + store.dispatch(consumerActions.clearConsumers()); + store.dispatch(roomActions.clearSpotlights()); store.dispatch(roomActions.setRoomState('connecting')); }); @@ -2561,8 +2560,6 @@ export default class RoomClient case 'moderator:mute': { - // const { peerId } = notification.data; - if (this._micProducer && !this._micProducer.paused) { this.muteMic(); @@ -2581,8 +2578,6 @@ export default class RoomClient case 'moderator:stopVideo': { - // const { peerId } = notification.data; - this.disableWebcam(); this.disableScreenSharing(); diff --git a/app/src/Spotlights.js b/app/src/Spotlights.js index 30dc48a..3d0673f 100644 --- a/app/src/Spotlights.js +++ b/app/src/Spotlights.js @@ -95,6 +95,15 @@ export default class Spotlights extends EventEmitter }); } + clearSpotlights() + { + this._started = false; + + this._peerList = []; + this._selectedSpotlights = []; + this._currentSpotlights = []; + } + _newPeer(id) { logger.debug( diff --git a/app/src/actions/consumerActions.js b/app/src/actions/consumerActions.js index 249d156..659a609 100644 --- a/app/src/actions/consumerActions.js +++ b/app/src/actions/consumerActions.js @@ -10,6 +10,11 @@ export const removeConsumer = (consumerId, peerId) => payload : { consumerId, peerId } }); +export const clearConsumers = () => + ({ + type : 'CLEAR_CONSUMERS' + }); + export const setConsumerPaused = (consumerId, originator) => ({ type : 'SET_CONSUMER_PAUSED', diff --git a/app/src/actions/peerActions.js b/app/src/actions/peerActions.js index 738928c..5672b47 100644 --- a/app/src/actions/peerActions.js +++ b/app/src/actions/peerActions.js @@ -10,6 +10,11 @@ export const removePeer = (peerId) => payload : { peerId } }); +export const clearPeers = () => + ({ + type : 'CLEAR_PEERS' + }); + export const setPeerDisplayName = (displayName, peerId) => ({ type : 'SET_PEER_DISPLAY_NAME', diff --git a/app/src/actions/roomActions.js b/app/src/actions/roomActions.js index cbfde37..5ae45e3 100644 --- a/app/src/actions/roomActions.js +++ b/app/src/actions/roomActions.js @@ -130,6 +130,11 @@ export const setSpotlights = (spotlights) => payload : { spotlights } }); +export const clearSpotlights = () => + ({ + type : 'CLEAR_SPOTLIGHTS' + }); + export const toggleJoined = () => ({ type : 'TOGGLE_JOINED' diff --git a/app/src/reducers/consumers.js b/app/src/reducers/consumers.js index 68a4a4a..6be31ae 100644 --- a/app/src/reducers/consumers.js +++ b/app/src/reducers/consumers.js @@ -110,6 +110,11 @@ const consumers = (state = initialState, action) => return { ...state, [consumerId]: newConsumer }; } + case 'CLEAR_CONSUMERS': + { + return initialState; + } + default: return state; } diff --git a/app/src/reducers/peers.js b/app/src/reducers/peers.js index 6c4fd1f..32d6fef 100644 --- a/app/src/reducers/peers.js +++ b/app/src/reducers/peers.js @@ -1,4 +1,6 @@ -const peer = (state = {}, action) => +const initialState = {}; + +const peer = (state = initialState, action) => { switch (action.type) { @@ -85,7 +87,7 @@ const peer = (state = {}, action) => } }; -const peers = (state = {}, action) => +const peers = (state = initialState, action) => { switch (action.type) { @@ -139,6 +141,11 @@ const peers = (state = {}, action) => return { ...state, [oldPeer.id]: peer(oldPeer, action) }; } + case 'CLEAR_PEERS': + { + return initialState; + } + default: return state; } diff --git a/app/src/reducers/room.js b/app/src/reducers/room.js index 595ca4e..d784219 100644 --- a/app/src/reducers/room.js +++ b/app/src/reducers/room.js @@ -212,6 +212,11 @@ const room = (state = initialState, action) => return { ...state, spotlights }; } + case 'CLEAR_SPOTLIGHTS': + { + return { ...state, spotlights: [] }; + } + case 'SET_LOBBY_PEERS_PROMOTION_IN_PROGRESS': return { ...state, lobbyPeersPromotionInProgress: action.payload.flag }; From c73ee5c26b4e77a21a529d75c8d71f8c347a27ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Fri, 8 May 2020 22:02:06 +0200 Subject: [PATCH 66/88] Fix missing picture on peer in lobby. --- app/src/RoomClient.js | 18 ++++++++++++------ server/lib/Lobby.js | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index afbfdc9..bce2f03 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -2124,15 +2124,21 @@ export default class RoomClient lobbyPeers.forEach((peer) => { store.dispatch( - lobbyPeerActions.addLobbyPeer(peer.peerId)); + lobbyPeerActions.addLobbyPeer(peer.id)); + store.dispatch( lobbyPeerActions.setLobbyPeerDisplayName( peer.displayName, - peer.peerId + peer.id ) ); + store.dispatch( - lobbyPeerActions.setLobbyPeerPicture(peer.picture)); + lobbyPeerActions.setLobbyPeerPicture( + peer.picture, + peer.id + ) + ); }); store.dispatch( @@ -2930,11 +2936,11 @@ export default class RoomClient (lobbyPeers.length > 0) && lobbyPeers.forEach((peer) => { store.dispatch( - lobbyPeerActions.addLobbyPeer(peer.peerId)); + lobbyPeerActions.addLobbyPeer(peer.id)); store.dispatch( - lobbyPeerActions.setLobbyPeerDisplayName(peer.displayName, peer.peerId)); + lobbyPeerActions.setLobbyPeerDisplayName(peer.displayName, peer.id)); store.dispatch( - lobbyPeerActions.setLobbyPeerPicture(peer.picture)); + lobbyPeerActions.setLobbyPeerPicture(peer.picture, peer.id)); }); (accessCode != null) && store.dispatch( diff --git a/server/lib/Lobby.js b/server/lib/Lobby.js index 0bcfb66..43fc30d 100644 --- a/server/lib/Lobby.js +++ b/server/lib/Lobby.js @@ -46,7 +46,7 @@ class Lobby extends EventEmitter return Object.values(this._peers).map((peer) => ({ - peerId : peer.id, + id : peer.id, displayName : peer.displayName, picture : peer.picture })); From a9e9a1c1fad7c7f9575b7f9873b5351e47fd3454 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Fri, 8 May 2020 22:11:48 +0200 Subject: [PATCH 67/88] Cleanup of join logic, and making sure that lobbyPeers are sent to all peers if last peer with PROMOTE_PEER leaves and allowWhenRoleMissing contains PROMOTE_PEER, fixes #303 --- server/lib/Room.js | 255 ++++++++++++++++++++++++++++----------------- 1 file changed, 157 insertions(+), 98 deletions(-) diff --git a/server/lib/Room.js b/server/lib/Room.js index 3ab281b..74accc4 100644 --- a/server/lib/Room.js +++ b/server/lib/Room.js @@ -1,4 +1,5 @@ const EventEmitter = require('events').EventEmitter; +const AwaitQueue = require('awaitqueue'); const axios = require('axios'); const Logger = require('./Logger'); const Lobby = require('./Lobby'); @@ -49,6 +50,8 @@ const roomPermissions = ...config.permissionsFromRoles }; +const roomAllowWhenRoleMissing = config.allowWhenRoleMissing || []; + const ROUTER_SCALE_SIZE = config.routerScaleSize || 40; class Room extends EventEmitter @@ -115,6 +118,9 @@ class Room extends EventEmitter // Closed flag. this._closed = false; + // Joining queue + this._queue = new AwaitQueue(); + // Locked flag. this._locked = false; @@ -166,6 +172,10 @@ class Room extends EventEmitter this._closed = true; + this._queue.close(); + + this._queue = null; + if (this._selfDestructTimeout) clearTimeout(this._selfDestructTimeout); @@ -288,7 +298,7 @@ class Room extends EventEmitter this._peerJoining(promotedPeer); - for (const peer of this._getPeersWithPermission(PROMOTE_PEER)) + for (const peer of this._getAllowedPeers(PROMOTE_PEER)) { this._notification(peer.socket, 'lobby:promotedPeer', { peerId: id }); } @@ -319,7 +329,7 @@ class Room extends EventEmitter { const { id, displayName } = changedPeer; - for (const peer of this._getPeersWithPermission(PROMOTE_PEER)) + for (const peer of this._getAllowedPeers(PROMOTE_PEER)) { this._notification(peer.socket, 'lobby:changeDisplayName', { peerId: id, displayName }); } @@ -329,7 +339,7 @@ class Room extends EventEmitter { const { id, picture } = changedPeer; - for (const peer of this._getPeersWithPermission(PROMOTE_PEER)) + for (const peer of this._getAllowedPeers(PROMOTE_PEER)) { this._notification(peer.socket, 'lobby:changePicture', { peerId: id, picture }); } @@ -341,7 +351,7 @@ class Room extends EventEmitter const { id } = closedPeer; - for (const peer of this._getPeersWithPermission(PROMOTE_PEER)) + for (const peer of this._getAllowedPeers(PROMOTE_PEER)) { this._notification(peer.socket, 'lobby:peerClosed', { peerId: id }); } @@ -447,114 +457,91 @@ class Room extends EventEmitter { this._lobby.parkPeer(parkPeer); - for (const peer of this._getPeersWithPermission(PROMOTE_PEER)) + for (const peer of this._getAllowedPeers(PROMOTE_PEER)) { this._notification(peer.socket, 'parkedPeer', { peerId: parkPeer.id }); } } - async _peerJoining(peer, returning = false) + _peerJoining(peer, returning = false) { - peer.socket.join(this._roomId); - - // If we don't have this peer, add to end - !this._lastN.includes(peer.id) && this._lastN.push(peer.id); - - this._peers[peer.id] = peer; - - // Assign routerId - peer.routerId = await this._getRouterId(); - - this._handlePeer(peer); - - if (returning) + this._queue.push(async () => { - this._notification(peer.socket, 'roomBack'); - } - else - { - const token = jwt.sign({ id: peer.id }, this._uuid, { noTimestamp: true }); + peer.socket.join(this._roomId); - peer.socket.handshake.session.token = token; + // If we don't have this peer, add to end + !this._lastN.includes(peer.id) && this._lastN.push(peer.id); - peer.socket.handshake.session.save(); + this._peers[peer.id] = peer; - let turnServers; - - if ('turnAPIURI' in config) + // Assign routerId + peer.routerId = await this._getRouterId(); + + this._handlePeer(peer); + + if (returning) { - try - { - const { data } = await axios.get( - config.turnAPIURI, - { - params : { - ...config.turnAPIparams, - 'api_key' : config.turnAPIKey, - 'ip' : peer.socket.request.connection.remoteAddress - } - }); - - turnServers = [ { - urls : data.uris, - username : data.username, - credential : data.password - } ]; - } - catch (error) - { - if ('backupTurnServers' in config) - turnServers = config.backupTurnServers; - - logger.error('_peerJoining() | error on REST turn [error:"%o"]', error); - } + this._notification(peer.socket, 'roomBack'); } - else if ('backupTurnServers' in config) + else { - turnServers = config.backupTurnServers; + const token = jwt.sign({ id: peer.id }, this._uuid, { noTimestamp: true }); + + peer.socket.handshake.session.token = token; + + peer.socket.handshake.session.save(); + + let turnServers; + + if ('turnAPIURI' in config) + { + try + { + const { data } = await axios.get( + config.turnAPIURI, + { + params : { + ...config.turnAPIparams, + 'api_key' : config.turnAPIKey, + 'ip' : peer.socket.request.connection.remoteAddress + } + }); + + turnServers = [ { + urls : data.uris, + username : data.username, + credential : data.password + } ]; + } + catch (error) + { + if ('backupTurnServers' in config) + turnServers = config.backupTurnServers; + + logger.error('_peerJoining() | error on REST turn [error:"%o"]', error); + } + } + else if ('backupTurnServers' in config) + { + turnServers = config.backupTurnServers; + } + + this._notification(peer.socket, 'roomReady', { turnServers }); } - - this._notification(peer.socket, 'roomReady', { turnServers }); - } + }) + .catch((error) => + { + logger.error('_peerJoining() [error:"%o"]', error); + }); } _handlePeer(peer) { logger.debug('_handlePeer() [peer:"%s"]', peer.id); - peer.socket.on('request', (request, cb) => - { - logger.debug( - 'Peer "request" event [method:"%s", peerId:"%s"]', - request.method, peer.id); - - this._handleSocketRequest(peer, request, cb) - .catch((error) => - { - logger.error('"request" failed [error:"%o"]', error); - - cb(error); - }); - }); - peer.on('close', () => { - if (this._closed) - return; - - // If the Peer was joined, notify all Peers. - if (peer.joined) - this._notification(peer.socket, 'peerClosed', { peerId: peer.id }, true); - - // Remove from lastN - this._lastN = this._lastN.filter((id) => id !== peer.id); - - delete this._peers[peer.id]; - - // If this is the last Peer in the room and - // lobby is empty, close the room after a while. - if (this.checkEmpty() && this._lobby.checkEmpty()) - this.selfDestructCountdown(); + this._handlePeerClose(peer); }); peer.on('displayNameChanged', ({ oldDisplayName }) => @@ -620,6 +607,69 @@ class Room extends EventEmitter role : oldRole }, true, true); }); + + peer.socket.on('request', (request, cb) => + { + logger.debug( + 'Peer "request" event [method:"%s", peerId:"%s"]', + request.method, peer.id); + + this._handleSocketRequest(peer, request, cb) + .catch((error) => + { + logger.error('"request" failed [error:"%o"]', error); + + cb(error); + }); + }); + + // Peer left before we were done joining + if (peer.closed) + this._handlePeerClose(peer); + } + + _handlePeerClose(peer) + { + logger.debug('_handlePeerClose() [peer:"%s"]', peer.id); + + if (this._closed) + return; + + // If the Peer was joined, notify all Peers. + if (peer.joined) + this._notification(peer.socket, 'peerClosed', { peerId: peer.id }, true); + + // Remove from lastN + this._lastN = this._lastN.filter((id) => id !== peer.id); + + // Need this to know if this peer was the last with PROMOTE_PEER + const hasPromotePeer = peer.roles.some((role) => + roomPermissions[PROMOTE_PEER].includes(role) + ); + + delete this._peers[peer.id]; + + // No peers left with PROMOTE_PEER, might need to give + // lobbyPeers to peers that are left. + if ( + hasPromotePeer && + !this._lobby.checkEmpty() && + roomAllowWhenRoleMissing.includes(PROMOTE_PEER) && + this._getPeersWithPermission(PROMOTE_PEER).length === 0 + ) + { + const lobbyPeers = this._lobby.peerList(); + + for (const allowedPeer of this._getAllowedPeers(PROMOTE_PEER)) + { + this._notification(allowedPeer.socket, 'parkedPeers', { lobbyPeers }); + } + } + + // If this is the last Peer in the room and + // lobby is empty, close the room after a while. + if (this.checkEmpty() && this._lobby.checkEmpty()) + this.selfDestructCountdown(); } async _handleSocketRequest(peer, request, cb) @@ -656,13 +706,9 @@ class Room extends EventEmitter // Tell the new Peer about already joined Peers. // And also create Consumers for existing Producers. - const joinedPeers = - [ - ...this._getJoinedPeers() - ]; + const joinedPeers = this._getJoinedPeers(peer); const peerInfos = joinedPeers - .filter((joinedPeer) => joinedPeer.id !== peer.id) .map((joinedPeer) => (joinedPeer.peerInfo)); let lobbyPeers = []; @@ -678,7 +724,7 @@ class Room extends EventEmitter authenticated : peer.authenticated, roomPermissions : roomPermissions, userRoles : userRoles, - allowWhenRoleMissing : config.allowWhenRoleMissing, + allowWhenRoleMissing : roomAllowWhenRoleMissing, chatHistory : this._chatHistory, fileHistory : this._fileHistory, lastNHistory : this._lastN, @@ -1622,8 +1668,7 @@ class Room extends EventEmitter // Allow if config is set, and no one is present if ( - 'allowWhenRoleMissing' in config && - config.allowWhenRoleMissing.includes(permission) && + roomAllowWhenRoleMissing.includes(permission) && this._getPeersWithPermission(permission).length === 0 ) return true; @@ -1645,6 +1690,20 @@ class Room extends EventEmitter .filter((peer) => peer.joined && peer !== excludePeer); } + _getAllowedPeers(permission = null, excludePeer = undefined, joined = true) + { + const peers = this._getPeersWithPermission(permission, excludePeer, joined); + + if (peers.length > 0) + return peers; + + // Allow if config is set, and no one is present + if (roomAllowWhenRoleMissing.includes(permission)) + return Object.values(this._peers); + + return peers; + } + _getPeersWithPermission(permission = null, excludePeer = undefined, joined = true) { return Object.values(this._peers) From c5143de64786c5489d309605c3f281fde2b6cd93 Mon Sep 17 00:00:00 2001 From: Roman Drozd <37835902+roman-drozd-it@users.noreply.github.com> Date: Fri, 8 May 2020 22:26:45 +0200 Subject: [PATCH 68/88] Fix ptt break layout (#298) --- app/src/components/Containers/Me.js | 31 +++++++++++++++-------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/app/src/components/Containers/Me.js b/app/src/components/Containers/Me.js index aa3a9a3..743c022 100644 --- a/app/src/components/Containers/Me.js +++ b/app/src/components/Containers/Me.js @@ -132,12 +132,12 @@ const styles = (theme) => { position : 'absolute', float : 'left', - top : '10%', + top : '25%', left : '50%', transform : 'translate(-50%, 0%)', color : 'rgba(255, 255, 255, 0.7)', - fontSize : '2vs', - backgroundColor : 'rgba(255, 0, 0, 0.5)', + fontSize : '1.3em', + backgroundColor : 'rgba(255, 0, 0, 0.9)', margin : '4px', padding : theme.spacing(2), zIndex : 31, @@ -322,19 +322,20 @@ const Me = (props) => }} style={spacingStyle} > + + { me.browser.platform !== 'mobile' && +
    + +
    + }
    - { !smallContainer && -
    - -
    - }

    Date: Sat, 9 May 2020 00:03:56 +0200 Subject: [PATCH 69/88] Option to make side drawer push videos to the side and be permanent, fixes #320 --- app/public/config/config.example.js | 3 + app/src/actions/settingsActions.js | 5 ++ app/src/components/Controls/TopBar.js | 49 ++++++++++- app/src/components/MeetingViews/Democratic.js | 8 +- app/src/components/MeetingViews/Filmstrip.js | 4 + app/src/components/Room.js | 83 +++++++++++++++---- .../components/Settings/AppearenceSettings.js | 17 ++++ app/src/reducers/settings.js | 8 ++ app/src/translations/cn.json | 1 + app/src/translations/cs.json | 1 + app/src/translations/de.json | 1 + app/src/translations/dk.json | 1 + app/src/translations/el.json | 1 + app/src/translations/en.json | 1 + app/src/translations/es.json | 1 + app/src/translations/fr.json | 1 + app/src/translations/hr.json | 1 + app/src/translations/hu.json | 1 + app/src/translations/it.json | 1 + app/src/translations/lv.json | 1 + app/src/translations/nb.json | 1 + app/src/translations/pl.json | 1 + app/src/translations/pt.json | 1 + app/src/translations/ro.json | 1 + app/src/translations/tr.json | 1 + app/src/translations/uk.json | 1 + 26 files changed, 174 insertions(+), 21 deletions(-) diff --git a/app/public/config/config.example.js b/app/public/config/config.example.js index 4bbc13f..7909284 100644 --- a/app/public/config/config.example.js +++ b/app/public/config/config.example.js @@ -57,6 +57,9 @@ var config = // If true, will show media control buttons in separate // control bar, not in the ME container. buttonControlBar : false, + // If false, will push videos away to make room for side + // drawer. If true, will overlay side drawer over videos + drawerOverlayed : true, // Timeout for autohiding topbar and button control bar hideTimeout : 3000, lastN : 4, diff --git a/app/src/actions/settingsActions.js b/app/src/actions/settingsActions.js index 4b7dc6d..21ff2fd 100644 --- a/app/src/actions/settingsActions.js +++ b/app/src/actions/settingsActions.js @@ -43,6 +43,11 @@ export const toggleButtonControlBar = () => type : 'TOGGLE_BUTTON_CONTROL_BAR' }); +export const toggleDrawerOverlayed = () => + ({ + type : 'TOGGLE_DRAWER_OVERLAYED' + }); + export const toggleShowNotifications = () => ({ type : 'TOGGLE_SHOW_NOTIFICATIONS' diff --git a/app/src/components/Controls/TopBar.js b/app/src/components/Controls/TopBar.js index b880c36..52b06cd 100644 --- a/app/src/components/Controls/TopBar.js +++ b/app/src/components/Controls/TopBar.js @@ -14,6 +14,7 @@ import { withStyles } from '@material-ui/core/styles'; import * as roomActions from '../../actions/roomActions'; import * as toolareaActions from '../../actions/toolareaActions'; import { useIntl, FormattedMessage } from 'react-intl'; +import classnames from 'classnames'; import AppBar from '@material-ui/core/AppBar'; import Toolbar from '@material-ui/core/Toolbar'; import MenuItem from '@material-ui/core/MenuItem'; @@ -43,6 +44,31 @@ import InfoIcon from '@material-ui/icons/Info'; const styles = (theme) => ({ + persistentDrawerOpen : + { + width : 'calc(100% - 30vw)', + marginLeft : '30vw', + [theme.breakpoints.down('lg')] : + { + width : 'calc(100% - 40vw)', + marginLeft : '40vw' + }, + [theme.breakpoints.down('md')] : + { + width : 'calc(100% - 50vw)', + marginLeft : '50vw' + }, + [theme.breakpoints.down('sm')] : + { + width : 'calc(100% - 70vw)', + marginLeft : '70vw' + }, + [theme.breakpoints.down('xs')] : + { + width : 'calc(100% - 90vw)', + marginLeft : '90vw' + } + }, menuButton : { margin : 0, @@ -188,6 +214,9 @@ const TopBar = (props) => peersLength, lobbyPeers, permanentTopBar, + drawerOverlayed, + toolAreaOpen, + isMobile, myPicture, loggedIn, loginEnabled, @@ -248,7 +277,12 @@ const TopBar = (props) => const mapStateToProps = (state) => ({ room : state.room, + isMobile : state.me.browser.platform === 'mobile', peersLength : peersLengthSelector(state), lobbyPeers : lobbyPeersKeySelector(state), permanentTopBar : state.settings.permanentTopBar, + drawerOverlayed : state.settings.drawerOverlayed, + toolAreaOpen : state.toolarea.toolAreaOpen, loggedIn : state.me.loggedIn, loginEnabled : state.me.loginEnabled, myPicture : state.me.picture, @@ -832,12 +872,15 @@ export default withRoomContext(connect( prev.peers === next.peers && prev.lobbyPeers === next.lobbyPeers && prev.settings.permanentTopBar === next.settings.permanentTopBar && + prev.settings.drawerOverlayed === next.settings.drawerOverlayed && prev.me.loggedIn === next.me.loggedIn && + prev.me.browser === next.me.browser && prev.me.loginEnabled === next.me.loginEnabled && prev.me.picture === next.me.picture && prev.me.roles === next.me.roles && prev.toolarea.unreadMessages === next.toolarea.unreadMessages && - prev.toolarea.unreadFiles === next.toolarea.unreadFiles + prev.toolarea.unreadFiles === next.toolarea.unreadFiles && + prev.toolarea.toolAreaOpen === next.toolarea.toolAreaOpen ); } } diff --git a/app/src/components/MeetingViews/Democratic.js b/app/src/components/MeetingViews/Democratic.js index f2fff1a..e2e774c 100644 --- a/app/src/components/MeetingViews/Democratic.js +++ b/app/src/components/MeetingViews/Democratic.js @@ -22,6 +22,7 @@ const styles = (theme) => display : 'flex', flexDirection : 'row', flexWrap : 'wrap', + overflow : 'hidden', justifyContent : 'center', alignItems : 'center', alignContent : 'center' @@ -189,6 +190,7 @@ Democratic.propTypes = toolbarsVisible : PropTypes.bool.isRequired, permanentTopBar : PropTypes.bool.isRequired, buttonControlBar : PropTypes.bool.isRequired, + toolAreaOpen : PropTypes.bool.isRequired, classes : PropTypes.object.isRequired }; @@ -199,7 +201,8 @@ const mapStateToProps = (state) => spotlightsPeers : spotlightPeersSelector(state), toolbarsVisible : state.room.toolbarsVisible, permanentTopBar : state.settings.permanentTopBar, - buttonControlBar : state.settings.buttonControlBar + buttonControlBar : state.settings.buttonControlBar, + toolAreaOpen : state.toolarea.toolAreaOpen }; }; @@ -217,7 +220,8 @@ export default connect( prev.room.spotlights === next.room.spotlights && prev.room.toolbarsVisible === next.room.toolbarsVisible && prev.settings.permanentTopBar === next.settings.permanentTopBar && - prev.settings.buttonControlBar === next.settings.buttonControlBar + prev.settings.buttonControlBar === next.settings.buttonControlBar && + prev.toolarea.toolAreaOpen === next.toolarea.toolAreaOpen ); } } diff --git a/app/src/components/MeetingViews/Filmstrip.js b/app/src/components/MeetingViews/Filmstrip.js index d1cfba6..120e985 100644 --- a/app/src/components/MeetingViews/Filmstrip.js +++ b/app/src/components/MeetingViews/Filmstrip.js @@ -25,6 +25,7 @@ const styles = () => height : '100%', width : '100%', display : 'grid', + overflow : 'hidden', gridTemplateColumns : '1fr', gridTemplateRows : '1fr 0.25fr' }, @@ -334,6 +335,7 @@ Filmstrip.propTypes = { spotlights : PropTypes.array.isRequired, boxes : PropTypes.number, toolbarsVisible : PropTypes.bool.isRequired, + toolAreaOpen : PropTypes.bool.isRequired, permanentTopBar : PropTypes.bool, classes : PropTypes.object.isRequired }; @@ -349,6 +351,7 @@ const mapStateToProps = (state) => spotlights : state.room.spotlights, boxes : videoBoxesSelector(state), toolbarsVisible : state.room.toolbarsVisible, + toolAreaOpen : state.toolarea.toolAreaOpen, permanentTopBar : state.settings.permanentTopBar }; }; @@ -364,6 +367,7 @@ export default withRoomContext(connect( prev.room.activeSpeakerId === next.room.activeSpeakerId && prev.room.selectedPeerId === next.room.selectedPeerId && prev.room.toolbarsVisible === next.room.toolbarsVisible && + prev.toolarea.toolAreaOpen === next.toolarea.toolAreaOpen && prev.settings.permanentTopBar === next.settings.permanentTopBar && prev.peers === next.peers && prev.consumers === next.consumers && diff --git a/app/src/components/Room.js b/app/src/components/Room.js index d021132..4dee360 100644 --- a/app/src/components/Room.js +++ b/app/src/components/Room.js @@ -12,6 +12,7 @@ import { FormattedMessage } from 'react-intl'; import CookieConsent from 'react-cookie-consent'; import CssBaseline from '@material-ui/core/CssBaseline'; import SwipeableDrawer from '@material-ui/core/SwipeableDrawer'; +import Drawer from '@material-ui/core/Drawer'; import Hidden from '@material-ui/core/Hidden'; import Notifications from './Notifications/Notifications'; import MeetingDrawer from './MeetingDrawer/MeetingDrawer'; @@ -45,6 +46,27 @@ const styles = (theme) => backgroundSize : 'cover', backgroundRepeat : 'no-repeat' }, + drawer : + { + width : '30vw', + flexShrink : 0, + [theme.breakpoints.down('lg')] : + { + width : '40vw' + }, + [theme.breakpoints.down('md')] : + { + width : '50vw' + }, + [theme.breakpoints.down('sm')] : + { + width : '70vw' + }, + [theme.breakpoints.down('xs')] : + { + width : '90vw' + } + }, drawerPaper : { width : '30vw', @@ -147,6 +169,7 @@ class Room extends React.PureComponent advancedMode, showNotifications, buttonControlBar, + drawerOverlayed, toolAreaOpen, toggleToolArea, classes, @@ -159,6 +182,8 @@ class Room extends React.PureComponent democratic : Democratic }[room.mode]; + const container = window !== undefined ? window.document.body : undefined; + return (

    { !isElectron() && @@ -195,22 +220,45 @@ class Room extends React.PureComponent onFullscreen={this.handleToggleFullscreen} /> - + { (browser.platform === 'mobile' || drawerOverlayed) ? + + : + + } { browser.platform === 'mobile' && browser.os !== 'ios' && @@ -252,6 +300,7 @@ Room.propTypes = advancedMode : PropTypes.bool.isRequired, showNotifications : PropTypes.bool.isRequired, buttonControlBar : PropTypes.bool.isRequired, + drawerOverlayed : PropTypes.bool.isRequired, toolAreaOpen : PropTypes.bool.isRequired, setToolbarsVisible : PropTypes.func.isRequired, toggleToolArea : PropTypes.func.isRequired, @@ -266,6 +315,7 @@ const mapStateToProps = (state) => advancedMode : state.settings.advancedMode, showNotifications : state.settings.showNotifications, buttonControlBar : state.settings.buttonControlBar, + drawerOverlayed : state.settings.drawerOverlayed, toolAreaOpen : state.toolarea.toolAreaOpen }); @@ -294,6 +344,7 @@ export default connect( prev.settings.advancedMode === next.settings.advancedMode && prev.settings.showNotifications === next.settings.showNotifications && prev.settings.buttonControlBar === next.settings.buttonControlBar && + prev.settings.drawerOverlayed === next.settings.drawerOverlayed && prev.toolarea.toolAreaOpen === next.toolarea.toolAreaOpen ); } diff --git a/app/src/components/Settings/AppearenceSettings.js b/app/src/components/Settings/AppearenceSettings.js index a859ab4..46cc898 100644 --- a/app/src/components/Settings/AppearenceSettings.js +++ b/app/src/components/Settings/AppearenceSettings.js @@ -26,12 +26,14 @@ const styles = (theme) => }); const AppearenceSettings = ({ + isMobile, room, settings, onTogglePermanentTopBar, onToggleHiddenControls, onToggleButtonControlBar, onToggleShowNotifications, + onToggleDrawerOverlayed, handleChangeMode, classes }) => @@ -111,6 +113,16 @@ const AppearenceSettings = ({ defaultMessage : 'Separate media controls' })} /> + { !isMobile && + } + label={intl.formatMessage({ + id : 'settings.drawerOverlayed', + defaultMessage : 'Side drawer over content' + })} + /> + } } @@ -125,18 +137,21 @@ const AppearenceSettings = ({ AppearenceSettings.propTypes = { + isMobile : PropTypes.bool.isRequired, room : appPropTypes.Room.isRequired, settings : PropTypes.object.isRequired, onTogglePermanentTopBar : PropTypes.func.isRequired, onToggleHiddenControls : PropTypes.func.isRequired, onToggleButtonControlBar : PropTypes.func.isRequired, onToggleShowNotifications : PropTypes.func.isRequired, + onToggleDrawerOverlayed : PropTypes.func.isRequired, handleChangeMode : PropTypes.func.isRequired, classes : PropTypes.object.isRequired }; const mapStateToProps = (state) => ({ + isMobile : state.me.browser.platform === 'mobile', room : state.room, settings : state.settings }); @@ -146,6 +161,7 @@ const mapDispatchToProps = { onToggleHiddenControls : settingsActions.toggleHiddenControls, onToggleShowNotifications : settingsActions.toggleShowNotifications, onToggleButtonControlBar : settingsActions.toggleButtonControlBar, + onToggleDrawerOverlayed : settingsActions.toggleDrawerOverlayed, handleChangeMode : roomActions.setDisplayMode }; @@ -157,6 +173,7 @@ export default connect( areStatesEqual : (next, prev) => { return ( + prev.me.browser === next.me.browser && prev.room === next.room && prev.settings === next.settings ); diff --git a/app/src/reducers/settings.js b/app/src/reducers/settings.js index f306726..1c375bf 100644 --- a/app/src/reducers/settings.js +++ b/app/src/reducers/settings.js @@ -19,6 +19,7 @@ const initialState = showNotifications : true, notificationSounds : true, buttonControlBar : window.config.buttonControlBar || false, + drawerOverlayed : window.config.drawerOverlayed || true, ...window.config.defaultAudio }; @@ -153,6 +154,13 @@ const settings = (state = initialState, action) => return { ...state, buttonControlBar }; } + case 'TOGGLE_DRAWER_OVERLAYED': + { + const drawerOverlayed = !state.drawerOverlayed; + + return { ...state, drawerOverlayed }; + } + case 'TOGGLE_HIDDEN_CONTROLS': { const hiddenControls = !state.hiddenControls; diff --git a/app/src/translations/cn.json b/app/src/translations/cn.json index df97372..0ce5f5b 100644 --- a/app/src/translations/cn.json +++ b/app/src/translations/cn.json @@ -138,6 +138,7 @@ "settings.echoCancellation": null, "settings.autoGainControl": null, "settings.noiseSuppression": null, + "settings.drawerOverlayed": null, "filesharing.saveFileError": "无法保存文件", "filesharing.startingFileShare": "正在尝试共享文件", diff --git a/app/src/translations/cs.json b/app/src/translations/cs.json index f73144e..3eac16c 100644 --- a/app/src/translations/cs.json +++ b/app/src/translations/cs.json @@ -137,6 +137,7 @@ "settings.echoCancellation": null, "settings.autoGainControl": null, "settings.noiseSuppression": null, + "settings.drawerOverlayed": null, "filesharing.saveFileError": "Není možné uložit soubor", "filesharing.startingFileShare": "Pokouším se sdílet soubor", diff --git a/app/src/translations/de.json b/app/src/translations/de.json index d141ae9..402c4b3 100644 --- a/app/src/translations/de.json +++ b/app/src/translations/de.json @@ -138,6 +138,7 @@ "settings.echoCancellation": null, "settings.autoGainControl": null, "settings.noiseSuppression": null, + "settings.drawerOverlayed": null, "filesharing.saveFileError": "Fehler beim Speichern der Datei", "filesharing.startingFileShare": "Starte Teilen der Datei", diff --git a/app/src/translations/dk.json b/app/src/translations/dk.json index a0bc95f..c5c3a2d 100644 --- a/app/src/translations/dk.json +++ b/app/src/translations/dk.json @@ -138,6 +138,7 @@ "settings.echoCancellation": null, "settings.autoGainControl": null, "settings.noiseSuppression": null, + "settings.drawerOverlayed": null, "filesharing.saveFileError": "Kan ikke gemme fil", "filesharing.startingFileShare": "Forsøger at dele filen", diff --git a/app/src/translations/el.json b/app/src/translations/el.json index c21ef1d..d71764f 100644 --- a/app/src/translations/el.json +++ b/app/src/translations/el.json @@ -138,6 +138,7 @@ "settings.echoCancellation": null, "settings.autoGainControl": null, "settings.noiseSuppression": null, + "settings.drawerOverlayed": null, "filesharing.saveFileError": "Αδυναμία αποθήκευσης του αρχείου", "filesharing.startingFileShare": "Προσπάθεια διαμοιρασμού αρχείου", diff --git a/app/src/translations/en.json b/app/src/translations/en.json index 4b33dbd..344d660 100644 --- a/app/src/translations/en.json +++ b/app/src/translations/en.json @@ -138,6 +138,7 @@ "settings.echoCancellation": "Echo cancellation", "settings.autoGainControl": "Auto gain control", "settings.noiseSuppression": "Noise suppression", + "settings.drawerOverlayed": "Side drawer over content", "filesharing.saveFileError": "Unable to save file", "filesharing.startingFileShare": "Attempting to share file", diff --git a/app/src/translations/es.json b/app/src/translations/es.json index 9bf9744..758b0c8 100644 --- a/app/src/translations/es.json +++ b/app/src/translations/es.json @@ -138,6 +138,7 @@ "settings.echoCancellation": null, "settings.autoGainControl": null, "settings.noiseSuppression": null, + "settings.drawerOverlayed": null, "filesharing.saveFileError": "No ha sido posible guardar el fichero", "filesharing.startingFileShare": "Intentando compartir el fichero", diff --git a/app/src/translations/fr.json b/app/src/translations/fr.json index 2b74da9..2eb7edf 100644 --- a/app/src/translations/fr.json +++ b/app/src/translations/fr.json @@ -138,6 +138,7 @@ "settings.echoCancellation": null, "settings.autoGainControl": null, "settings.noiseSuppression": null, + "settings.drawerOverlayed": null, "filesharing.saveFileError": "Impossible d'enregistrer le fichier", "filesharing.startingFileShare": "Début du transfert de fichier", diff --git a/app/src/translations/hr.json b/app/src/translations/hr.json index 9d55c44..c0f8879 100644 --- a/app/src/translations/hr.json +++ b/app/src/translations/hr.json @@ -138,6 +138,7 @@ "settings.echoCancellation": null, "settings.autoGainControl": null, "settings.noiseSuppression": null, + "settings.drawerOverlayed": null, "filesharing.saveFileError": "Nije moguće spremiti datoteku", "filesharing.startingFileShare": "Pokušaj dijeljenja datoteke", diff --git a/app/src/translations/hu.json b/app/src/translations/hu.json index be0342d..cf65f1d 100644 --- a/app/src/translations/hu.json +++ b/app/src/translations/hu.json @@ -138,6 +138,7 @@ "settings.echoCancellation": "Visszhangelnyomás", "settings.autoGainControl": "Automatikus hangerő", "settings.noiseSuppression": "Zajelnyomás", + "settings.drawerOverlayed": null, "filesharing.saveFileError": "A file-t nem sikerült elmenteni", "filesharing.startingFileShare": "Fájl megosztása", diff --git a/app/src/translations/it.json b/app/src/translations/it.json index 8ce1764..5ce26b6 100644 --- a/app/src/translations/it.json +++ b/app/src/translations/it.json @@ -137,6 +137,7 @@ "settings.echoCancellation": null, "settings.autoGainControl": null, "settings.noiseSuppression": null, + "settings.drawerOverlayed": null, "filesharing.saveFileError": "Impossibile salvare file", "filesharing.startingFileShare": "Tentativo di condivisione file", diff --git a/app/src/translations/lv.json b/app/src/translations/lv.json index 5a39315..7bf0b24 100644 --- a/app/src/translations/lv.json +++ b/app/src/translations/lv.json @@ -132,6 +132,7 @@ "settings.echoCancellation": null, "settings.autoGainControl": null, "settings.noiseSuppression": null, + "settings.drawerOverlayed": null, "filesharing.saveFileError": "Nav iespējams saglabāt failu", "filesharing.startingFileShare": "Tiek mēģināts kopīgot failu", diff --git a/app/src/translations/nb.json b/app/src/translations/nb.json index 139341a..251858b 100644 --- a/app/src/translations/nb.json +++ b/app/src/translations/nb.json @@ -138,6 +138,7 @@ "settings.echoCancellation": "Echokansellering", "settings.autoGainControl": "Auto gain kontroll", "settings.noiseSuppression": "Støy reduksjon", + "settings.drawerOverlayed": "Sidemeny over innhold", "filesharing.saveFileError": "Klarte ikke å lagre fil", "filesharing.startingFileShare": "Starter fildeling", diff --git a/app/src/translations/pl.json b/app/src/translations/pl.json index 4c8f28a..705a6d7 100644 --- a/app/src/translations/pl.json +++ b/app/src/translations/pl.json @@ -138,6 +138,7 @@ "settings.echoCancellation": null, "settings.autoGainControl": null, "settings.noiseSuppression": null, + "settings.drawerOverlayed": null, "filesharing.saveFileError": "Nie można zapisać pliku", "filesharing.startingFileShare": "Próba udostępnienia pliku", diff --git a/app/src/translations/pt.json b/app/src/translations/pt.json index df2bf73..8250231 100644 --- a/app/src/translations/pt.json +++ b/app/src/translations/pt.json @@ -138,6 +138,7 @@ "settings.echoCancellation": null, "settings.autoGainControl": null, "settings.noiseSuppression": null, + "settings.drawerOverlayed": null, "filesharing.saveFileError": "Impossível de gravar o ficheiro", "filesharing.startingFileShare": "Tentando partilha de ficheiro", diff --git a/app/src/translations/ro.json b/app/src/translations/ro.json index fe7fb50..1ba455c 100644 --- a/app/src/translations/ro.json +++ b/app/src/translations/ro.json @@ -138,6 +138,7 @@ "settings.echoCancellation": null, "settings.autoGainControl": null, "settings.noiseSuppression": null, + "settings.drawerOverlayed": null, "filesharing.saveFileError": "Încercarea de a salva fișierul a eșuat", "filesharing.startingFileShare": "Partajarea fișierului", diff --git a/app/src/translations/tr.json b/app/src/translations/tr.json index 4a105d4..524c557 100644 --- a/app/src/translations/tr.json +++ b/app/src/translations/tr.json @@ -135,6 +135,7 @@ "settings.echoCancellation": null, "settings.autoGainControl": null, "settings.noiseSuppression": null, + "settings.drawerOverlayed": null, "filesharing.saveFileError": "Dosya kaydedilemiyor", "filesharing.startingFileShare": "Paylaşılan dosyaya erişiliyor", diff --git a/app/src/translations/uk.json b/app/src/translations/uk.json index 71ead8e..a8cc077 100644 --- a/app/src/translations/uk.json +++ b/app/src/translations/uk.json @@ -138,6 +138,7 @@ "settings.echoCancellation": null, "settings.autoGainControl": null, "settings.noiseSuppression": null, + "settings.drawerOverlayed": null, "filesharing.saveFileError": "Неможливо зберегти файл", "filesharing.startingFileShare": "Спроба поділитися файлом", From 13e611e17747481fb0ca9b207052255d32db34b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Sat, 9 May 2020 00:07:12 +0200 Subject: [PATCH 70/88] Missing tooltip, fixes #324 --- app/src/components/Controls/TopBar.js | 29 +++++++++++++++++---------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/app/src/components/Controls/TopBar.js b/app/src/components/Controls/TopBar.js index 52b06cd..bbf408d 100644 --- a/app/src/components/Controls/TopBar.js +++ b/app/src/components/Controls/TopBar.js @@ -312,18 +312,25 @@ const TopBar = (props) =>
    - handleMenuOpen(event, 'moreActions')} - color='inherit' + - - + handleMenuOpen(event, 'moreActions')} + color='inherit' + > + + + { fullscreenEnabled && Date: Sat, 9 May 2020 00:09:52 +0200 Subject: [PATCH 71/88] Add h shortcut for help dialog --- app/src/RoomClient.js | 7 +++++++ app/src/components/Controls/Help.js | 1 + 2 files changed, 8 insertions(+) diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index bce2f03..2217d65 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -408,6 +408,13 @@ export default class RoomClient break; } + case 'H': // Open help dialog + { + store.dispatch(roomActions.setHelpOpen(true)); + + break; + } + default: { break; diff --git a/app/src/components/Controls/Help.js b/app/src/components/Controls/Help.js index 3091ec4..98415b5 100644 --- a/app/src/components/Controls/Help.js +++ b/app/src/components/Controls/Help.js @@ -17,6 +17,7 @@ import Tabs from '@material-ui/core/Tabs'; import Tab from '@material-ui/core/Tab'; const shortcuts=[ + { key: 'h', label: 'room.help', defaultMessage: 'Help' }, { key: 'm', label: 'device.muteAudio', defaultMessage: 'Mute Audio' }, { key: 'v', label: 'device.stopVideo', defaultMessage: 'Mute Video' }, { key: '1', label: 'label.democratic', defaultMessage: 'Democratic View' }, From 1bccc9a98568303e159fc66f9e833bdc16e4e8aa Mon Sep 17 00:00:00 2001 From: Roman Drozd Date: Sat, 9 May 2020 00:29:20 +0200 Subject: [PATCH 72/88] Update pl translation --- app/src/translations/pl.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/translations/pl.json b/app/src/translations/pl.json index 705a6d7..8d3ecf5 100644 --- a/app/src/translations/pl.json +++ b/app/src/translations/pl.json @@ -60,7 +60,7 @@ "room.loweredHand": null, "room.extraVideo": null, "room.overRoomLimit": null, - "room.help": null, + "room.help": "Pomoc", "room.about": null, "room.shortcutKeys": null, From aa5f75b79478662ccb11ecb5ce5f45e21cfe2251 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Sat, 9 May 2020 00:37:47 +0200 Subject: [PATCH 73/88] Do things in correct order. --- server/lib/Lobby.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/lib/Lobby.js b/server/lib/Lobby.js index 43fc30d..45cdc06 100644 --- a/server/lib/Lobby.js +++ b/server/lib/Lobby.js @@ -154,8 +154,6 @@ class Lobby extends EventEmitter this.emit('lobbyEmpty'); }; - this._notification(peer.socket, 'enteredLobby'); - this._peers[peer.id] = peer; peer.on('gotRole', peer.gotRoleHandler); @@ -165,6 +163,8 @@ class Lobby extends EventEmitter peer.socket.on('request', peer.socketRequestHandler); peer.on('close', peer.closeHandler); + + this._notification(peer.socket, 'enteredLobby'); } async _handleSocketRequest(peer, request, cb) @@ -189,7 +189,8 @@ class Lobby extends EventEmitter cb(); break; - } + } + case 'changePicture': { const { picture } = request.data; From f468f95cdc58a2ab8ca2b1a408ffb3f02e8afbd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Sat, 9 May 2020 00:43:05 +0200 Subject: [PATCH 74/88] Translated media buttons, fixes #309 --- .../components/Controls/ButtonControlBar.js | 75 +++++++++++++++---- 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/app/src/components/Controls/ButtonControlBar.js b/app/src/components/Controls/ButtonControlBar.js index 2ee5b7e..f6e4fd6 100644 --- a/app/src/components/Controls/ButtonControlBar.js +++ b/app/src/components/Controls/ButtonControlBar.js @@ -7,6 +7,7 @@ import useMediaQuery from '@material-ui/core/useMediaQuery'; import classnames from 'classnames'; import * as appPropTypes from '../appPropTypes'; import { withRoomContext } from '../../RoomContext'; +import { useIntl } from 'react-intl'; import Fab from '@material-ui/core/Fab'; import Tooltip from '@material-ui/core/Tooltip'; import MicIcon from '@material-ui/icons/Mic'; @@ -57,6 +58,8 @@ const styles = (theme) => const ButtonControlBar = (props) => { + const intl = useIntl(); + const { roomClient, toolbarsVisible, @@ -73,20 +76,37 @@ const ButtonControlBar = (props) => let micTip; - if (!me.canSendMic || !micProducer) + if (!me.canSendMic) { micState = 'unsupported'; - micTip = 'Audio unsupported'; + micTip = intl.formatMessage({ + id : 'device.audioUnsupported', + defaultMessage : 'Audio unsupported' + }); + } + else if (!micProducer) + { + micState = 'off'; + micTip = intl.formatMessage({ + id : 'device.activateAudio', + defaultMessage : 'Activate audio' + }); } else if (!micProducer.locallyPaused && !micProducer.remotelyPaused) { micState = 'on'; - micTip = 'Mute audio'; + micTip = intl.formatMessage({ + id : 'device.muteAudio', + defaultMessage : 'Mute audio' + }); } else { - micState = 'off'; - micTip = 'Unmute audio'; + micState = 'muted'; + micTip = intl.formatMessage({ + id : 'device.unMuteAudio', + defaultMessage : 'Unmute audio' + }); } let webcamState; @@ -96,17 +116,26 @@ const ButtonControlBar = (props) => if (!me.canSendWebcam) { webcamState = 'unsupported'; - webcamTip = 'Video unsupported'; + webcamTip = intl.formatMessage({ + id : 'device.videoUnsupported', + defaultMessage : 'Video unsupported' + }); } else if (webcamProducer) { webcamState = 'on'; - webcamTip = 'Stop video'; + webcamTip = intl.formatMessage({ + id : 'device.stopVideo', + defaultMessage : 'Stop video' + }); } else { webcamState = 'off'; - webcamTip = 'Start video'; + webcamTip = intl.formatMessage({ + id : 'device.startVideo', + defaultMessage : 'Start video' + }); } let screenState; @@ -116,17 +145,26 @@ const ButtonControlBar = (props) => if (!me.canShareScreen) { screenState = 'unsupported'; - screenTip = 'Screen sharing not supported'; + screenTip = intl.formatMessage({ + id : 'device.screenSharingUnsupported', + defaultMessage : 'Screen sharing not supported' + }); } else if (screenProducer) { screenState = 'on'; - screenTip = 'Stop screen sharing'; + screenTip = intl.formatMessage({ + id : 'device.stopScreenSharing', + defaultMessage : 'Stop screen sharing' + }); } else { screenState = 'off'; - screenTip = 'Start screen sharing'; + screenTip = intl.formatMessage({ + id : 'device.startScreenSharing', + defaultMessage : 'Start screen sharing' + }); } const smallScreen = useMediaQuery(theme.breakpoints.down('sm')); @@ -143,7 +181,10 @@ const ButtonControlBar = (props) => > Date: Sat, 9 May 2020 00:43:20 +0200 Subject: [PATCH 75/88] Remove unused function --- app/src/components/Room.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/components/Room.js b/app/src/components/Room.js index 4dee360..e862be6 100644 --- a/app/src/components/Room.js +++ b/app/src/components/Room.js @@ -249,7 +249,6 @@ class Room extends React.PureComponent anchor={theme.direction === 'rtl' ? 'right' : 'left'} open={toolAreaOpen} onClose={() => toggleToolArea()} - onOpen={() => toggleToolArea()} classes={{ paper : classes.drawerPaper }} From 2dcecae8889472fe74b18aec0783fe8064afade6 Mon Sep 17 00:00:00 2001 From: Roman Drozd Date: Sat, 9 May 2020 01:26:50 +0200 Subject: [PATCH 76/88] Update pl translation --- app/src/translations/pl.json | 80 ++++++++++++++++++------------------ 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/app/src/translations/pl.json b/app/src/translations/pl.json index 8d3ecf5..174684c 100644 --- a/app/src/translations/pl.json +++ b/app/src/translations/pl.json @@ -49,25 +49,25 @@ "room.spotlights": "Aktywni uczestnicy", "room.passive": "Pasywni uczestnicy", "room.videoPaused": "To wideo jest wstrzymane.", - "room.muteAll": null, - "room.stopAllVideo": null, - "room.closeMeeting": null, - "room.clearChat": null, - "room.clearFileSharing": null, - "room.speechUnsupported": null, - "room.moderatoractions": null, - "room.raisedHand": null, - "room.loweredHand": null, - "room.extraVideo": null, - "room.overRoomLimit": null, + "room.muteAll": "Wycisz wszystkich", + "room.stopAllVideo": "Zatrzymaj wszystkie Video", + "room.closeMeeting": "Zamknij spotkanie", + "room.clearChat": "Wyczyść Chat", + "room.clearFileSharing": "Wyczyść pliki", + "room.speechUnsupported": "Twoja przeglądarka nie rozpoznaje mowy", + "room.moderatoractions": "Akcje moderatora", + "room.raisedHand": "{displayName} podniósł rękę", + "room.loweredHand": "{displayName} opuścił rękę", + "room.extraVideo": "Dodatkowe Video", + "room.overRoomLimit": "Pokój jest pełny, spróbuj za jakiś czas.", "room.help": "Pomoc", - "room.about": null, - "room.shortcutKeys": null, + "room.about": "O pogramie", + "room.shortcutKeys": "Skróty klawiaturowe", - "me.mutedPTT": null, + "me.mutedPTT": "Masz wyciszony mikrofon, przytrzymaj spację aby mówić", - "roles.gotRole": null, - "roles.lostRole": null, + "roles.gotRole": "Masz rolę {role}", + "roles.lostRole": "Nie masz już roli {role}", "tooltip.login": "Zaloguj", "tooltip.logout": "Wyloguj", @@ -79,11 +79,11 @@ "tooltip.lobby": "Pokaż poczekalnię", "tooltip.settings": "Pokaż ustawienia", "tooltip.participants": "Pokaż uczestników", - "tooltip.kickParticipant": null, - "tooltip.muteParticipant": null, - "tooltip.muteParticipantVideo": null, - "tooltip.raisedHand": null, - "tooltip.muteScreenSharing": null, + "tooltip.kickParticipant": "Wyrzuć użytkownika", + "tooltip.muteParticipant": "Wycisz użytkownika", + "tooltip.muteParticipantVideo": "Wyłącz wideo użytkownika", + "tooltip.raisedHand": "Podnieś rękę", + "tooltip.muteScreenSharing": "Anuluj udostępniania pulpitu przez użytkownika", "label.roomName": "Nazwa konferencji", "label.chooseRoomButton": "Kontynuuj", @@ -97,7 +97,7 @@ "label.filesharing": "Udostępnianie plików", "label.participants": "Uczestnicy", "label.shareFile": "Udostępnij plik", - "label.shareGalleryFile": null, + "label.shareGalleryFile": "Udostępnij obraz", "label.fileSharingUnsupported": "Udostępnianie plików nie jest obsługiwane", "label.unknown": "Nieznane", "label.democratic": "Układ demokratyczny", @@ -108,12 +108,12 @@ "label.veryHigh": "Bardzo wysoka (FHD)", "label.ultra": "Ultra (UHD)", "label.close": "Zamknij", - "label.media": null, - "label.appearence": null, - "label.advanced": null, - "label.addVideo": null, - "label.promoteAllPeers": null, - "label.moreActions": null, + "label.media": "Media", + "label.appearence": "Wygląd", + "label.advanced": "Zaawansowane", + "label.addVideo": "Dodaj wideo", + "label.promoteAllPeers": "Wpuść wszystkich", + "label.moreActions": "Więcej akcji", "settings.settings": "Ustawienia", "settings.camera": "Kamera", @@ -131,14 +131,14 @@ "settings.advancedMode": "Tryb zaawansowany", "settings.permanentTopBar": "Stały górny pasek", "settings.lastn": "Liczba widocznych uczestników (zdalnych)", - "settings.hiddenControls": null, - "settings.notificationSounds": null, - "settings.showNotifications": null, - "settings.buttonControlBar": null, - "settings.echoCancellation": null, - "settings.autoGainControl": null, - "settings.noiseSuppression": null, - "settings.drawerOverlayed": null, + "settings.hiddenControls": "Ukryte kontrolki mediów", + "settings.notificationSounds": "Powiadomienia dźwiękiem", + "settings.showNotifications": "Pokaż powiadomienia", + "settings.buttonControlBar": "Rozdziel kontrolki mediów", + "settings.echoCancellation": "Usuwanie echa", + "settings.autoGainControl": "Auto korekta wzmocnienia", + "settings.noiseSuppression": "Wyciszenie szumów", + "settings.drawerOverlayed": "Szuflada nad zawartością", "filesharing.saveFileError": "Nie można zapisać pliku", "filesharing.startingFileShare": "Próba udostępnienia pliku", @@ -180,8 +180,8 @@ "devices.cameraDisconnected": "Kamera odłączona", "devices.cameraError": "Wystąpił błąd podczas uzyskiwania dostępu do kamery", - "moderator.clearChat": null, - "moderator.clearFiles": null, - "moderator.muteAudio": null, - "moderator.muteVideo": null + "moderator.clearChat": "Moderator wyczyścił chat", + "moderator.clearFiles": "Moderator wyczyścił pliki", + "moderator.muteAudio": "Moderator wyciszył audio", + "moderator.muteVideo": "Moderator wyciszył twoje video" } \ No newline at end of file From 36b40fd795e346372784f5bd631e2703498698b1 Mon Sep 17 00:00:00 2001 From: Stefan Otto Date: Sat, 9 May 2020 12:15:05 +0200 Subject: [PATCH 77/88] Added german translations --- app/src/translations/de.json | 72 ++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/app/src/translations/de.json b/app/src/translations/de.json index 402c4b3..c4b67fe 100644 --- a/app/src/translations/de.json +++ b/app/src/translations/de.json @@ -52,22 +52,22 @@ "room.muteAll": "Alle stummschalten", "room.stopAllVideo": "Alle Videos stoppen", "room.closeMeeting": "Meeting schließen", - "room.clearChat": null, - "room.clearFileSharing": null, + "room.clearChat": "Liste löschen", + "room.clearFileSharing": "Liste löschen", "room.speechUnsupported": "Dein Browser unterstützt keine Spracherkennung", - "room.moderatoractions": null, - "room.raisedHand": null, - "room.loweredHand": null, - "room.extraVideo": null, - "room.overRoomLimit": null, - "room.help": null, - "room.about": null, - "room.shortcutKeys": null, + "room.moderatoractions": "Moderator Aktionen", + "room.raisedHand": "{displayName} hebt die Hand", + "room.loweredHand": "{displayName} senkt die Hand", + "room.extraVideo": "Video hinzufügen", + "room.overRoomLimit": "Der Raum ist voll, probiere es später nochmal", + "room.help": "Hilfe", + "room.about": "Impressum", + "room.shortcutKeys": "Tastaturkürzel", "me.mutedPTT": "Du bist stummgeschalted, Halte die SPACE-Taste um zu sprechen", - "roles.gotRole": null, - "roles.lostRole": null, + "roles.gotRole": "Rolle erhalten: {role}", + "roles.lostRole": "Rolle entzogen: {role}", "tooltip.login": "Anmelden", "tooltip.logout": "Abmelden", @@ -79,11 +79,11 @@ "tooltip.lobby": "Warteraum", "tooltip.settings": "Einstellungen", "tooltip.participants": "Teilnehmer", - "tooltip.kickParticipant": "Teilnehmer rauswerfen", - "tooltip.muteParticipant": null, - "tooltip.muteParticipantVideo": null, - "tooltip.raisedHand": null, - "tooltip.muteScreenSharing": null, + "tooltip.kickParticipant": "Rauswerfen", + "tooltip.muteParticipant": "Stummschalten", + "tooltip.muteParticipantVideo": "Video stoppen", + "tooltip.raisedHand": "Hand heben", + "tooltip.muteScreenSharing": "Stoppe Bildschirmfreigabe", "label.roomName": "Name des Raums", "label.chooseRoomButton": "Weiter", @@ -97,7 +97,7 @@ "label.filesharing": "Dateien", "label.participants": "Teilnehmer", "label.shareFile": "Datei hochladen", - "label.shareGalleryFile": null, + "label.shareGalleryFile": "Bild teilen", "label.fileSharingUnsupported": "Dateifreigabe nicht unterstützt", "label.unknown": "Unbekannt", "label.democratic": "Demokratisch", @@ -108,12 +108,12 @@ "label.veryHigh": "Sehr hoch (FHD)", "label.ultra": "Ultra (UHD)", "label.close": "Schließen", - "label.media": null, - "label.appearence": null, - "label.advanced": null, - "label.addVideo": null, - "label.promoteAllPeers": null, - "label.moreActions": null, + "label.media": "Audio / Video", + "label.appearence": "Erscheinung", + "label.advanced": "Erweiter", + "label.addVideo": "Video hinzufügen", + "label.promoteAllPeers": "Alle Teinehmer einlassen", + "label.moreActions": "Weitere Aktionen", "settings.settings": "Einstellungen", "settings.camera": "Kamera", @@ -131,14 +131,14 @@ "settings.advancedMode": "Erweiterter Modus", "settings.permanentTopBar": "Permanente obere Leiste", "settings.lastn": "Anzahl der sichtbaren Videos", - "settings.hiddenControls": null, - "settings.notificationSounds": null, - "settings.showNotifications": null, - "settings.buttonControlBar": null, - "settings.echoCancellation": null, - "settings.autoGainControl": null, - "settings.noiseSuppression": null, - "settings.drawerOverlayed": null, + "settings.hiddenControls": "Medienwerkzeugleiste automatisch ausblenden", + "settings.notificationSounds": "Audiosignal bei Benachrichtigungen", + "settings.showNotifications": "Zeige Benachrichtigungen", + "settings.buttonControlBar": "Seperate seitliche Medienwerkzeugleiste", + "settings.echoCancellation": "Echounterdrückung", + "settings.autoGainControl": "Automatische Pegelregelung (Audioeingang)", + "settings.noiseSuppression": "Rauschunterdrückung", + "settings.drawerOverlayed": "Seitenpanel verdeckt Hauptinhalt", "filesharing.saveFileError": "Fehler beim Speichern der Datei", "filesharing.startingFileShare": "Starte Teilen der Datei", @@ -180,8 +180,8 @@ "devices.cameraDisconnected": "Kamera getrennt", "devices.cameraError": "Fehler mit deiner Kamera", - "moderator.clearChat": null, - "moderator.clearFiles": null, - "moderator.muteAudio": null, - "moderator.muteVideo": null + "moderator.clearChat": "Moderator hat Chat gelöscht", + "moderator.clearFiles": "Moderator hat geteilte Dateiliste gelöscht", + "moderator.muteAudio": "Moderator hat dich stummgeschaltet", + "moderator.muteVideo": "Moderator hat dein Video gestoppt" } From 7ab388a7142066b808232b21a3a165b67d8d78b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Sun, 10 May 2020 20:43:51 +0200 Subject: [PATCH 78/88] Propagate producer score into state. --- app/src/reducers/producers.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/reducers/producers.js b/app/src/reducers/producers.js index 64aee90..a27c06e 100644 --- a/app/src/reducers/producers.js +++ b/app/src/reducers/producers.js @@ -60,6 +60,17 @@ const producers = (state = initialState, action) => return { ...state, [producerId]: newProducer }; } + case 'SET_PRODUCER_SCORE': + { + const { producerId, score } = action.payload; + + const producer = state[producerId]; + + const newProducer = { ...producer, score }; + + return { ...state, [producerId]: newProducer }; + } + default: return state; } From a7557e3e151adea6ddabfbca3afba7ab45991705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A5var=20Aamb=C3=B8=20Fosstveit?= Date: Sun, 10 May 2020 23:08:43 +0200 Subject: [PATCH 79/88] Show quality indicator on me view. Fix "Me" text on me view on hover on extra videos and screen sharing. --- app/src/components/Containers/Me.js | 86 ++++++++------- app/src/components/Containers/Peer.js | 3 + .../components/VideoContainers/VideoView.js | 103 ++++++++++-------- 3 files changed, 105 insertions(+), 87 deletions(-) diff --git a/app/src/components/Containers/Me.js b/app/src/components/Containers/Me.js index 743c022..ea6e9e0 100644 --- a/app/src/components/Containers/Me.js +++ b/app/src/components/Containers/Me.js @@ -290,6 +290,28 @@ const Me = (props) => 'margin' : spacing }; + let audioScore = null; + + if (micProducer && micProducer.score) + { + audioScore = + micProducer.score.reduce( + (prev, curr) => + (prev.score < curr.score ? prev : curr) + ); + } + + let videoScore = null; + + if (webcamProducer && webcamProducer.score) + { + videoScore = + webcamProducer.score.reduce( + (prev, curr) => + (prev.score < curr.score ? prev : curr) + ); + } + return (
    videoVisible={videoVisible} audioCodec={micProducer && micProducer.codec} videoCodec={webcamProducer && webcamProducer.codec} + audioScore={audioScore} + videoScore={videoScore} onChangeDisplayName={(displayName) => { roomClient.changeDisplayName(displayName); @@ -627,6 +652,18 @@ const Me = (props) => style={spacingStyle} >
    +

    + +

    }, 2000); }} > -

    - -

    - { smallContainer ? style={spacingStyle} >
    -
    setHover(true)} - onMouseOut={() => setHover(false)} - onTouchStart={() => - { - if (touchTimeout) - clearTimeout(touchTimeout); - - setHover(true); - }} - onTouchEnd={() => - { - - if (touchTimeout) - clearTimeout(touchTimeout); - - touchTimeout = setTimeout(() => - { - setHover(false); - }, 2000); - }} > -

    - -

    -
    + +

    ; + let quality = null; - if (videoScore || audioScore) + if (showQuality) { - const score = videoScore ? videoScore : audioScore; + quality = ; - switch (score.producerScore) + if (videoScore || audioScore) { - case 0: - case 1: + const score = videoScore ? videoScore : audioScore; + + switch (isMe ? score.score : score.producerScore) { - quality = ; - - break; - } - - case 2: - case 3: - { - quality = ; - - break; - } - - case 4: - case 5: - case 6: - { - quality = ; - - break; - } - - case 7: - case 8: - case 9: - { - quality = ; - - break; - } - - case 10: - { - quality = null; - - break; - } - - default: - { - break; + case 0: + case 1: + { + quality = ; + + break; + } + + case 2: + case 3: + { + quality = ; + + break; + } + + case 4: + case 5: + case 6: + { + quality = ; + + break; + } + + case 7: + case 8: + case 9: + { + quality = ; + + break; + } + + case 10: + { + quality = null; + + break; + } + + default: + { + break; + } } } } @@ -258,7 +264,7 @@ class VideoView extends React.PureComponent

    {videoWidth}x{videoHeight}

    }
    - { !isMe && + { showQuality &&
    { quality @@ -438,6 +444,7 @@ class VideoView extends React.PureComponent VideoView.propTypes = { isMe : PropTypes.bool, + showQuality : PropTypes.bool, isScreen : PropTypes.bool, displayName : PropTypes.string, showPeerInfo : PropTypes.bool, From 67ea56b02ae048317872dcefaf1482766aa146d6 Mon Sep 17 00:00:00 2001 From: Luca Date: Mon, 11 May 2020 08:50:34 +0200 Subject: [PATCH 80/88] Italian translation update --- app/src/translations/it.json | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/app/src/translations/it.json b/app/src/translations/it.json index 5ce26b6..312a2ff 100644 --- a/app/src/translations/it.json +++ b/app/src/translations/it.json @@ -59,10 +59,10 @@ "room.raisedHand": "{displayName} ha alzato la mano", "room.loweredHand": "{displayName} ha abbassato la mano", "room.extraVideo": "Video extra", - "room.overRoomLimit": null, - "room.help": null, - "room.about": null, - "room.shortcutKeys": null, + "room.overRoomLimit": "La stanza è piena, riprova più tardi.", + "room.help": "Aiuto", + "room.about": "Informazioni su", + "room.shortcutKeys": "Scorciatoie da tastiera", "me.mutedPTT": "Sei mutato, tieni premuto SPAZIO per parlare", @@ -71,7 +71,7 @@ "tooltip.login": "Log in", "tooltip.logout": "Log out", - "tooltip.admitFromLobby": "Ammetti dalla lobby", + "tooltip.admitFromLobby": "Accetta partecipante dalla lobby", "tooltip.lockRoom": "Blocca stanza", "tooltip.unLockRoom": "Sblocca stanza", "tooltip.enterFullscreen": "Modalità schermo intero", @@ -79,10 +79,11 @@ "tooltip.lobby": "Mostra lobby", "tooltip.settings": "Mostra impostazioni", "tooltip.participants": "Mostra partecipanti", + "tooltip.kickParticipant": "Espelli partecipante", "tooltip.muteParticipant": "Muta partecipante", "tooltip.muteParticipantVideo": "Ferma video partecipante", "tooltip.raisedHand": "Mano alzata", - "tooltip.muteScreenSharing": null, + "tooltip.muteScreenSharing": "Ferma condivisione schermo partecipante", "label.roomName": "Nome della stanza", "label.chooseRoomButton": "Continua", @@ -96,7 +97,7 @@ "label.filesharing": "Condivisione file", "label.participants": "Partecipanti", "label.shareFile": "Condividi file", - "label.shareGalleryFile": null, + "label.shareGalleryFile": "Condividi immagine", "label.fileSharingUnsupported": "Condivisione file non supportata", "label.unknown": "Sconosciuto", "label.democratic": "Vista Democratica", @@ -112,7 +113,7 @@ "label.advanced": "Avanzate", "label.addVideo": "Aggiungi video", "label.promoteAllPeers": "Promuovi tutti", - "label.moreActions": null, + "label.moreActions": "Altre azioni", "settings.settings": "Impostazioni", "settings.camera": "Videocamera", @@ -132,12 +133,12 @@ "settings.lastn": "Numero di video visibili", "settings.hiddenControls": "Controlli media nascosti", "settings.notificationSounds": "Suoni di notifica", - "settings.showNotifications": null, - "settings.buttonControlBar": null, - "settings.echoCancellation": null, - "settings.autoGainControl": null, - "settings.noiseSuppression": null, - "settings.drawerOverlayed": null, + "settings.showNotifications": "Mostra notifiche", + "settings.buttonControlBar": "Controlli media separati", + "settings.echoCancellation": "Cancellazione echo", + "settings.autoGainControl": "Controllo guadagno automatico", + "settings.noiseSuppression": "Riduzione del rumore", + "settings.drawerOverlayed": "Barra laterale sovrapposta", "filesharing.saveFileError": "Impossibile salvare file", "filesharing.startingFileShare": "Tentativo di condivisione file", @@ -153,7 +154,7 @@ "devices.devicesChanged": "Il tuo dispositivo è cambiato, configura i dispositivi nel menù di impostazioni", "device.audioUnsupported": "Dispositivo audio non supportato", - "device.activateAudio": "Attiva audio", + "device.activateAudio": "Attiva audio", "device.muteAudio": "Silenzia audio", "device.unMuteAudio": "Riattiva audio", @@ -183,4 +184,4 @@ "moderator.clearFiles": "Il moderatore ha pulito i file", "moderator.muteAudio": "Il moderatore ha mutato il tuo audio", "moderator.muteVideo": "Il moderatore ha fermato il tuo video" -} +} \ No newline at end of file From 2623ef2eaa3164c2af79ad36063b7ca3de54dddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9sz=C3=A1ros=20Mih=C3=A1ly?= Date: Tue, 12 May 2020 11:09:32 +0200 Subject: [PATCH 81/88] Fix lint --- .../MeetingDrawer/ParticipantList/ListPeer.js | 92 +++++++++---------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js index 76f9b10..7aa5181 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js @@ -254,57 +254,57 @@ const ListPeer = (props) => } { isModerator && micConsumer && - - { - e.stopPropagation(); - - roomClient.mutePeer(peer.id); - }} + title={intl.formatMessage({ + id : 'tooltip.muteParticipant', + defaultMessage : 'Mute globally participant mic' + })} + placement='bottom' > - { !micConsumer.remotelyPaused ? - - : - - } - - + + { + e.stopPropagation(); + + roomClient.mutePeer(peer.id); + }} + > + { !micConsumer.remotelyPaused ? + + : + + } + + } { isModerator && webcamConsumer && - - { - e.stopPropagation(); - - roomClient.stopPeerVideo(peer.id); - }} + title={intl.formatMessage({ + id : 'tooltip.muteParticipantVideo', + defaultMessage : 'Mute globally participant video' + })} + placement='bottom' > - { !webcamConsumer.remotelyPaused ? - - : - - } - - + + { + e.stopPropagation(); + + roomClient.stopPeerVideo(peer.id); + }} + > + { !webcamConsumer.remotelyPaused ? + + : + + } + + } {children}
    From 1e5b6680c4f66695506b4cf3db0afc5e93a054c3 Mon Sep 17 00:00:00 2001 From: Luca Date: Tue, 12 May 2020 18:09:08 +0200 Subject: [PATCH 82/88] Translate moderator mute actions on peer --- .../components/MeetingDrawer/ParticipantList/ListPeer.js | 8 ++++---- app/src/translations/cn.json | 3 +++ app/src/translations/cs.json | 3 +++ app/src/translations/de.json | 3 +++ app/src/translations/dk.json | 3 +++ app/src/translations/el.json | 3 +++ app/src/translations/en.json | 3 +++ app/src/translations/es.json | 3 +++ app/src/translations/fr.json | 3 +++ app/src/translations/hr.json | 3 +++ app/src/translations/hu.json | 3 +++ app/src/translations/it.json | 3 +++ app/src/translations/lv.json | 3 +++ app/src/translations/nb.json | 3 +++ app/src/translations/pl.json | 3 +++ app/src/translations/pt.json | 3 +++ app/src/translations/ro.json | 3 +++ app/src/translations/tr.json | 3 +++ app/src/translations/uk.json | 3 +++ 19 files changed, 58 insertions(+), 4 deletions(-) diff --git a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js index 7aa5181..0ca5d89 100644 --- a/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js +++ b/app/src/components/MeetingDrawer/ParticipantList/ListPeer.js @@ -255,8 +255,8 @@ const ListPeer = (props) => { isModerator && micConsumer && @@ -282,8 +282,8 @@ const ListPeer = (props) => { isModerator && webcamConsumer && diff --git a/app/src/translations/cn.json b/app/src/translations/cn.json index 0ce5f5b..cd58a88 100644 --- a/app/src/translations/cn.json +++ b/app/src/translations/cn.json @@ -84,6 +84,9 @@ "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, "tooltip.muteScreenSharing": null, + "tooltip.muteParticipantAudioModerator": null, + "tooltip.muteParticipantVideoModerator": null, + "tooltip.muteScreenSharingModerator": null, "label.roomName": "房间名称", "label.chooseRoomButton": "继续", diff --git a/app/src/translations/cs.json b/app/src/translations/cs.json index 3eac16c..826dcef 100644 --- a/app/src/translations/cs.json +++ b/app/src/translations/cs.json @@ -83,6 +83,9 @@ "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, "tooltip.muteScreenSharing": null, + "tooltip.muteParticipantAudioModerator": null, + "tooltip.muteParticipantVideoModerator": null, + "tooltip.muteScreenSharingModerator": null, "label.roomName": "Jméno místnosti", "label.chooseRoomButton": "Pokračovat", diff --git a/app/src/translations/de.json b/app/src/translations/de.json index c4b67fe..fc1de49 100644 --- a/app/src/translations/de.json +++ b/app/src/translations/de.json @@ -84,6 +84,9 @@ "tooltip.muteParticipantVideo": "Video stoppen", "tooltip.raisedHand": "Hand heben", "tooltip.muteScreenSharing": "Stoppe Bildschirmfreigabe", + "tooltip.muteParticipantAudioModerator": null, + "tooltip.muteParticipantVideoModerator": null, + "tooltip.muteScreenSharingModerator": null, "label.roomName": "Name des Raums", "label.chooseRoomButton": "Weiter", diff --git a/app/src/translations/dk.json b/app/src/translations/dk.json index c5c3a2d..778e10d 100644 --- a/app/src/translations/dk.json +++ b/app/src/translations/dk.json @@ -84,6 +84,9 @@ "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, "tooltip.muteScreenSharing": null, + "tooltip.muteParticipantAudioModerator": null, + "tooltip.muteParticipantVideoModerator": null, + "tooltip.muteScreenSharingModerator": null, "label.roomName": "Værelsesnavn", "label.chooseRoomButton": "Fortsæt", diff --git a/app/src/translations/el.json b/app/src/translations/el.json index d71764f..22e991e 100644 --- a/app/src/translations/el.json +++ b/app/src/translations/el.json @@ -84,6 +84,9 @@ "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, "tooltip.muteScreenSharing": null, + "tooltip.muteParticipantAudioModerator": null, + "tooltip.muteParticipantVideoModerator": null, + "tooltip.muteScreenSharingModerator": null, "label.roomName": "Όνομα δωματίου", "label.chooseRoomButton": "Συνέχεια", diff --git a/app/src/translations/en.json b/app/src/translations/en.json index 344d660..776ae29 100644 --- a/app/src/translations/en.json +++ b/app/src/translations/en.json @@ -84,6 +84,9 @@ "tooltip.muteParticipantVideo": "Mute participant video", "tooltip.raisedHand": "Raise hand", "tooltip.muteScreenSharing": "Mute participant share", + "tooltip.muteParticipantAudioModerator": "Mute participant audio globally", + "tooltip.muteParticipantVideoModerator": "Mute participant video globally", + "tooltip.muteScreenSharingModerator": "Mute participant screen share globally", "label.roomName": "Room name", "label.chooseRoomButton": "Continue", diff --git a/app/src/translations/es.json b/app/src/translations/es.json index 758b0c8..abb06f4 100644 --- a/app/src/translations/es.json +++ b/app/src/translations/es.json @@ -84,6 +84,9 @@ "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, "tooltip.muteScreenSharing": null, + "tooltip.muteParticipantAudioModerator": null, + "tooltip.muteParticipantVideoModerator": null, + "tooltip.muteScreenSharingModerator": null, "label.roomName": "Nombre de la sala", "label.chooseRoomButton": "Continuar", diff --git a/app/src/translations/fr.json b/app/src/translations/fr.json index 2eb7edf..7d17970 100644 --- a/app/src/translations/fr.json +++ b/app/src/translations/fr.json @@ -84,6 +84,9 @@ "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, "tooltip.muteScreenSharing": null, + "tooltip.muteParticipantAudioModerator": null, + "tooltip.muteParticipantVideoModerator": null, + "tooltip.muteScreenSharingModerator": null, "label.roomName": "Nom de la salle", "label.chooseRoomButton": "Continuer", diff --git a/app/src/translations/hr.json b/app/src/translations/hr.json index c0f8879..61d6d69 100644 --- a/app/src/translations/hr.json +++ b/app/src/translations/hr.json @@ -84,6 +84,9 @@ "tooltip.muteParticipantVideo": "Ne primaj video sudionika", "tooltip.raisedHand": "Podigni ruku", "tooltip.muteScreenSharing": null, + "tooltip.muteParticipantAudioModerator": null, + "tooltip.muteParticipantVideoModerator": null, + "tooltip.muteScreenSharingModerator": null, "label.roomName": "Naziv sobe", "label.chooseRoomButton": "Nastavi", diff --git a/app/src/translations/hu.json b/app/src/translations/hu.json index cf65f1d..9e748d6 100644 --- a/app/src/translations/hu.json +++ b/app/src/translations/hu.json @@ -84,6 +84,9 @@ "tooltip.muteParticipantVideo": "Résztvevő videóstreamének némítása", "tooltip.raisedHand": "Jelentkezés", "tooltip.muteScreenSharing": "Képernyőmegosztás szüneteltetése", + "tooltip.muteParticipantAudioModerator": null, + "tooltip.muteParticipantVideoModerator": null, + "tooltip.muteScreenSharingModerator": null, "label.roomName": "Konferencia", "label.chooseRoomButton": "Tovább", diff --git a/app/src/translations/it.json b/app/src/translations/it.json index 312a2ff..a4345cc 100644 --- a/app/src/translations/it.json +++ b/app/src/translations/it.json @@ -84,6 +84,9 @@ "tooltip.muteParticipantVideo": "Ferma video partecipante", "tooltip.raisedHand": "Mano alzata", "tooltip.muteScreenSharing": "Ferma condivisione schermo partecipante", + "tooltip.muteParticipantAudioModerator": "Muta partecipante", + "tooltip.muteParticipantVideoModerator": "Ferma video partecipante", + "tooltip.muteScreenSharingModerator": "Ferma condivisione schermo partecipante", "label.roomName": "Nome della stanza", "label.chooseRoomButton": "Continua", diff --git a/app/src/translations/lv.json b/app/src/translations/lv.json index 7bf0b24..20eb8f8 100644 --- a/app/src/translations/lv.json +++ b/app/src/translations/lv.json @@ -83,6 +83,9 @@ "tooltip.muteParticipantVideo": "Atslēgt dalībnieka video", "tooltip.raisedHand": "Pacelt roku", "tooltip.muteScreenSharing": null, + "tooltip.muteParticipantAudioModerator": null, + "tooltip.muteParticipantVideoModerator": null, + "tooltip.muteScreenSharingModerator": null, "label.roomName": "Sapulces telpas nosaukums (ID)", "label.chooseRoomButton": "Turpināt", diff --git a/app/src/translations/nb.json b/app/src/translations/nb.json index 251858b..3714f76 100644 --- a/app/src/translations/nb.json +++ b/app/src/translations/nb.json @@ -84,6 +84,9 @@ "tooltip.muteParticipantVideo": "Demp deltakervideo", "tooltip.raisedHand": "Rekk opp hånden", "tooltip.muteScreenSharing": "Demp deltaker skjermdeling", + "tooltip.muteParticipantAudioModerator": null, + "tooltip.muteParticipantVideoModerator": null, + "tooltip.muteScreenSharingModerator": null, "label.roomName": "Møtenavn", "label.chooseRoomButton": "Fortsett", diff --git a/app/src/translations/pl.json b/app/src/translations/pl.json index 174684c..147a87e 100644 --- a/app/src/translations/pl.json +++ b/app/src/translations/pl.json @@ -84,6 +84,9 @@ "tooltip.muteParticipantVideo": "Wyłącz wideo użytkownika", "tooltip.raisedHand": "Podnieś rękę", "tooltip.muteScreenSharing": "Anuluj udostępniania pulpitu przez użytkownika", + "tooltip.muteParticipantAudioModerator": null, + "tooltip.muteParticipantVideoModerator": null, + "tooltip.muteScreenSharingModerator": null, "label.roomName": "Nazwa konferencji", "label.chooseRoomButton": "Kontynuuj", diff --git a/app/src/translations/pt.json b/app/src/translations/pt.json index 8250231..c386bcf 100644 --- a/app/src/translations/pt.json +++ b/app/src/translations/pt.json @@ -84,6 +84,9 @@ "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, "tooltip.muteScreenSharing": null, + "tooltip.muteParticipantAudioModerator": null, + "tooltip.muteParticipantVideoModerator": null, + "tooltip.muteScreenSharingModerator": null, "label.roomName": "Nome da sala", "label.chooseRoomButton": "Continuar", diff --git a/app/src/translations/ro.json b/app/src/translations/ro.json index 1ba455c..81b778b 100644 --- a/app/src/translations/ro.json +++ b/app/src/translations/ro.json @@ -84,6 +84,9 @@ "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, "tooltip.muteScreenSharing": null, + "tooltip.muteParticipantAudioModerator": null, + "tooltip.muteParticipantVideoModerator": null, + "tooltip.muteScreenSharingModerator": null, "label.roomName": "Numele camerei", "label.chooseRoomButton": "Continuare", diff --git a/app/src/translations/tr.json b/app/src/translations/tr.json index 524c557..41d8322 100644 --- a/app/src/translations/tr.json +++ b/app/src/translations/tr.json @@ -84,6 +84,9 @@ "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, "tooltip.muteScreenSharing": null, + "tooltip.muteParticipantAudioModerator": null, + "tooltip.muteParticipantVideoModerator": null, + "tooltip.muteScreenSharingModerator": null, "label.roomName": "Oda adı", "label.chooseRoomButton": "Devam", diff --git a/app/src/translations/uk.json b/app/src/translations/uk.json index a8cc077..81c5c81 100644 --- a/app/src/translations/uk.json +++ b/app/src/translations/uk.json @@ -84,6 +84,9 @@ "tooltip.muteParticipantVideo": null, "tooltip.raisedHand": null, "tooltip.muteScreenSharing": null, + "tooltip.muteParticipantAudioModerator": null, + "tooltip.muteParticipantVideoModerator": null, + "tooltip.muteScreenSharingModerator": null, "label.roomName": "Назва кімнати", "label.chooseRoomButton": "Продовжити", From 0e40ed76961d17303097fc44f548c20267ac87bf Mon Sep 17 00:00:00 2001 From: Luca Date: Tue, 12 May 2020 18:17:51 +0200 Subject: [PATCH 83/88] Update it translation --- app/src/translations/it.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/translations/it.json b/app/src/translations/it.json index a4345cc..69add90 100644 --- a/app/src/translations/it.json +++ b/app/src/translations/it.json @@ -84,9 +84,9 @@ "tooltip.muteParticipantVideo": "Ferma video partecipante", "tooltip.raisedHand": "Mano alzata", "tooltip.muteScreenSharing": "Ferma condivisione schermo partecipante", - "tooltip.muteParticipantAudioModerator": "Muta partecipante", - "tooltip.muteParticipantVideoModerator": "Ferma video partecipante", - "tooltip.muteScreenSharingModerator": "Ferma condivisione schermo partecipante", + "tooltip.muteParticipantAudioModerator": "Sospendi audio globale", + "tooltip.muteParticipantVideoModerator": "Sospendi video globale", + "tooltip.muteScreenSharingModerator": "Sospendi condivisione schermo globale", "label.roomName": "Nome della stanza", "label.chooseRoomButton": "Continua", From b3764a1d64be877655a3296b358f875966253913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9sz=C3=A1ros=20Mih=C3=A1ly?= Date: Tue, 12 May 2020 20:29:50 +0200 Subject: [PATCH 84/88] fixes #343 --- app/src/components/Containers/Me.js | 410 +++++++++++++++------------- 1 file changed, 213 insertions(+), 197 deletions(-) diff --git a/app/src/components/Containers/Me.js b/app/src/components/Containers/Me.js index ea6e9e0..8d45473 100644 --- a/app/src/components/Containers/Me.js +++ b/app/src/components/Containers/Me.js @@ -400,195 +400,207 @@ const Me = (props) => { smallContainer ? - - { - if (micState === 'off') - roomClient.enableMic(); - else if (micState === 'on') - roomClient.muteMic(); - else - roomClient.unmuteMic(); - }} - > - { micState === 'on' ? - - : - - } - +
    + + { + if (micState === 'off') + roomClient.enableMic(); + else if (micState === 'on') + roomClient.muteMic(); + else + roomClient.unmuteMic(); + }} + > + { micState === 'on' ? + + : + + } + +
    : - - { - if (micState === 'off') - roomClient.enableMic(); - else if (micState === 'on') - roomClient.muteMic(); - else - roomClient.unmuteMic(); - }} - > - { micState === 'on' ? - - : - - } - +
    + + { + if (micState === 'off') + roomClient.enableMic(); + else if (micState === 'on') + roomClient.muteMic(); + else + roomClient.unmuteMic(); + }} + > + { micState === 'on' ? + + : + + } + +
    }
    { smallContainer ? - - { - webcamState === 'on' ? - roomClient.disableWebcam() : - roomClient.enableWebcam(); - }} - > - { webcamState === 'on' ? - - : - - } - +
    + + { + webcamState === 'on' ? + roomClient.disableWebcam() : + roomClient.enableWebcam(); + }} + > + { webcamState === 'on' ? + + : + + } + +
    : - - { - webcamState === 'on' ? - roomClient.disableWebcam() : - roomClient.enableWebcam(); - }} - > - { webcamState === 'on' ? - - : - - } - +
    + + { + webcamState === 'on' ? + roomClient.disableWebcam() : + roomClient.enableWebcam(); + }} + > + { webcamState === 'on' ? + + : + + } + +
    }
    { me.browser.platform !== 'mobile' && { smallContainer ? - + - { - switch (screenState) - { - case 'on': - { - roomClient.disableScreenSharing(); - break; - } - case 'off': - { - roomClient.enableScreenSharing(); - break; - } - default: - { - break; - } } - }} - > - { (screenState === 'on' || screenState === 'unsupported') && + color='primary' + size='small' + onClick={() => + { + switch (screenState) + { + case 'on': + { + roomClient.disableScreenSharing(); + break; + } + case 'off': + { + roomClient.enableScreenSharing(); + break; + } + default: + { + break; + } + } + }} + > + { (screenState === 'on' || screenState === 'unsupported') && - } - { screenState === 'off' && + } + { screenState === 'off' && - } + } - + +
    : - + - { - switch (screenState) - { - case 'on': - { - roomClient.disableScreenSharing(); - break; - } - case 'off': - { - roomClient.enableScreenSharing(); - break; - } - default: - { - break; - } } - }} - > - { (screenState === 'on' || screenState === 'unsupported') && + color={screenState === 'on' ? 'primary' : 'default'} + size='large' + onClick={() => + { + switch (screenState) + { + case 'on': + { + roomClient.disableScreenSharing(); + break; + } + case 'off': + { + roomClient.enableScreenSharing(); + break; + } + default: + { + break; + } + } + }} + > + { (screenState === 'on' || screenState === 'unsupported') && - } - { screenState === 'off' && + } + { screenState === 'off' && - } - + } + +
    } } @@ -692,39 +704,43 @@ const Me = (props) => > { smallContainer ? - - { - roomClient.disableExtraVideo(producer.id); - }} - > - +
    + + { + roomClient.disableExtraVideo(producer.id); + }} + > + - + +
    : - - { - roomClient.disableExtraVideo(producer.id); - }} - > - - +
    + + { + roomClient.disableExtraVideo(producer.id); + }} + > + + +
    }
    From d84d30e40215c4ffb11a96fa70b92235914b1e24 Mon Sep 17 00:00:00 2001 From: mi4aux <63875263+mi4aux@users.noreply.github.com> Date: Tue, 12 May 2020 22:52:30 +0200 Subject: [PATCH 85/88] Typofix german translation --- app/src/translations/de.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/translations/de.json b/app/src/translations/de.json index c4b67fe..b5e5f5d 100644 --- a/app/src/translations/de.json +++ b/app/src/translations/de.json @@ -64,7 +64,7 @@ "room.about": "Impressum", "room.shortcutKeys": "Tastaturkürzel", - "me.mutedPTT": "Du bist stummgeschalted, Halte die SPACE-Taste um zu sprechen", + "me.mutedPTT": "Du bist stummgeschaltet. Halte die SPACE-Taste um zu sprechen", "roles.gotRole": "Rolle erhalten: {role}", "roles.lostRole": "Rolle entzogen: {role}", @@ -110,9 +110,9 @@ "label.close": "Schließen", "label.media": "Audio / Video", "label.appearence": "Erscheinung", - "label.advanced": "Erweiter", + "label.advanced": "Erweitert", "label.addVideo": "Video hinzufügen", - "label.promoteAllPeers": "Alle Teinehmer einlassen", + "label.promoteAllPeers": "Alle Teilnehmer reinlassen", "label.moreActions": "Weitere Aktionen", "settings.settings": "Einstellungen", @@ -134,7 +134,7 @@ "settings.hiddenControls": "Medienwerkzeugleiste automatisch ausblenden", "settings.notificationSounds": "Audiosignal bei Benachrichtigungen", "settings.showNotifications": "Zeige Benachrichtigungen", - "settings.buttonControlBar": "Seperate seitliche Medienwerkzeugleiste", + "settings.buttonControlBar": "Separate seitliche Medienwerkzeugleiste", "settings.echoCancellation": "Echounterdrückung", "settings.autoGainControl": "Automatische Pegelregelung (Audioeingang)", "settings.noiseSuppression": "Rauschunterdrückung", From af1dcd6bab9cf06a3c3aaf075bd09f4fa84647e4 Mon Sep 17 00:00:00 2001 From: mi4aux <63875263+mi4aux@users.noreply.github.com> Date: Tue, 12 May 2020 22:55:42 +0200 Subject: [PATCH 86/88] Suggestions for better german translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit room.closeMeeting: "Meeting beenden" or alternatively "Meeting abschließen" room.about: "Über" on software often also like "Über multiparty-meeting" or "Info" / "Informationen" in general "Impressum" is more like "imprint" / "legal info" on a website, .. but depends on what it's ment to be label.medium: "Mittel" better (true) german, "Medium" obtained on quality is german-english mix (or has other meaning) label.appearence: "Ansicht" shorter, more often used on software .. alternatively "Aussehen" or "Anzeige" --- app/src/translations/de.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/translations/de.json b/app/src/translations/de.json index b5e5f5d..e2ecc02 100644 --- a/app/src/translations/de.json +++ b/app/src/translations/de.json @@ -51,7 +51,7 @@ "room.videoPaused": "Video gestoppt", "room.muteAll": "Alle stummschalten", "room.stopAllVideo": "Alle Videos stoppen", - "room.closeMeeting": "Meeting schließen", + "room.closeMeeting": "Meeting beenden", "room.clearChat": "Liste löschen", "room.clearFileSharing": "Liste löschen", "room.speechUnsupported": "Dein Browser unterstützt keine Spracherkennung", @@ -61,7 +61,7 @@ "room.extraVideo": "Video hinzufügen", "room.overRoomLimit": "Der Raum ist voll, probiere es später nochmal", "room.help": "Hilfe", - "room.about": "Impressum", + "room.about": "Über", "room.shortcutKeys": "Tastaturkürzel", "me.mutedPTT": "Du bist stummgeschaltet. Halte die SPACE-Taste um zu sprechen", @@ -103,13 +103,13 @@ "label.democratic": "Demokratisch", "label.filmstrip": "Filmstreifen", "label.low": "Niedrig", - "label.medium": "Medium", + "label.medium": "Mittel", "label.high": "Hoch (HD)", "label.veryHigh": "Sehr hoch (FHD)", "label.ultra": "Ultra (UHD)", "label.close": "Schließen", "label.media": "Audio / Video", - "label.appearence": "Erscheinung", + "label.appearence": "Ansicht", "label.advanced": "Erweitert", "label.addVideo": "Video hinzufügen", "label.promoteAllPeers": "Alle Teilnehmer reinlassen", From bd06d68742bf6720b942724aa7a7915572994b94 Mon Sep 17 00:00:00 2001 From: mi4aux <63875263+mi4aux@users.noreply.github.com> Date: Tue, 12 May 2020 22:56:46 +0200 Subject: [PATCH 87/88] Typofix variablename --- app/src/components/Settings/Settings.js | 2 +- app/src/translations/cn.json | 2 +- app/src/translations/cs.json | 2 +- app/src/translations/de.json | 2 +- app/src/translations/dk.json | 2 +- app/src/translations/el.json | 2 +- app/src/translations/en.json | 2 +- app/src/translations/es.json | 2 +- app/src/translations/fr.json | 2 +- app/src/translations/hr.json | 2 +- app/src/translations/hu.json | 2 +- app/src/translations/it.json | 2 +- app/src/translations/lv.json | 2 +- app/src/translations/nb.json | 2 +- app/src/translations/pl.json | 2 +- app/src/translations/pt.json | 2 +- app/src/translations/ro.json | 2 +- app/src/translations/tr.json | 2 +- app/src/translations/uk.json | 2 +- 19 files changed, 19 insertions(+), 19 deletions(-) diff --git a/app/src/components/Settings/Settings.js b/app/src/components/Settings/Settings.js index 6633829..cbfb8b1 100644 --- a/app/src/components/Settings/Settings.js +++ b/app/src/components/Settings/Settings.js @@ -95,7 +95,7 @@ const Settings = ({ /> diff --git a/app/src/translations/cn.json b/app/src/translations/cn.json index 0ce5f5b..bf709ef 100644 --- a/app/src/translations/cn.json +++ b/app/src/translations/cn.json @@ -109,7 +109,7 @@ "label.ultra": "超高 (UHD)", "label.close": "关闭", "label.media": null, - "label.appearence": null, + "label.appearance": null, "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, diff --git a/app/src/translations/cs.json b/app/src/translations/cs.json index 3eac16c..b5c48f0 100644 --- a/app/src/translations/cs.json +++ b/app/src/translations/cs.json @@ -108,7 +108,7 @@ "label.ultra": "Ultra (UHD)", "label.close": "Zavřít", "label.media": null, - "label.appearence": null, + "label.appearance": null, "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, diff --git a/app/src/translations/de.json b/app/src/translations/de.json index e2ecc02..6acb321 100644 --- a/app/src/translations/de.json +++ b/app/src/translations/de.json @@ -109,7 +109,7 @@ "label.ultra": "Ultra (UHD)", "label.close": "Schließen", "label.media": "Audio / Video", - "label.appearence": "Ansicht", + "label.appearance": "Ansicht", "label.advanced": "Erweitert", "label.addVideo": "Video hinzufügen", "label.promoteAllPeers": "Alle Teilnehmer reinlassen", diff --git a/app/src/translations/dk.json b/app/src/translations/dk.json index c5c3a2d..ada9f33 100644 --- a/app/src/translations/dk.json +++ b/app/src/translations/dk.json @@ -109,7 +109,7 @@ "label.ultra": "Ultra (UHD)", "label.close": "Luk", "label.media": null, - "label.appearence": null, + "label.appearance": null, "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, diff --git a/app/src/translations/el.json b/app/src/translations/el.json index d71764f..4384c0a 100644 --- a/app/src/translations/el.json +++ b/app/src/translations/el.json @@ -109,7 +109,7 @@ "label.ultra": "Ultra (UHD)", "label.close": "Κλείσιμο", "label.media": null, - "label.appearence": null, + "label.appearance": null, "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, diff --git a/app/src/translations/en.json b/app/src/translations/en.json index 344d660..a8ec093 100644 --- a/app/src/translations/en.json +++ b/app/src/translations/en.json @@ -109,7 +109,7 @@ "label.ultra": "Ultra (UHD)", "label.close": "Close", "label.media": "Media", - "label.appearence": "Appearence", + "label.appearance": "Appearence", "label.advanced": "Advanced", "label.addVideo": "Add video", "label.promoteAllPeers": "Promote all", diff --git a/app/src/translations/es.json b/app/src/translations/es.json index 758b0c8..21e711d 100644 --- a/app/src/translations/es.json +++ b/app/src/translations/es.json @@ -109,7 +109,7 @@ "label.ultra": "Ultra (UHD)", "label.close": "Cerrar", "label.media": null, - "label.appearence": null, + "label.appearance": null, "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, diff --git a/app/src/translations/fr.json b/app/src/translations/fr.json index 2eb7edf..aad845d 100644 --- a/app/src/translations/fr.json +++ b/app/src/translations/fr.json @@ -109,7 +109,7 @@ "label.ultra": "Ultra Haute Définition", "label.close": "Fermer", "label.media": null, - "label.appearence": null, + "label.appearance": null, "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, diff --git a/app/src/translations/hr.json b/app/src/translations/hr.json index c0f8879..ac554d8 100644 --- a/app/src/translations/hr.json +++ b/app/src/translations/hr.json @@ -109,7 +109,7 @@ "label.ultra": "Ultra visoka (UHD)", "label.close": "Zatvori", "label.media": "Medij", - "label.appearence": "Prikaz", + "label.appearance": "Prikaz", "label.advanced": "Napredno", "label.addVideo": "Dodaj video", "label.promoteAllPeers": "Promoviraj sve", diff --git a/app/src/translations/hu.json b/app/src/translations/hu.json index cf65f1d..88fccce 100644 --- a/app/src/translations/hu.json +++ b/app/src/translations/hu.json @@ -109,7 +109,7 @@ "label.ultra": "Ultra magas (UHD)", "label.close": "Bezár", "label.media": "Média", - "label.appearence": "Megjelenés", + "label.appearance": "Megjelenés", "label.advanced": "Részletek", "label.addVideo": "Videó hozzáadása", "label.promoteAllPeers": "Mindenkit beengedek", diff --git a/app/src/translations/it.json b/app/src/translations/it.json index 312a2ff..ae210c5 100644 --- a/app/src/translations/it.json +++ b/app/src/translations/it.json @@ -109,7 +109,7 @@ "label.ultra": "Ultra (UHD)", "label.close": "Chiudi", "label.media": "Media", - "label.appearence": "Aspetto", + "label.appearance": "Aspetto", "label.advanced": "Avanzate", "label.addVideo": "Aggiungi video", "label.promoteAllPeers": "Promuovi tutti", diff --git a/app/src/translations/lv.json b/app/src/translations/lv.json index 7bf0b24..7a38196 100644 --- a/app/src/translations/lv.json +++ b/app/src/translations/lv.json @@ -107,7 +107,7 @@ "label.ultra": "Ultra (UHD)", "label.close": "Aizvērt", "label.media": "Mediji", - "label.appearence": "Izskats", + "label.appearance": "Izskats", "label.advanced": "Advancēts", "label.addVideo": "Pievienot video", "label.moreActions": null, diff --git a/app/src/translations/nb.json b/app/src/translations/nb.json index 251858b..b755d6e 100644 --- a/app/src/translations/nb.json +++ b/app/src/translations/nb.json @@ -109,7 +109,7 @@ "label.ultra": "Ultra (UHD)", "label.close": "Lukk", "label.media": "Media", - "label.appearence": "Utseende", + "label.appearance": "Utseende", "label.advanced": "Avansert", "label.addVideo": "Legg til video", "label.promoteAllPeers": "Slipp inn alle", diff --git a/app/src/translations/pl.json b/app/src/translations/pl.json index 174684c..142bf34 100644 --- a/app/src/translations/pl.json +++ b/app/src/translations/pl.json @@ -109,7 +109,7 @@ "label.ultra": "Ultra (UHD)", "label.close": "Zamknij", "label.media": "Media", - "label.appearence": "Wygląd", + "label.appearance": "Wygląd", "label.advanced": "Zaawansowane", "label.addVideo": "Dodaj wideo", "label.promoteAllPeers": "Wpuść wszystkich", diff --git a/app/src/translations/pt.json b/app/src/translations/pt.json index 8250231..3b79148 100644 --- a/app/src/translations/pt.json +++ b/app/src/translations/pt.json @@ -109,7 +109,7 @@ "label.ultra": "Ultra (UHD)", "label.close": "Fechar", "label.media": null, - "label.appearence": null, + "label.appearance": null, "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, diff --git a/app/src/translations/ro.json b/app/src/translations/ro.json index 1ba455c..ab70c94 100644 --- a/app/src/translations/ro.json +++ b/app/src/translations/ro.json @@ -109,7 +109,7 @@ "label.ultra": "Rezoluție ultra înaltă (UHD)", "label.close": "Închide", "label.media": null, - "label.appearence": null, + "label.appearance": null, "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, diff --git a/app/src/translations/tr.json b/app/src/translations/tr.json index 524c557..2716d13 100644 --- a/app/src/translations/tr.json +++ b/app/src/translations/tr.json @@ -109,7 +109,7 @@ "label.ultra": "Ultra (UHD)", "label.close": "Kapat", "label.media": null, - "label.appearence": null, + "label.appearance": null, "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, diff --git a/app/src/translations/uk.json b/app/src/translations/uk.json index a8cc077..b6b6526 100644 --- a/app/src/translations/uk.json +++ b/app/src/translations/uk.json @@ -109,7 +109,7 @@ "label.ultra": "Ультра (UHD)", "label.close": "Закрити", "label.media": null, - "label.appearence": null, + "label.appearance": null, "label.advanced": null, "label.addVideo": null, "label.promoteAllPeers": null, From 41d62cf9b808d71425858ffea9be11aafcdafc27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9sz=C3=A1ros=20Mih=C3=A1ly?= Date: Wed, 13 May 2020 07:57:38 +0200 Subject: [PATCH 88/88] Update hungarian translation --- app/src/translations/hu.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/translations/hu.json b/app/src/translations/hu.json index 4a2cc50..dee4034 100644 --- a/app/src/translations/hu.json +++ b/app/src/translations/hu.json @@ -84,9 +84,9 @@ "tooltip.muteParticipantVideo": "Résztvevő videóstreamének némítása", "tooltip.raisedHand": "Jelentkezés", "tooltip.muteScreenSharing": "Képernyőmegosztás szüneteltetése", - "tooltip.muteParticipantAudioModerator": null, - "tooltip.muteParticipantVideoModerator": null, - "tooltip.muteScreenSharingModerator": null, + "tooltip.muteParticipantAudioModerator": "Résztvevő hangjának általános némítása", + "tooltip.muteParticipantVideoModerator": "Résztvevő videójának általános némítása", + "tooltip.muteScreenSharingModerator": "Résztvevő képernyőmegosztásának általános némítása", "label.roomName": "Konferencia", "label.chooseRoomButton": "Tovább", @@ -141,7 +141,7 @@ "settings.echoCancellation": "Visszhangelnyomás", "settings.autoGainControl": "Automatikus hangerő", "settings.noiseSuppression": "Zajelnyomás", - "settings.drawerOverlayed": null, + "settings.drawerOverlayed": "Oldalsáv a tartalom felett", "filesharing.saveFileError": "A file-t nem sikerült elmenteni", "filesharing.startingFileShare": "Fájl megosztása",