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 0000000..cc420c4 Binary files /dev/null and b/LTI/lti1.png differ diff --git a/LTI/lti2.png b/LTI/lti2.png new file mode 100644 index 0000000..8c1d271 Binary files /dev/null and b/LTI/lti2.png differ diff --git a/LTI/lti3.png b/LTI/lti3.png new file mode 100644 index 0000000..3b16d35 Binary files /dev/null and b/LTI/lti3.png differ diff --git a/LTI/lti4.png b/LTI/lti4.png new file mode 100644 index 0000000..c36bbe2 Binary files /dev/null and b/LTI/lti4.png differ diff --git a/README.md b/README.md index dc2c8cf..43269d8 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,9 @@ If you want the automatic approach, you can find a docker image [here](https://h If you want the ansible approach, you can find ansible role [here](https://github.com/misi/mm-ansible/). [![asciicast](https://asciinema.org/a/311365.svg)](https://asciinema.org/a/311365) - ## Manual installation * Prerequisites: -Currently multiparty-meeting will only run on nodejs v10.* +Currently multiparty-meeting will only run on nodejs v13.x To install see here [here](https://github.com/nodesource/distributions/blob/master/README.md#debinstall). ```bash @@ -77,7 +76,7 @@ $ npm install $ cd server $ npm start ``` -* Note: Do not run the server as root. If you need to use port 80/443 make a iptables-mapping for that or use systemd configuration for that (see futher down this doc). +* Note: Do not run the server as root. If you need to use port 80/443 make a iptables-mapping for that or use systemd configuration for that (see further down this doc). * Test your service in a webRTC enabled browser: `https://yourDomainOrIPAdress:3443/roomname` ## Deploy it in a server @@ -103,12 +102,24 @@ $ systemctl enable multiparty-meeting ## Ports and firewall * 3443/tcp (default https webserver and signaling - adjustable in `server/config.js`) -* 4443/tcp (default `npm start` port for developing with live browser reload, not needed in production enviroments - adjustable in app/package.json) +* 4443/tcp (default `npm start` port for developing with live browser reload, not needed in production environments - adjustable in app/package.json) * 40000-49999/udp/tcp (media ports - adjustable in `server/config.js`) +## Load balanced installation +To deploy this as a load balanced cluster, have a look at [HAproxy](HAproxy.md). + +## Learning management integration +To integrate with an LMS (e.g. Moodle), have a look at [LTI](LTI/LTI.md). + ## TURN configuration -* You need an addtional [TURN](https://github.com/coturn/coturn)-server for clients located behind restrictive firewalls! Add your server and credentials to `app/config.js` +* You need an additional [TURN](https://github.com/coturn/coturn)-server for clients located behind restrictive firewalls! Add your server and credentials to `app/config.js` + +## Community-driven support + +* Open mailing list: community@lists.edumeet.org +* Subscribe: lists.edumeet.org/sympa/subscribe/community/ +* Open archive: lists.edumeet.org/sympa/arc/community/ ## Authors @@ -123,7 +134,7 @@ This started as a fork of the [work](https://github.com/versatica/mediasoup-demo ## License -MIT +MIT License (see `LICENSE.md`) 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. diff --git a/app/package.json b/app/package.json index d7cff17..ec92c5b 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "multiparty-meeting", - "version": "3.1.0", + "version": "3.3.0", "private": true, "description": "multiparty meeting service", "author": "Håvar Aambø Fosstveit ", @@ -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' && } -