Compare commits

..

No commits in common. "master" and "1.2" have entirely different histories.
master ... 1.2

371 changed files with 13120 additions and 60370 deletions

View file

@ -1,40 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. ...
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Branch/Versions:**
If you are developer, on which branch is this.
If you are reporting as user, on which version or location you experienced the issue.
Version is available under the 3 dots in the topbar under about.
**Server (if available please complete the following information):**
- OS: [e.g. Debian 11]
**Client:**
- Browser [e.g. chrome, safari, firefox, edge, ...]
- Browser version [e.g. 99]
- other [e.g. mobil browser under ios]
**Additional context**
Add any other context about the problem here.

View file

@ -1,10 +0,0 @@
---
name: Custom issue template
about: Describe this issue template's purpose here.
title: ''
labels: ''
assignees: ''
---

View file

@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View file

@ -1,106 +0,0 @@
name: Debian package
on:
push:
branches: [ master ]
# pull_request:
# branches: [ develop ]
jobs:
build:
runs-on: ubuntu-latest
env:
CI: false
strategy:
matrix:
node-version: [16.x]
steps:
- uses: actions/checkout@v2
with:
path: edumeet
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Get eduMEET version
id: get-version
run: |
echo "::set-output name=VERSION::$(cat edumeet/server/package.json | jq -r '.version')"
- name: Build Debian package
id: build-deb
run: |
cd edumeet
cp server/config/config.example.js server/config/config.js
cp server/config/config.example.yaml server/config/config.yaml
cp app/public/config/config.example.js app/public/config/config.js
cd app
yarn install && yarn build
cd ../server
yarn install && yarn build
cat <<< $(jq '.bundleDependencies += .dependencies' package.json) > package.json
npm pack
VERSION=${{ steps.get-version.outputs.VERSION }}
DATE=$(date)
mkdir -p /home/runner/package
cd /home/runner/package
mkdir DEBIAN
mkdir -p usr/local/src/edumeet/server
mkdir -p etc/systemd/system/
tar -xf /home/runner/work/***/***/***/server/***-server-$VERSION.tgz package/ 1>/dev/null 2>/dev/null || true
mv package/* usr/local/src/edumeet/server/
mv /home/runner/work/***/***/***/*.service etc/systemd/system/
rm -rf package
touch DEBIAN/md5sums
touch DEBIAN/md5sums
touch DEBIAN/control
#find . -type f ! -regex '.*.hg.*' ! -regex '.*?debian-binary.*' ! -regex '.*?DEBIAN.*' -printf '%P ' | xargs md5sum 1>/dev/null 2>/dev/null || true
#
cat > DEBIAN/control <<EOF
Package: edumeet
Version: $VERSION
Maintainer: eduMEET team (${{ github.server_url }}/${{ github.repository }})
Section: admin
Date : $DATE
Architecture: amd64
Priority: optional
Description: eduMMET is multiparty web-meetings based on mediasoup and WebRTC
Packet created from ${{ github.ref_name }} branch, commit:${{ github.sha }}
Visit ${{ github.server_url }}/${{ github.repository }}/tree/${{ github.ref_name }} for details
Depends: nodejs (>= 16), redis
EOF
#
cat > DEBIAN/postinst <<EOF
#!/bin/bash
mkdir -p /etc/edumeet/
ln -s /usr/local/src/edumeet/server/config/config.js /etc/edumeet/server-config.js || true
ln -s /usr/local/src/edumeet/server/dist/config/config.yaml /etc/edumeet/server-config.yaml || true
ln -s /usr/local/src/edumeet/server/public/config/config.js /etc/edumeet/client-config.js || true
systemctl daemon-reload
systemctl enable edumeet
echo "eduMEET multiparty meeting is installed.\n"
echo "\n\nClient and server configuration files are in /etc/edumeet directory\n"
echo "Please visit ${{ github.server_url }}/${{ github.repository }}/tree/${{ github.ref_name }} for configuration details.\n\n"
echo "\n\nAfter configuration, start service with 'sudo systemctl start edumeet' command.\n\n"
EOF
#
chmod 775 DEBIAN/postinst
cd ../
dpkg-deb -Zgzip --build package
mv /home/runner/package.deb /home/runner/edumeet-$VERSION.deb
- name : Upload artifact
uses: actions/upload-artifact@v2
with:
name: edumeet-${{ steps.get-version.outputs.VERSION }}
path: "/home/runner/edumeet-*.deb"
- name: Add to release assets
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.get-version.outputs.VERSION }}
files: "/home/runner/edumeet-*.deb"

13
.gitignore vendored
View file

@ -1,16 +1,9 @@
node_modules/
/app/build/
/app/public/config/config.js
/app/public/images/logo.*
!/app/public/images/logo.edumeet.svg
/app/config/
!/app/config/config.example.js
/server/config/
!/server/config/config.example.*
!/server/config/README.md
!/server/config/config.example.js
/server/public/
/server/certs/
/server/dist/
!/server/certs/mediasoup-demo.localhost.*
.vscode
/app/public/config/*.pem
yarn-error.log

View file

@ -1,269 +1,15 @@
# Changelog
## 3.5.3
Hotfix configuration issue
## 3.5.2
Security update for dependencies
## 3.5.1
* Updated Documentation
## 3.5.0
### Added
* Update build steps
* Update documentations
* Major chat refactoring
* Added chat badge displaying unread messages
* Merged file sharing + chat
* Save chat history (both in leave screen and in chat directly)
* Sorting messages
* Format messages
* Combine messages
* Local recording (chrome, firefox, edge)
* This is still default off in config because of bad UX connected to getUserMedias
* New app configuration system
* New server configuration system
* Add leaving room confirmation dialog
* New audio config with audio profiles
* New translations
* Screen Sharing now supports audio (chrome, edge)
* Add QoS set DSCP bits in IP header according to new option networkPriorities in app config
Our media traffic should get now higher priority where QoS has been implemented.
Routers will add higher priority or put media packets to low latency queue.
If QoS implemented in L2 too, then switches also set the L2 CoS bits according DSCP.
WIFI AP-s that implements QoS, also based on it could activate the multimedia extension MMS.
Hopefully it will help and add little bit more priority lower latency in congested networks.
At the time of this writing it is only implemented in Chrome.
### Improved
* Scalability and performance:
* Improvements for handling simulcast and video container size
* Improved performance for encryption of media (+25% capacity)
* Improved balancing of peers to workers
* CSS for mobile
* UI improvements (bugs fixes) #922
* Improved button layout
* Improved login dialog
* Added bcrypt for local auth strategy
### Fixed
* Fix auto unmute buttons on separate bar
* Fix browser version comparison
* Safari Screen Share Bug Fix
* Fix session overwrites
* Fix loosing the current language after leaving the room)
* Extra video duplication fix
* Fix loosing the current language after leaving the room)
* Fix notifications (they were displayed always in English)
* Translation updates
* A lot of bugs in auth
* Sanitizations for room names
## 3.4.0
### Added
* Multiparty meeting renamed to edumeet
* Merged room selector- and join- dialog - default keyboard focus on login field
* Localization selectable
* Added aspect ratio 16 : 9 and this is default now, ref #439
* New worker load calculation so router selection is based on that and not random anymore
* New permissions and roles:
* Propagate userRoles to client state, ref #437
* Extend userRoles and use the new audio, video permissions, ref #437
* New permission to modify peer roles
* Create room actions for giving and taking roles
* Add functions to client for modifying roles live
* Ability to give roles to users
* Add new permission to config
* Add room to userMapping. Example of giving moderator if there is no authenticated user
* Promote all peers from lobby when a peer joins with the PROMOTE_PEER permission and activateOnHostJoin is true in config
* Make menus more intuitive on mobile
* Simplify electron screenshare check
* Logo support
* Improve autoMute mic-indicators, Improve audio level scaling
* Clean up participant list
* Add indicator for peers in focus. Ref #360
* TCP enabled by default, prefer UDP
* Ability for Prometheus exporter to listen on localhost
* Make list headers bolder
* Trim displayName inputs. Add random number to Guest displayName.
* Removed facingMode from mobile
* Documentation for prometheus exporter
* Added switching of own video mirror in settings
* Added hiding of own videos
* Request audio and webcam permission at once, when user is requesting media
* Add initial support for local and saml auth
* Local login form
* Add bcrypt encrypted passwords for local strategy
* Add displayName mapping to usermapping
* Add saml attriute mappings
* Use shared cookieparser for web and websocket
* Update TopBar leave button
* Add joinAudio capability
* Standardize Auth button
### Migration
from last master version:
* Copy paste defaultAudio and centralAudioOtions from config.example.js to config.js
* Copy paste whole theme from config.example.js to config
* Configure logo in config.js
### Upgrade depencies
* webtorrent from 0.107.17 to 0.108.1 …
* Upgrade React-scripts
### Languages
* Updates translations: hu, tr, no, pl, uk
* Addad translations: Hindi (hi), Russian (ru), Kazakh (ka)
### Bugfixes
* Hopefully fix to silent peer issue, ref #256
* Set timeout for TURN API request, fixes #484
* Possible fix for #582 Crackling sound
* Fix for #444 Settings persistence
* Fix Audio settings from config.js take no effect
* Fix only firefox handles applyConstraints to audio tracks correctly
* Fix (autoMuted) mic too big click area
* Fix userRoles check
* Fix spoltights ignoring maxLastN
* Fix wrong config parameter naming (voiceActivatedUnmute)
* Fix locale checking state
* Fix express error handler
* Add constraint for user facing camera. Update media devices properly
* Cleanup on close
* Fixed null values for peers not yet assigned to a router
* Fix URL sanitizer bug
* Removed audio request modification
* Fix race in spotlights
* Fix piping bug when peers returning to router
* Fix piped router count
* Add comma to prometheus config code
* Removed code that is problematic and also unused
* Remove duplicated callbackURL
* Fix moderator buttons layout
* Fix: Special chars are not sanitized in URL
* Fix another roomId bug
* Tidy: replace obj.entries with obj.values to avoid unused key
* Fix close room link
## 3.3
* Add: Rooms now scale across cores
* Add: Permissions and roles. Users can now have different roles (moderator, admin etc.) that give different permissions.
* Add: TURN API or fallback TURN server
* Add: Configurable room size limit
* Add: Prometheus monitoring support
* Add: Possible to share several videos (ex: 2 webcams)
* Add: Configurable audio settings (echocancellation etc.)
* Add: Configurable audio output device (in supported browsers)
* Add: Audio auto mute/unmute based on volume
* Add: Handle unsupported browsers properly
* Add: Lots of appearance settings
* Add: Side drawer can now stay permanently open
* Add: Move control buttons to separate control bar
* Add: Can now "raise hand"
* Add: Screen sharing in Safari 13+, Opera and Edge
* Add: Extended advanced info about network in client
* Add: Configurable screen sharing frame rate
* Add: Help and About dialogs
* Add: More keyboard shortcuts
* Add: Quality indicator on videos
* Add: More translations
* Fix: Various UI fixes and improvements
* Fix: Better audio/video device handling
* Fix: Update keyboard shortcut handling
* Fix: Authentication for load balanced scenarios
* Fix: Signaling when entering lobby
* Fix: Signaling timeouts and retries
* Fix: Filesharing fixes (sharing same file twice, etc.)
* Fix: Better handling of hark
* Fix: Use applyContraints instead of restarting producers
* Fix: Now handles reconnects properly if client loses connection
* Fix: Rotating devices don't show rotated videos
* Fix: Various fixes to client authentication
## 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 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 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 translations (12+1 languages)
* Add support IPv6
* Many other fixes and refactorings
## 3.1
* Browser session storage
* Virtual lobby for rooms
* Allow minimum TLSv1.2 and recommended ciphers
* Code splitting for faster load times
* Various GUI fixes
* Internationalization support
* Can require sign in for access
## 3.0
* Updated to mediasoup v3
* Replace lib "passport-datporten" with "openid-client" (a general OIDC certified client)
* OpenID Connect discovery
* Auth code flow
* Add spdy http2 support.
* Notice it does not supports node 11.x
* Updated to Material UI v4
## 2.0
* Material UI
* Separate settings for lastN for desktop and mobile
## 1.2
* Add Lock Room feature
* Fix suspended Web Audio context / fixed delayed getUsermedia
* Added support for the new getdisplaymedia API in Chrome 72
## 1.1
### 1.1
* Moved Filesharing code out from React code to RoomClient
* Major cleanup of CSS. Variables for most colors and sizes exposed in :root
* Started using React Context instead of middleware
* Small fixes to buttons and layout
## 1.0
### 1.0
* Fixed toolarea button based on feedback from users
* Added possibility to move video to separate window
* Added SIP gateway
## RC1 1.0
### RC1 1.0
* First stable release?

View file

@ -1,3 +0,0 @@
Source code contributions should pass static code analysis as performed by `npm run lint` in `server` and `app` respectively.
Please contribute by creating your pull requests against the `develop` branch.

View file

@ -1,21 +0,0 @@
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.

View file

@ -1,61 +0,0 @@
# 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 edumeet moodle plugin:
[https://github.com/edumeet/moodle-mod_edumeet](https://github.com/edumeet/moodle-mod_edumeet)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

317
README.md
View file

@ -1,291 +1,118 @@
# ![edumeet logo](/app/public/images/logo.edumeet.svg) **WebRTC meeting service using [mediasoup](https://mediasoup.org).**
Official website: [edumeet.org](https://edumeet.org)
# multiparty-meeting
https://user-images.githubusercontent.com/37835902/152279867-639db9bc-bf78-430f-b96f-d17733527474.mp4
A WebRTC meeting service using [mediasoup](https://mediasoup.org).
Try it online at [letsmeet.no](https://letsmeet.no)
Try it online at https://letsmeet.no. You can add /roomname to the URL for specifying a room.
## Main features
## Features
* Audio/Video
* Chat
* Screen sharing
* File sharing
* Different video layouts
| Feature | Description |
| ------------- | ------------- |
| **A/V streaming** | Share your microphone and camera + additional video stream |
| **Video layouts** | Choose between **Democratic** and **Filmstrip** views. More in progress. |
| **Screen sharing** | Share your screen to make some presentation right from your desktop |
| **File sharing** | Share your files with the peers (torrent solution under the hood) |
| **Chat messages** | Text conversation with other participants |
| **Local Recording** | Record window/tab/screen content in browser supported formats with room audio and save them (**disabled by default**) |
| **Authentication** | Supported types: **OIDC**, **SAML**, **local db (text-based)** |
There is also a SIP gateway that can be found [here](https://github.com/havfo/multiparty-meeting-sipgw). To try it, call: roomname@letsmeet.no.
## Docker
If you want the automatic approach, you can find a docker image [here](https://hub.docker.com/r/misi/mm/).
### Internationalization (22 languages)
<details>
<summary>Help us with translations:exclamation:</summary>
#### How to contribute?
1. Continue to translate existing [language file](/app/src/intl/translations)
2. find the _null_ values
> "settings.language": null,
3. replace them based on the _en.json_ file
> "settings.language": "Select language",
4. If your language is not listed, create a new translation _.json_ file..
> copy en.json to [_"two letter country code"_.json](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) and translate to your languange
5. make a Pull Request, or send us a file by [e-mail](mailto:community@lists.edumeet.org)
Thank you in advance!
</details>
## Manual installation
### Local Recording
<details>
<summary>See more</summary>
* Local Recording records the browser window video and audio. From the list of media formats that your browser supports you can select your preferred media format in the settings menu advanced video menu setting. MediaRecorder makes small chucks of recording and these recorded blob chunks temporary stored in IndexedDB, if IndexedDB implemented in your browser. Otherwise it stores blobs in memory in an array of blobs.
Local Recording creates a local IndexedDB with the name of the starting timestamp (unix timestamp format) And a storage called chunks. All chunks read in an array and created a final blob that you can download. After blobs array concatenation as a big blob, this big blob saved as file, and finally we delete the temporary local IndexedDB.
* Local recording is **disabled** by default. It could be enabled by setting _localRecordingEnabled_ to true in (./app/public/config/config.js)
* **WARNING**: Take care that local recording will increase cpu, memory and disk space consumption.
**Enough free disk space has to be provided!!!**
Keep in mind that Browsers don't allow to use all the disk free capacity!
See more info about browsers storage limits:
* <https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Browser_storage_limits_and_eviction_criteria#storage_limits>
* <https://chromium.googlesource.com/chromium/src/+/refs/heads/master/storage/browser/quota/quota_settings.cc#68>
</details>
# Installation
See here for [Docker](https://github.com/edumeet/edumeet-docker/) or [Ansible](https://github.com/edumeet/edumeet-ansible/) (based on Docker) installation procedures
## Debian & Ubuntu based operating systems (.deb package)
* Prerequisites: Installed NodeJS (v16.x) as described in [Manual installation](#manual-installation-build) section.
* See [Configuration](#configuration) section for client and server configuration details.
* Download from [releases](https://github.com/edumeet/edumeet/releases) assets, or latest job [artifact](https://github.com/edumeet/edumeet/actions?query=workflow%3ADeployer+branch%3Amaster+is%3Asuccess).
* Clone the project:
```bash
# Unzip the file
unzip edumeet.zip
# Install the package
sudo apt install edumeet/edumeet.deb
# After package installation, don't forget to edit configuration files.
sudo nano /etc/educonf/client-config.js
sudo nano /etc/educonf/server-config.js
sudo nano /etc/educonf/server-config.yaml
# Finally, start the service by (it's enabled by default)
sudo systemctl start edumeet
$ git clone https://github.com/havfo/multiparty-meeting.git
$ cd multiparty-meeting
```
## Manual installation (build)
Installation example is based on Debian/Ubuntu Linux operating system.
1. Install [NodeJS (v16.x)](https://github.com/nodesource/distributions) and [Yarn ](https://classic.yarnpkg.com/en/docs/install#debian-stable) package manager
- NodeJS (v16.x) [Debian/Ubuntu](https://github.com/nodesource/distributions#deb)
```bash
# Using Ubuntu
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash -
sudo apt-get install -y nodejs
# Using Debian, as root
curl -fsSL https://deb.nodesource.com/setup_16.x | bash -
apt-get install -y nodejs
```
- Yarn package manager:
```bash
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
sudo apt update && sudo apt install yarn
```
2. Install all required dependencies
```bash
sudo apt update && sudo apt install -y curl git python python3-pip build-essential redis openssl libssl-dev pkg-config
```
3. Clone eduMEET git repository
```bash
git clone https://github.com/edumeet/edumeet.git
cd edumeet
```
(switch to the "develop" branch to get the latest features)
```bash
git checkout develop
```
### Configuration
**eduMEET** will start and run normally with just default settings. If there is no configuration files, it will automatically detect your host IP address, and listen on port 443 (https). In order to change default values (e.g. certificates), or activate features (e.g. authentication), use appropriate configuration file (see below for details).
**:warning: Note:** There are separate configuration files for eduMEET application and eduMEET server:
**eduMEET application (app)** for: enabling login, change logo or background, adjust A/V parameters, etc...
Copy [example](/app/public/config/config.example.js) template and edit values (see all available parameters in [./app/public/config/README.md](/app/public/config/README.md))
```bash
cp app/public/config/config.example.js app/public/config/config.js
```
**eduMEET server** require **:warning:two** configuration files: **config.js**, and **config.{_json_, _yaml_ or _toml_}** (multiple format supported)
**1. config.js** for setting authentication methods and user roles.
Copy example template and edit values (see additional details in [example](/server/config/config.example.js) file)
```bash
cp server/config/config.example.js server/config/config.js
```
**2. config.{_json_, _yaml_ or _toml_}** for configuring: server port, server certificates, [STUN/TURN](#turn-configuration) configuration, monitoring, etc... (See below examples of different configuration styles).
[**:point_right: _config.yaml_**](/server/config/config.example.yaml) example:
```yaml
listeningPort: 443
tls:
key: /opt/edumeet/server/certs/privkey.pem
cert: /opt/edumeet/server/certs/cert.pem
```
[**:point_right: _config.json_**](/server/config/config.example.json) example:
```javascript
{
"listeningPort" : "443",
"tls" : {
"cert" : "/opt/edumeet/server/certs/cert.pem",
"key" : "/opt/edumeet/server/certs/privkey.pem"
}
}
```
[**:point_right: _config.toml_**](/server/config/config.example.toml) example:
```toml
listeningPort = "443"
[tls]
cert = "/opt/edumeet/server/certs/cert.pem"
key = "/opt/edumeet/server/certs/privkey.pem"
```
**:red_circle: IMPORTANT:** Use **only one** type for second configuration file (`yaml` file format is highly recommended)
Copy **only one** example template file and edit values (see all available parameters in [./server/config/README.md](/server/config/README.md))
```bash
cp server/config/config.example.yaml server/config/config.yaml
OR!!!
cp server/config/config.example.json server/config/config.json
OR!!!
cp server/config/config.example.toml server/config/config.toml
```
**:warning: NOTE:** application and server components **has to be rebuild** if configuration parameter is changed ([see build steps](#manual-installation-build)). Rebuild is not necessary for Docker or Debian (.deb) version, just restart container/service.
### Build
**Note:** It is highly recommended to use _yarn_ package manager.
* Copy `server/config.example.js` to `server/config.js` :
```bash
cd app
yarn && yarn build
cd ../server
yarn && yarn build
```
### Run
**Run on server** (as root or with sudo)
```bash
# Run the Node.js server application in a terminal:
cd server
sudo yarn start
$ cp server/config.example.js server/config.js
```
**Run locally** (for development)
* The newest build is always in **develop branch** if you want to make a contribution/pull request use it instead of master branch.
* Copy `app/config.example.js` to `app/config.js` :
```bash
# run a live build from app folder:
app$ yarn start
# and run server in server folder:
server$ yarn start
$ cp app/config.example.js app/config.js
```
Note: To avoid running server as root, redirects privileged ports with firewall rules:
* Edit your two `config.js` with appropriate settings (listening IP/port, logging options, **valid** TLS certificate, etc).
* Set up the browser app:
```bash
#adjust ports to your needs
sudo iptables -t nat -A PREROUTING -p tcp --dport 443 -j REDIRECT --to-ports 8443
sudo iptables -t nat -A OUTPUT -p tcp --dport 443 -o lo -j REDIRECT --to-port 8443
sudo iptables -t nat -A PREROUTING -p tcp --dport 3443 -j REDIRECT --to-ports 8443
sudo iptables -t nat -A OUTPUT -p tcp --dport 3443 -o lo -j REDIRECT --to-port 8443
# make it persistent
sudo apt install iptables-persistent
sudo iptables-save > /etc/iptables/rules.v4
sudo ip6tables-save > /etc/iptables/rules.v6
$ cd app
$ npm install
$ export NODE_ENV=production
$ gulp dist
```
* Test your service in a webRTC enabled browser: `https://yourDomainOrIPAdress:3443/roomname`
This will build the client application and copy everythink to `server/public` from where the server can host client code to browser requests.
**Run as a service** (systemd)
* Globally install `gulp-cli` NPM module (may need `sudo`):
```bash
# Stop your locally running server. Copy systemd-service file `edumeet.service` to `/etc/systemd/system/` and check location path settings:
cp edumeet.service /etc/systemd/system/
$ npm install -g gulp-cli
```
# modify the install paths, if required
sudo edit /etc/systemd/system/edumeet.service
* Set up the server:
# Reload systemd configuration and start service:
sudo systemctl daemon-reload
sudo systemctl start edumeet
```bash
$ cd ..
$ cd server
$ npm install
```
# If you want to start edumeet at boot time:
sudo systemctl enable edumeet
## Run it locally
* Run the Node.js server application in a terminal:
```bash
$ node server.js
```
* test your service in a webRTC enabled browser: `https://yourDomainOrIPAdress:3443/roomname`
## Deploy it in a server
* Stop your locally running server. Copy systemd-service file `multiparty-meeting.service` to `/etc/systemd/system/` and check location path settings:
```bash
$ cp multiparty-meeting.service /etc/systemd/system/
$ edit /etc/systemd/system/multiparty-meeting.service
```
* reload systemd configuration and start service:
```bash
$ systemctl daemon-reload
$ systemctl start multiparty-meeting
```
* if you want to start multiparty-meeting at boot time:
```bash
$ systemctl enable multiparty-meeting
```
## Ports and firewall
| Port | protocol | description |
| ---- | ----------- | ----------- |
| 443 | tcp | default https webserver and signaling - adjustable in `server/config/config.yaml`) |
| 4443 | tcp | default `yarn 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/config.yaml` |
## Load balanced installation
To deploy this as a load balanced cluster, have a look at [HAproxy](/docs/HAproxy.md).
## Learning management integration
To integrate with an LMS (e.g. Moodle), have a look at [LTI](LTI/LTI.md).
* 3443/tcp (default https webserver and signaling - adjustable in `server/config.js`)
* 3000/tcp (default `gulp live` port for developing with live browser reload, not needed in production enviroments - adjustable in app/gulpfile.js)
* 40000-49999/udp/tcp (media ports - adjustable in `server/config.js`)
## TURN configuration
If you are part of the GEANT eduGAIN, you can request your turn api key at [https://turn.geant.org/](https://turn.geant.org/)
You need an additional [TURN](https://github.com/coturn/coturn)-server for clients located behind restrictive firewalls!
Add your server and credentials to `server/config/config.yaml`
## Community-driven support
| Type | |
| ----------- | ----------- |
| Open mailing list | community@lists.edumeet.org |
| Subscribe | lists.edumeet.org/sympa/subscribe/community/ |
| Open archive | lists.edumeet.org/sympa/arc/community/ |
* 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`
## Authors
* Håvar Aambø Fosstveit
* Stefan Otto
* Mészáros Mihály
* Roman Drozd
* Rémai Gábor László
* Piotr Pawałowski
This started as a fork of the [work](https://github.com/versatica/mediasoup-demo) done by:
* Iñaki Baz Castillo [[website](https://inakibaz.me)|[github](https://github.com/ibc/)]
## License
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 Unions Horizon 2020 research and innovation programme under Grant Agreement No. 731122 (GN4-2). On behalf of GÉANT project, GÉANT Association is the sole owner of the copyright in all material which was developed by a member of the GÉANT project.
GÉANT Vereniging (Association) is registered with the Chamber of Commerce in Amsterdam with registration number 40535155 and operates in the UK as a branch of GÉANT Vereniging. Registered office: Hoekenrode 3, 1102BR Amsterdam, The Netherlands. UK branch address: City House, 126-130 Hills Road, Cambridge CB2 1PQ, UK.
MIT

26
app/.babelrc Normal file
View file

@ -0,0 +1,26 @@
{
"plugins":
[
"@babel/plugin-proposal-object-rest-spread",
"jsx-control-statements",
"@babel/plugin-proposal-class-properties",
"@babel/plugin-transform-runtime"
],
"presets":
[
[
"@babel/preset-env",
{
"targets": {
"browsers": [
"chrome >= 67",
"edge >= 17",
"firefox >= 60",
"safari >= 12"
]
}
}
],
"@babel/react"
]
}

View file

@ -1,2 +0,0 @@
REACT_APP_VERSION=$npm_package_version
REACT_APP_NAME=$npm_package_name

View file

@ -1,2 +0,0 @@
.eslintrc.js
src/react-app-env.d.ts

231
app/.eslintrc.js Normal file
View file

@ -0,0 +1,231 @@
module.exports =
{
env:
{
browser: true,
es6: true,
node: true
},
plugins:
[
'import',
'react',
'jsx-control-statements'
],
extends:
[
'eslint:recommended',
'plugin:react/recommended',
'plugin:jsx-control-statements/recommended'
],
settings:
{
react:
{
pragma: 'React',
version: '16'
}
},
parser: "babel-eslint",
parserOptions:
{
ecmaVersion: 2018,
sourceType: 'module',
ecmaFeatures:
{
impliedStrict: true,
jsx: true
}
},
rules:
{
'array-bracket-spacing': [ 2, 'always',
{
objectsInArrays: true,
arraysInArrays: true
}],
'arrow-parens': [ 2, 'always' ],
'arrow-spacing': 2,
'block-spacing': [ 2, 'always' ],
'brace-style': [ 2, 'allman', { allowSingleLine: true } ],
'camelcase': 2,
'comma-dangle': 2,
'comma-spacing': [ 2, { before: false, after: true } ],
'comma-style': 2,
'computed-property-spacing': 2,
'constructor-super': 2,
'func-call-spacing': 2,
'generator-star-spacing': 2,
'guard-for-in': 2,
'indent': [ 2, 'tab', { 'SwitchCase': 1 } ],
'key-spacing': [ 2,
{
singleLine:
{
beforeColon: false,
afterColon: true
},
multiLine:
{
beforeColon: true,
afterColon: true,
align: 'colon'
}
}],
'keyword-spacing': 2,
'linebreak-style': [ 2, 'unix' ],
'lines-around-comment': [ 2,
{
allowBlockStart: true,
allowObjectStart: true,
beforeBlockComment: true,
beforeLineComment: false
}],
'max-len': [ 2, 90,
{
tabWidth: 2,
comments: 110,
ignoreUrls: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
ignoreRegExpLiterals: true
}],
'newline-after-var': 2,
'newline-before-return': 2,
'newline-per-chained-call': 2,
'no-alert': 2,
'no-caller': 2,
'no-case-declarations': 2,
'no-catch-shadow': 2,
'no-class-assign': 2,
'no-confusing-arrow': 2,
'no-console': 2,
'no-const-assign': 2,
'no-debugger': 2,
'no-dupe-args': 2,
'no-dupe-keys': 2,
'no-duplicate-case': 2,
'no-div-regex': 2,
'no-empty': [ 2, { allowEmptyCatch: true } ],
'no-empty-pattern': 2,
'no-else-return': 0,
'no-eval': 2,
'no-extend-native': 2,
'no-ex-assign': 2,
'no-extra-bind': 2,
'no-extra-boolean-cast': 2,
'no-extra-label': 2,
'no-extra-semi': 2,
'no-fallthrough': 2,
'no-func-assign': 2,
'no-global-assign': 2,
'no-implicit-coercion': 2,
'no-implicit-globals': 2,
'no-inner-declarations': 2,
'no-invalid-regexp': 2,
'no-irregular-whitespace': 2,
'no-lonely-if': 2,
'no-mixed-operators': 2,
'no-mixed-spaces-and-tabs': 2,
'no-multi-spaces': 2,
'no-multi-str': 2,
'no-multiple-empty-lines': [ 2, { max: 1, maxEOF: 0, maxBOF: 0 } ],
'no-native-reassign': 2,
'no-negated-in-lhs': 2,
'no-new': 2,
'no-new-func': 2,
'no-new-wrappers': 2,
'no-obj-calls': 2,
'no-proto': 2,
'no-prototype-builtins': 0,
'no-redeclare': 2,
'no-regex-spaces': 2,
'no-restricted-imports': 2,
'no-return-assign': 2,
'no-self-assign': 2,
'no-self-compare': 2,
'no-sequences': 2,
'no-shadow': 2,
'no-shadow-restricted-names': 2,
'no-spaced-func': 2,
'no-sparse-arrays': 2,
'no-this-before-super': 2,
'no-throw-literal': 2,
'no-undef': 2,
'no-unexpected-multiline': 2,
'no-unmodified-loop-condition': 2,
'no-unreachable': 2,
'no-unused-vars': [ 1, { vars: 'all', args: 'after-used' }],
'no-use-before-define': [ 2, { functions: false } ],
'no-useless-call': 2,
'no-useless-computed-key': 2,
'no-useless-concat': 2,
'no-useless-rename': 2,
'no-var': 2,
'no-whitespace-before-property': 2,
'object-curly-newline': 0,
'object-curly-spacing': [ 2, 'always' ],
'object-property-newline': [ 2, { allowMultiplePropertiesPerLine: true } ],
'prefer-const': 2,
'prefer-rest-params': 2,
'prefer-spread': 2,
'prefer-template': 2,
'quotes': [ 2, 'single', { avoidEscape: true } ],
'semi': [ 2, 'always' ],
'semi-spacing': 2,
'space-before-blocks': 2,
'space-before-function-paren': [ 2, { anonymous: 'never', named: 'never', 'asyncArrow': 'always'}],
'space-in-parens': [ 2, 'never' ],
'spaced-comment': [ 2, 'always' ],
'strict': 2,
'valid-typeof': 2,
'eol-last': 0,
'yoda': 2,
// eslint-plugin-import options.
'import/extensions': 2,
'import/no-duplicates': 2,
// eslint-plugin-react options.
'jsx-quotes': [ 2, 'prefer-single' ],
'react/display-name': [ 2, { ignoreTranspilerName: false } ],
'react/forbid-prop-types': 0,
'react/jsx-boolean-value': 2,
'react/jsx-closing-bracket-location': 2,
'react/jsx-curly-spacing': 2,
'react/jsx-equals-spacing': 2,
'react/jsx-handler-names': 2,
'react/jsx-indent-props': [ 2, 'tab' ],
'react/jsx-indent': [ 2, 'tab' ],
'react/jsx-key': 2,
'react/jsx-max-props-per-line': 0,
'react/jsx-no-bind': 0,
'react/jsx-no-duplicate-props': 2,
'react/jsx-no-literals': 0,
'react/jsx-no-undef': 0,
'react/jsx-pascal-case': 2,
'react/jsx-sort-prop-types': 0,
'react/jsx-sort-props': 0,
'react/jsx-uses-react': 2,
'react/jsx-uses-vars': 2,
'react/no-danger': 2,
'react/no-deprecated': 2,
'react/no-did-mount-set-state': 2,
'react/no-did-update-set-state': 2,
'react/no-direct-mutation-state': 2,
'react/no-is-mounted': 2,
'react/no-multi-comp': 0,
'react/no-set-state': 0,
'react/no-string-refs': 0,
'react/no-unknown-property': 2,
'react/prefer-es6-class': 2,
'react/prop-types': [ 2, { skipUndeclared: true } ],
'react/react-in-jsx-scope': 2,
'react/self-closing-comp': 2,
'react/sort-comp': 0,
'react/jsx-wrap-multilines': [ 2,
{
declaration: false,
assignment: false,
return: true
}]
}
};

View file

@ -1,386 +0,0 @@
{
"env": {
"browser": true,
"es6": true,
"node": true
},
"overrides": [
{
"files": ["**/*.{ts,tsx}"],
"plugins": ["@typescript-eslint"],
"extends":[
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json"
},
"settings": {
"import/resolver": {
"typescript": {
"alwaysTryTypes": true
}
}
},
"rules": {
"no-unused-vars" : 0,
"@typescript-eslint/ban-types" : 0,
"@typescript-eslint/ban-ts-comment" : 0,
"@typescript-eslint/ban-ts-ignore" : 0,
"@typescript-eslint/explicit-module-boundary-types" : 0,
"@typescript-eslint/member-delimiter-style" : [ 2,
{
"multiline" : { "delimiter": "semi", "requireLast": true },
"singleline" : { "delimiter": "semi", "requireLast": false }
}
],
"@typescript-eslint/no-explicit-any" : 0,
"@typescript-eslint/no-unused-vars" : [ 2,
{
"vars" : "all",
"args" : "after-used",
"ignoreRestSiblings" : false
}
],
"@typescript-eslint/no-use-before-define" : [ 2, { "functions": false } ],
"@typescript-eslint/no-empty-function" : 0,
"@typescript-eslint/no-non-null-assertion" : 0
}
}
],
"plugins": [
"import",
"react"
],
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"react-app"
],
"settings": {
"react": {
"pragma": "React",
"version": "16"
}
},
"parser": "babel-eslint",
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module",
"ecmaFeatures": {
"impliedStrict": true,
"jsx": true
}
},
"rules": {
"array-bracket-spacing": [
2,
"always",
{
"objectsInArrays": true,
"arraysInArrays": true
}
],
"arrow-parens": [
2,
"always"
],
"arrow-spacing": 2,
"block-spacing": [
2,
"always"
],
"brace-style": [
2,
"allman",
{
"allowSingleLine": true
}
],
"camelcase": 2,
"comma-dangle": 2,
"comma-spacing": [
2,
{
"before": false,
"after": true
}
],
"comma-style": 2,
"computed-property-spacing": 2,
"constructor-super": 2,
"func-call-spacing": 2,
"generator-star-spacing": 2,
"guard-for-in": 2,
"indent": [
2,
"tab",
{
"SwitchCase": 1
}
],
"key-spacing": [
2,
{
"singleLine": {
"beforeColon": false,
"afterColon": true
},
"multiLine": {
"beforeColon": true,
"afterColon": true,
"align": "colon"
}
}
],
"keyword-spacing": 2,
"linebreak-style": [
2,
"unix"
],
"lines-around-comment": [
2,
{
"allowBlockStart": true,
"allowObjectStart": true,
"beforeBlockComment": true,
"beforeLineComment": false
}
],
"max-len": [
2,
90,
{
"tabWidth": 2,
"comments": 110,
"ignoreUrls": true,
"ignoreStrings": true,
"ignoreTemplateLiterals": true,
"ignoreRegExpLiterals": true
}
],
"newline-after-var": 2,
"newline-before-return": 2,
"newline-per-chained-call": 2,
"no-alert": 2,
"no-caller": 2,
"no-case-declarations": 2,
"no-catch-shadow": 2,
"no-class-assign": 2,
"no-confusing-arrow": [
"error",
{
"allowParens": true
}
],
"no-console": 2,
"no-const-assign": 2,
"no-debugger": 2,
"no-dupe-args": 2,
"no-dupe-keys": 2,
"no-duplicate-case": 2,
"no-div-regex": 2,
"no-empty": [
2,
{
"allowEmptyCatch": true
}
],
"no-empty-pattern": 2,
"no-else-return": 0,
"no-eval": 2,
"no-extend-native": 2,
"no-ex-assign": 2,
"no-extra-bind": 2,
"no-extra-boolean-cast": 2,
"no-extra-label": 2,
"no-extra-semi": 2,
"no-fallthrough": 2,
"no-func-assign": 2,
"no-global-assign": 2,
"no-implicit-coercion": 2,
"no-implicit-globals": 2,
"no-inner-declarations": 2,
"no-invalid-regexp": 2,
"no-irregular-whitespace": 2,
"no-trailing-spaces": [
"error",
{
"ignoreComments": true
}
],
"no-lonely-if": 2,
"no-mixed-operators": 2,
"no-mixed-spaces-and-tabs": 2,
"no-multi-spaces": 2,
"no-multi-str": 2,
"no-multiple-empty-lines": [
2,
{
"max": 1,
"maxEOF": 0,
"maxBOF": 0
}
],
"no-native-reassign": 2,
"no-negated-in-lhs": 2,
"no-new": 2,
"no-new-func": 2,
"no-new-wrappers": 2,
"no-obj-calls": 2,
"no-proto": 2,
"no-prototype-builtins": 0,
"no-redeclare": 2,
"no-regex-spaces": 2,
"no-restricted-imports": 2,
"no-return-assign": 2,
"no-self-assign": 2,
"no-self-compare": 2,
"no-sequences": 2,
"no-shadow": 2,
"no-shadow-restricted-names": 2,
"no-spaced-func": 2,
"no-sparse-arrays": 2,
"no-this-before-super": 2,
"no-throw-literal": 2,
"no-undef": 2,
"no-unexpected-multiline": 2,
"no-unmodified-loop-condition": 2,
"no-unreachable": 2,
"no-unused-vars": [
1,
{
"vars": "all",
"args": "after-used"
}
],
"no-use-before-define": [
2,
{
"functions": false
}
],
"no-useless-call": 2,
"no-useless-computed-key": 2,
"no-useless-concat": 2,
"no-useless-rename": 2,
"no-var": 2,
"no-whitespace-before-property": 2,
"object-curly-newline": 0,
"object-curly-spacing": [
2,
"always"
],
"object-property-newline": [
2,
{
"allowMultiplePropertiesPerLine": true
}
],
"prefer-const": 2,
"prefer-rest-params": 2,
"prefer-spread": 2,
"prefer-template": 2,
"quotes": [
2,
"single",
{
"avoidEscape": true
}
],
"semi": [
2,
"always"
],
"semi-spacing": 2,
"space-before-blocks": 2,
"space-before-function-paren": [
2,
{
"anonymous": "never",
"named": "never",
"asyncArrow": "always"
}
],
"space-in-parens": [
2,
"never"
],
"spaced-comment": [
2,
"always"
],
"strict": 2,
"valid-typeof": 2,
"eol-last": 0,
"yoda": 2,
"import/extensions": 2,
"import/no-duplicates": 2,
"jsx-quotes": [
2,
"prefer-single"
],
"react/display-name": [
2,
{
"ignoreTranspilerName": false
}
],
"react/forbid-prop-types": 0,
"react/jsx-boolean-value": 2,
"react/jsx-closing-bracket-location": 2,
"react/jsx-curly-spacing": 2,
"react/jsx-equals-spacing": 2,
"react/jsx-handler-names": 2,
"react/jsx-indent-props": [
2,
"tab"
],
"react/jsx-indent": [
2,
"tab"
],
"react/jsx-key": 2,
"react/jsx-max-props-per-line": 0,
"react/jsx-no-bind": 0,
"react/jsx-no-duplicate-props": 2,
"react/jsx-no-literals": 0,
"react/jsx-no-undef": 0,
"react/jsx-pascal-case": 2,
"react/jsx-sort-prop-types": 0,
"react/jsx-sort-props": 0,
"react/jsx-uses-react": 2,
"react/jsx-uses-vars": 2,
"react/no-danger": 2,
"react/no-deprecated": 2,
"react/no-did-mount-set-state": 2,
"react/no-did-update-set-state": 2,
"react/no-direct-mutation-state": 2,
"react/no-is-mounted": 2,
"react/no-multi-comp": 0,
"react/no-set-state": 0,
"react/no-string-refs": 0,
"react/no-unknown-property": 2,
"react/prefer-es6-class": 2,
"react/prop-types": [
2,
{
"skipUndeclared": true
}
],
"react/react-in-jsx-scope": 2,
"react/self-closing-comp": 2,
"react/sort-comp": 0,
"react/jsx-wrap-multilines": [
2,
{
"declaration": false,
"assignment": false,
"return": true
}
]
}
}

1
app/.npmrc Normal file
View file

@ -0,0 +1 @@
package-lock=false

View file

@ -1,2 +0,0 @@
react: npm start
electron: node src/electron-wait-react

7
app/banner.txt Normal file
View file

@ -0,0 +1,7 @@
/*
* <%= pkg.name %> v<%= pkg.version %>
* <%= pkg.description %>
* Copyright: 2017-<%= currentYear %> <%= pkg.author %>
* License: <%= pkg.license %>
*/

74
app/chooseRoom.html Normal file
View file

@ -0,0 +1,74 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Multiparty Meeting</title>
</head>
<style>
body{
margin:auto;
padding:0.5vmin;
text-align:center;
position: fixed;
left: 50%;
top: 40%;
width: 90%;
transform: translate(-50%, 0%);
background-image: url('/resources/images/background.svg');
background-attachment: fixed;
background-position: center;
background-size: cover;
background-repeat: repeat;
}
input:hover {opacity:0.9;}
input[type=text]{
font-size: 1.5em;
padding: 1.5vmin;
background-color: rgba(0,0,0,0.3);
border: 0;
color: #fff;
margin: 0.8vmin;
width: 50%;
}
button:hover {background-color: #28bd7b;}
button{
font-size: 1.5em;
padding: 1.5vmin;
margin: 0.8vmin;
background-color: #38cd8b;
border-radius: 1.8vmin;
color: #fff;
border: 0;
}
img{
height: 15vmin;
}
</style>
<body>
<a>
<img src='/resources/images/logo.svg'></img><br>
</a>
<input id="room" type="text" onkeypress="checkEnter(event)" value="" placeholder="your room name">
<button onclick = "start(location.href)">Go to room</button>
</body>
<script>
var room = document.getElementById("room");
var stateObj = { foo: "bar" };
room.addEventListener("input", function(e) {
console.log(e.charCode);
history.replaceState(stateObj, "Multiparty Meeting", "/"+room.value);
},true);
room.focus();
function start(target){
location.href;history.replaceState(stateObj, "Multiparty Meeting", "/");
window.location = target;
}
function checkEnter(event){
var x = event.charCode || event.keyCode;
if (x == 13 ) {
start(location.href);
}
}
</script>
</html>

View file

@ -0,0 +1,19 @@
module.exports =
{
chromeExtension : 'https://chrome.google.com/webstore/detail/fckajcjdaabdgnbdcmhhebdglogjfodi',
loginEnabled : false,
turnServers : [
{
urls : [
'turn:example.com:443?transport=tcp'
],
username : 'example',
credential : 'example'
}
],
requestTimeout : 10000,
transportOptions :
{
tcp : true
}
};

299
app/gulpfile.js Normal file
View file

@ -0,0 +1,299 @@
/**
* Tasks:
*
* gulp dist
* Generates the browser app in development mode (unless NODE_ENV is set
* to 'production').
*
* gulp live
* Generates the browser app in development mode (unless NODE_ENV is set
* to 'production'), opens it and watches for changes in the source code.
*
* gulp
* Alias for `gulp live`.
*/
const fs = require('fs');
const path = require('path');
const gulp = require('gulp');
const gulpif = require('gulp-if');
const gutil = require('gulp-util');
const plumber = require('gulp-plumber');
const rename = require('gulp-rename');
const change = require('gulp-change');
const header = require('gulp-header');
const touch = require('gulp-touch-cmd');
const browserify = require('browserify');
const watchify = require('watchify');
const envify = require('envify/custom');
const uglify = require('gulp-uglify-es').default;
const source = require('vinyl-source-stream');
const buffer = require('vinyl-buffer');
const del = require('del');
const mkdirp = require('mkdirp');
const ncp = require('ncp');
const eslint = require('gulp-eslint');
const stylus = require('gulp-stylus');
const cssBase64 = require('gulp-css-base64');
const nib = require('nib');
const browserSync = require('browser-sync');
const PKG = require('./package.json');
const BANNER = fs.readFileSync('banner.txt').toString();
const BANNER_OPTIONS =
{
pkg : PKG,
currentYear : (new Date()).getFullYear()
};
const OUTPUT_DIR = '../server/public';
const appOptions = require('./config/config');
const SERVER_CONFIG = '../server/config/config';
// Set Node 'development' environment (unless externally set).
process.env.NODE_ENV = process.env.NODE_ENV || 'development';
gutil.log(`NODE_ENV: ${process.env.NODE_ENV}`);
function logError(error)
{
gutil.log(gutil.colors.red(error.stack));
}
function bundle(options)
{
options = options || {};
const watch = Boolean(options.watch);
let bundler = browserify(
{
entries : PKG.main,
extensions : [ '.js', '.jsx' ],
// required for sourcemaps (must be false otherwise).
debug : process.env.NODE_ENV === 'development',
// required for watchify.
cache : {},
// required for watchify.
packageCache : {},
// required to be true only for watchify.
fullPaths : watch
})
.transform('babelify')
.transform(envify(
{
NODE_ENV : process.env.NODE_ENV,
_ : 'purge'
}));
if (watch)
{
bundler = watchify(bundler);
bundler.on('update', () =>
{
const start = Date.now();
gutil.log('bundling...');
rebundle();
gutil.log('bundle took %sms', (Date.now() - start));
});
}
function rebundle()
{
return bundler.bundle()
.on('error', logError)
.pipe(plumber())
.pipe(source(`${PKG.name}.js`))
.pipe(buffer())
.pipe(rename(`${PKG.name}.js`))
.pipe(gulpif(process.env.NODE_ENV === 'production',
uglify()
))
.pipe(header(BANNER, BANNER_OPTIONS))
.pipe(gulp.dest(OUTPUT_DIR));
}
return rebundle();
}
function changeHTML(content)
{
return content.replace(/chromeExtension/g, appOptions.chromeExtension);
}
gulp.task('clean', () => del(OUTPUT_DIR, { force: true }));
const LINTING_FILES = [
'gulpfile.js',
'lib/**/*.js',
'lib/**/*.jsx'
];
gulp.task('lint', () =>
{
return gulp.src(LINTING_FILES)
.pipe(plumber())
.pipe(eslint())
.pipe(eslint.format());
});
gulp.task('lint-fix', function()
{
return gulp.src(LINTING_FILES)
.pipe(plumber())
.pipe(eslint({ fix: true }))
.pipe(eslint.format())
.pipe(gulp.dest((file) => file.base));
});
gulp.task('css', () =>
{
return gulp.src('stylus/index.styl')
.pipe(plumber())
.pipe(stylus(
{
use : nib(),
compress : process.env.NODE_ENV === 'production'
}))
.on('error', logError)
.pipe(cssBase64(
{
baseDir : '.',
maxWeightResource : 50000 // So big ttf fonts are not included, nice.
}))
.pipe(rename(`${PKG.name}.css`))
.pipe(gulp.dest(OUTPUT_DIR))
.pipe(touch());
});
gulp.task('html', () =>
{
return gulp.src('*.html')
.pipe(change(changeHTML))
.pipe(gulp.dest(OUTPUT_DIR));
});
gulp.task('resources', (done) =>
{
const dst = path.join(OUTPUT_DIR, 'resources');
mkdirp.sync(dst);
ncp('resources', dst, { stopOnErr: true }, (error) =>
{
if (error && error[0].code !== 'ENOENT')
throw new Error(`resources copy failed: ${error}`);
done();
});
});
gulp.task('bundle', () =>
{
return bundle({ watch: false });
});
gulp.task('bundle:watch', () =>
{
return bundle({ watch: true });
});
gulp.task('livebrowser', (done) =>
{
const config = require(SERVER_CONFIG);
browserSync(
{
open : 'external',
host : config.domain,
port : 3000,
server :
{
baseDir : OUTPUT_DIR
},
https : config.tls,
ghostMode : false,
files : path.join(OUTPUT_DIR, '**', '*')
});
done();
});
gulp.task('browser', (done) =>
{
const config = require(SERVER_CONFIG);
browserSync(
{
open : 'external',
host : config.domain,
port : 3000,
server :
{
baseDir : OUTPUT_DIR
},
https : config.tls,
ghostMode : false
});
done();
});
gulp.task('watch', (done) =>
{
// Watch changes in HTML.
gulp.watch([ '*.html' ], gulp.series(
'html'
));
// Watch changes in Stylus files.
gulp.watch([ 'stylus/**/*.styl' ], gulp.series(
'css'
));
// Watch changes in resources.
gulp.watch([ 'resources/**/*' ], gulp.series(
'resources', 'css'
));
// Watch changes in JS files.
gulp.watch([ 'gulpfile.js', 'lib/**/*.js', 'lib/**/*.jsx' ], gulp.series(
'lint'
));
done();
});
gulp.task('dist', gulp.series(
'clean',
'lint',
'bundle',
'html',
'css',
'resources'
));
gulp.task('dist-watch', gulp.series(
'clean',
'lint',
'bundle:watch',
'html',
'css',
'resources',
'watch',
));
gulp.task('live', gulp.series(
'clean',
'lint',
'bundle:watch',
'html',
'css',
'resources',
'watch',
'livebrowser'
));
gulp.task('open', gulp.series('browser'));
gulp.task('default', gulp.series('live'));

30
app/index.html Normal file
View file

@ -0,0 +1,30 @@
<!doctype html>
<html>
<head>
<title>Multiparty Meeting</title>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no'>
<meta name='description' content='multiparty meeting - Cutting Edge WebRTC Video Conferencing'>
<link rel='stylesheet' href='/multiparty-meeting.css'>
<link rel="chrome-webstore-item" href="chromeExtension">
<script src='/resources/js/antiglobal.js'></script>
<script>
window.localStorage.setItem('debug', '* -engine* -socket* -RIE* *WARN* *ERROR*');
if (window.antiglobal)
{
window.antiglobal('__multipartyMeetingScreenShareExtensionAvailable__', '___browserSync___oldSocketIo', 'io', '___browserSync___', '__core-js_shared__');
setInterval(window.antiglobal, 180000);
}
</script>
<script async src='/multiparty-meeting.js'></script>
</head>
<body>
<div id='multiparty-meeting'></div>
<div id='multiparty-meeting-media-query-detector'></div>
</body>
</html>

View file

@ -1,6 +1,6 @@
import debug from 'debug';
const APP_NAME = 'edumeet';
const APP_NAME = 'multiparty-meeting';
export default class Logger
{

1990
app/lib/RoomClient.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,59 +1,44 @@
import isElectron from 'is-electron';
let electron = null;
/**
* Check if window.require function exits
* because electron default is "nodeIntegration: false"
* and this case window.require is not a function.
* It caused issue with Rocket Chat electron client.
*
* TODO: do it more inteligently.
*/
if (isElectron() && typeof window.require === 'function')
electron = window.require('electron');
class ElectronScreenShare
class ChromeScreenShare
{
constructor()
{
this._stream = null;
}
start()
start(options = { })
{
return Promise.resolve()
.then(() =>
const state = this;
return new Promise((resolve, reject) =>
{
window.addEventListener('message', _onExtensionMessage, false);
window.postMessage({ type: 'getStreamId' }, '*');
function _onExtensionMessage({ data })
{
return electron.desktopCapturer.getSources({ types: [ 'window', 'screen' ] });
})
.then((sources) =>
{
for (const source of sources)
if (data.type !== 'gotStreamId')
{
// Currently only getting whole screen
if (source.name === 'Entire Screen')
{
return navigator.mediaDevices.getUserMedia({
audio : true,
video :
{
mandatory :
{
chromeMediaSource : 'desktop',
chromeMediaSourceId : source.id
}
}
});
}
return;
}
})
.then((stream) =>
{
this._stream = stream;
return stream;
});
const constraints = state._toConstraints(options, data.streamId);
navigator.mediaDevices.getUserMedia(constraints)
.then((stream) =>
{
window.removeEventListener('message', _onExtensionMessage);
state._stream = stream;
resolve(stream);
})
.catch((err) =>
{
window.removeEventListener('message', _onExtensionMessage);
reject(err);
});
}
});
}
stop()
@ -69,76 +54,60 @@ class ElectronScreenShare
isScreenShareAvailable()
{
return true;
}
}
class DisplayMediaScreenShare
{
constructor()
{
this._stream = null;
}
start(options = {})
{
const constraints = this._toConstraints(options);
return navigator.mediaDevices.getDisplayMedia(constraints)
.then((stream) =>
{
this._stream = stream;
return Promise.resolve(stream);
});
}
stop()
{
if (this._stream instanceof MediaStream === false)
if ('__multipartyMeetingScreenShareExtensionAvailable__' in window)
{
return;
return true;
}
this._stream.getTracks().forEach((track) => track.stop());
this._stream = null;
}
isScreenShareAvailable()
{
return true;
}
isAudioEnabled()
{
return false;
}
needExtension()
{
if ('__multipartyMeetingScreenShareExtensionAvailable__' in window)
{
return false;
}
_toConstraints(options)
return true;
}
_toConstraints(options, streamId)
{
const constraints = {
video : {},
audio : true
video : {
mandatory : {
chromeMediaSource : 'desktop',
chromeMediaSourceId : streamId
},
optional : [ {
googTemporalLayeredScreencast : true
} ]
},
audio : false
};
if (isFinite(options.width))
{
constraints.video.width = options.width;
constraints.video.mandatory.maxWidth = options.width;
constraints.video.mandatory.minWidth = options.width;
}
if (isFinite(options.height))
{
constraints.video.height = options.height;
constraints.video.mandatory.maxHeight = options.height;
constraints.video.mandatory.minHeight = options.height;
}
if (isFinite(options.frameRate))
{
constraints.video.frameRate = options.frameRate;
constraints.video.mandatory.maxFrameRate = options.frameRate;
constraints.video.mandatory.minFrameRate = options.frameRate;
}
return constraints;
}
}
class DisplayMediaScreenShareWithAudio
class Chrome72ScreenShare
{
constructor()
{
@ -174,39 +143,17 @@ class DisplayMediaScreenShareWithAudio
return true;
}
isAudioEnabled()
needExtension()
{
return true;
return false;
}
_toConstraints(options)
_toConstraints()
{
const constraints = {
video : {},
audio : {
sampleRate : options.sampleRate,
channelCount : options.channelCount,
volume : options.volume,
autoGainControl : options.autoGainControl,
echoCancellation : options.echoCancellation,
noiseSuppression : options.noiseSuppression,
sampleSize : options.sampleSize
}
video : true
};
if (isFinite(options.width))
{
constraints.video.width = options.width;
}
if (isFinite(options.height))
{
constraints.video.height = options.height;
}
if (isFinite(options.frameRate))
{
constraints.video.frameRate = options.frameRate;
}
return constraints;
}
}
@ -247,7 +194,7 @@ class FirefoxScreenShare
return true;
}
isAudioEnabled()
needExtension()
{
return false;
}
@ -291,6 +238,57 @@ class FirefoxScreenShare
}
}
class EdgeScreenShare
{
constructor()
{
this._stream = null;
}
start(options = {})
{
const constraints = this._toConstraints(options);
return navigator.getDisplayMedia(constraints)
.then((stream) =>
{
this._stream = stream;
return Promise.resolve(stream);
});
}
stop()
{
if (this._stream instanceof MediaStream === false)
{
return;
}
this._stream.getTracks().forEach((track) => track.stop());
this._stream = null;
}
isScreenShareAvailable()
{
return true;
}
needExtension()
{
return false;
}
_toConstraints()
{
const constraints = {
video : true
};
return constraints;
}
}
class DefaultScreenShare
{
isScreenShareAvailable()
@ -298,7 +296,7 @@ class DefaultScreenShare
return false;
}
isAudioEnabled()
needExtension()
{
return false;
}
@ -308,39 +306,26 @@ export default class ScreenShare
{
static create(device)
{
if (electron)
return new ElectronScreenShare();
else if (device.platform !== 'desktop')
return new DefaultScreenShare();
else
switch (device.flag)
{
switch (device.flag)
case 'firefox':
{
case 'firefox':
{
if (device.bowser.satisfies({ firefox: '<66' }))
return new FirefoxScreenShare();
else
return new DisplayMediaScreenShare();
}
case 'safari':
{
if (device.bowser.satisfies({ safari: '>=13' }))
return new DisplayMediaScreenShare();
else
return new DefaultScreenShare();
}
case 'opera':
case 'chrome':
case 'chromium':
case 'edge':
{
return new DisplayMediaScreenShareWithAudio();
}
default:
{
return new DefaultScreenShare();
}
return new FirefoxScreenShare();
}
case 'chrome':
{
if (device.version < 72.0)
return new ChromeScreenShare();
else
return new Chrome72ScreenShare();
}
case 'msedge':
{
return new EdgeScreenShare();
}
default:
{
return new DefaultScreenShare();
}
}
}

184
app/lib/Spotlights.js Normal file
View file

@ -0,0 +1,184 @@
import { EventEmitter } from 'events';
import Logger from './Logger';
const logger = new Logger('Spotlight');
export default class Spotlights extends EventEmitter
{
constructor(maxSpotlights, room)
{
super();
this._room = room;
this._maxSpotlights = maxSpotlights;
this._peerList = [];
this._selectedSpotlights = [];
this._currentSpotlights = [];
this._started = false;
}
start()
{
const peers = this._room.peers;
for (const peer of peers)
{
this._handlePeer(peer);
}
this._handleRoom();
this._started = true;
this._spotlightsUpdated();
}
peerInSpotlights(peerName)
{
if (this._started)
{
return this._currentSpotlights.indexOf(peerName) !== -1;
}
else
{
return false;
}
}
setPeerSpotlight(peerName)
{
logger.debug('setPeerSpotlight() [peerName:"%s"]', peerName);
const index = this._selectedSpotlights.indexOf(peerName);
if (index !== -1)
{
this._selectedSpotlights = [];
}
else
{
this._selectedSpotlights = [ peerName ];
}
/*
if (index === -1) // We don't have this peer in the list, adding
{
this._selectedSpotlights.push(peerName);
}
else // We have this peer, remove
{
this._selectedSpotlights.splice(index, 1);
}
*/
if (this._started)
this._spotlightsUpdated();
}
_handleRoom()
{
this._room.on('newpeer', (peer) =>
{
logger.debug(
'room "newpeer" event [name:"%s", peer:%o]', peer.name, peer);
this._handlePeer(peer);
});
}
addSpeakerList(speakerList)
{
this._peerList = [ ...new Set([ ...speakerList, ...this._peerList ]) ];
if (this._started)
this._spotlightsUpdated();
}
_handlePeer(peer)
{
logger.debug('_handlePeer() [peerName:"%s"]', peer.name);
if (this._peerList.indexOf(peer.name) === -1) // We don't have this peer in the list
{
peer.on('close', () =>
{
let index = this._peerList.indexOf(peer.name);
if (index !== -1) // We have this peer in the list, remove
{
this._peerList.splice(index, 1);
}
index = this._selectedSpotlights.indexOf(peer.name);
if (index !== -1) // We have this peer in the list, remove
{
this._selectedSpotlights.splice(index, 1);
}
this._spotlightsUpdated();
});
logger.debug('_handlePeer() | adding peer [peerName:"%s"]', peer.name);
this._peerList.push(peer.name);
this._spotlightsUpdated();
}
}
handleActiveSpeaker(peerName)
{
logger.debug('handleActiveSpeaker() [peerName:"%s"]', peerName);
const index = this._peerList.indexOf(peerName);
if (index > -1)
{
this._peerList.splice(index, 1);
this._peerList = [ peerName ].concat(this._peerList);
this._spotlightsUpdated();
}
}
_spotlightsUpdated()
{
let spotlights;
if (this._selectedSpotlights.length > 0)
{
spotlights = [ ...new Set([ ...this._selectedSpotlights, ...this._peerList ]) ];
}
else
{
spotlights = this._peerList;
}
if (
!this._arraysEqual(
this._currentSpotlights, spotlights.slice(0, this._maxSpotlights)
)
)
{
logger.debug('_spotlightsUpdated() | spotlights updated, emitting');
this._currentSpotlights = spotlights.slice(0, this._maxSpotlights);
this.emit('spotlights-updated', this._currentSpotlights);
}
else
logger.debug('_spotlightsUpdated() | spotlights not updated');
}
_arraysEqual(arr1, arr2)
{
if (arr1.length !== arr2.length)
return false;
for (let i = arr1.length; i--;)
{
if (arr1[i] !== arr2[i])
return false;
}
return true;
}
}

View file

@ -0,0 +1,78 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import * as stateActions from '../../redux/stateActions';
class HiddenPeers extends Component
{
constructor(props)
{
super(props);
this.state = { className: '' };
}
componentDidUpdate(prevProps)
{
const { hiddenPeersCount } = this.props;
if (hiddenPeersCount !== prevProps.hiddenPeersCount)
{
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ className: 'pulse' }, () =>
{
if (this.timeout)
{
clearTimeout(this.timeout);
}
this.timeout = setTimeout(() =>
{
this.setState({ className: '' });
}, 500);
});
}
}
render()
{
const {
hiddenPeersCount,
openUsersTab
} = this.props;
return (
<div data-component='HiddenPeers'>
<div className={classnames('view-container', this.state.className)} onClick={() => openUsersTab()}>
<p>+{hiddenPeersCount} <br /> participant
{(hiddenPeersCount === 1) ? null : 's'}
</p>
</div>
</div>
);
}
}
HiddenPeers.propTypes =
{
hiddenPeersCount : PropTypes.number,
openUsersTab : PropTypes.func.isRequired
};
const mapDispatchToProps = (dispatch) =>
{
return {
openUsersTab : () =>
{
dispatch(stateActions.openToolArea());
dispatch(stateActions.setToolTab('users'));
}
};
};
const HiddenPeersContainer = connect(
null,
mapDispatchToProps
)(HiddenPeers);
export default HiddenPeersContainer;

View file

@ -0,0 +1,240 @@
import React from 'react';
import { connect } from 'react-redux';
import ReactTooltip from 'react-tooltip';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { getDeviceInfo } from 'mediasoup-client';
import * as appPropTypes from '../appPropTypes';
import { withRoomContext } from '../../RoomContext';
import PeerView from '../VideoContainers/PeerView';
import ScreenView from '../VideoContainers/ScreenView';
class Me extends React.Component
{
state = {
controlsVisible : false
};
handleMouseOver = () =>
{
this.setState({
controlsVisible : true
});
};
handleMouseOut = () =>
{
this.setState({
controlsVisible : false
});
};
constructor(props)
{
super(props);
this._mounted = false;
this._rootNode = null;
this._tooltip = true;
// TODO: Issue when using react-tooltip in Edge:
// https://github.com/wwayne/react-tooltip/issues/328
if (getDeviceInfo().flag === 'msedge')
this._tooltip = false;
}
render()
{
const {
roomClient,
connected,
me,
advancedMode,
micProducer,
webcamProducer,
screenProducer
} = this.props;
let micState;
if (!me.canSendMic)
micState = 'unsupported';
else if (!micProducer)
micState = 'unsupported';
else if (!micProducer.locallyPaused && !micProducer.remotelyPaused)
micState = 'on';
else
micState = 'off';
let webcamState;
if (!me.canSendWebcam)
webcamState = 'unsupported';
else if (webcamProducer)
webcamState = 'on';
else
webcamState = 'off';
const videoVisible = (
Boolean(webcamProducer) &&
!webcamProducer.locallyPaused &&
!webcamProducer.remotelyPaused
);
const screenVisible = (
Boolean(screenProducer) &&
!screenProducer.locallyPaused &&
!screenProducer.remotelyPaused
);
let tip;
if (!me.displayNameSet)
tip = 'Click on your name to change it';
return (
<div
data-component='Me'
ref={(node) => (this._rootNode = node)}
data-tip={tip}
data-tip-disable={!tip}
data-type='dark'
onMouseOver={this.handleMouseOver}
onMouseOut={this.handleMouseOut}
>
<div className={classnames('view-container', 'webcam')}>
<If condition={connected}>
<div className={classnames('controls', 'visible')}>
<div
data-tip='keyboard shortcut: &lsquo;m&lsquo;'
data-type='dark'
data-place='bottom'
data-for='me'
className={classnames('button', 'mic', micState, {
disabled : me.audioInProgress,
visible : micState == 'off' || this.state.controlsVisible
})}
onClick={() =>
{
micState === 'on' ?
roomClient.muteMic() :
roomClient.unmuteMic();
}}
/>
<ReactTooltip
id='me'
effect='solid'
/>
<div
className={classnames('button', 'webcam', webcamState, {
disabled : me.webcamInProgress,
visible : webcamState == 'off' || this.state.controlsVisible
})}
onClick={() =>
{
webcamState === 'on' ?
roomClient.disableWebcam() :
roomClient.enableWebcam();
}}
/>
</div>
</If>
<PeerView
isMe
advancedMode={advancedMode}
peer={me}
audioTrack={micProducer ? micProducer.track : null}
volume={micProducer ? micProducer.volume : null}
videoTrack={webcamProducer ? webcamProducer.track : null}
videoVisible={videoVisible}
audioCodec={micProducer ? micProducer.codec : null}
videoCodec={webcamProducer ? webcamProducer.codec : null}
onChangeDisplayName={(displayName) =>
{
roomClient.changeDisplayName(displayName);
}}
/>
</div>
<If condition={screenProducer}>
<div className={classnames('view-container', 'screen')}>
<ScreenView
isMe
advancedMode={advancedMode}
screenTrack={screenProducer ? screenProducer.track : null}
screenVisible={screenVisible}
screenCodec={screenProducer ? screenProducer.codec : null}
/>
</div>
</If>
</div>
);
}
componentDidMount()
{
this._mounted = true;
if (this._tooltip)
{
setTimeout(() =>
{
if (!this._mounted || this.props.me.displayNameSet)
return;
ReactTooltip.show(this._rootNode);
}, 4000);
}
}
componentWillUnmount()
{
this._mounted = false;
}
componentWillReceiveProps(nextProps)
{
if (this._tooltip)
{
if (nextProps.me.displayNameSet)
ReactTooltip.hide(this._rootNode);
}
}
}
Me.propTypes =
{
roomClient : PropTypes.any.isRequired,
connected : PropTypes.bool.isRequired,
advancedMode : PropTypes.bool,
me : appPropTypes.Me.isRequired,
micProducer : appPropTypes.Producer,
webcamProducer : appPropTypes.Producer,
screenProducer : appPropTypes.Producer
};
const mapStateToProps = (state) =>
{
const producersArray = Object.values(state.producers);
const micProducer =
producersArray.find((producer) => producer.source === 'mic');
const webcamProducer =
producersArray.find((producer) => producer.source === 'webcam');
const screenProducer =
producersArray.find((producer) => producer.source === 'screen');
return {
connected : state.room.state === 'connected',
me : state.me,
micProducer : micProducer,
webcamProducer : webcamProducer,
screenProducer : screenProducer
};
};
const MeContainer = withRoomContext(connect(
mapStateToProps
)(Me));
export default MeContainer;

View file

@ -0,0 +1,266 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import * as appPropTypes from '../appPropTypes';
import { withRoomContext } from '../../RoomContext';
import * as stateActions from '../../redux/stateActions';
import PeerView from '../VideoContainers/PeerView';
import ScreenView from '../VideoContainers/ScreenView';
class Peer extends Component
{
state = {
controlsVisible : false
};
handleMouseOver = () =>
{
this.setState({
controlsVisible : true
});
};
handleMouseOut = () =>
{
this.setState({
controlsVisible : false
});
};
render()
{
const {
roomClient,
advancedMode,
peer,
micConsumer,
webcamConsumer,
screenConsumer,
toggleConsumerFullscreen,
toggleConsumerWindow,
style,
windowConsumer
} = this.props;
const micEnabled = (
Boolean(micConsumer) &&
!micConsumer.locallyPaused &&
!micConsumer.remotelyPaused
);
const videoVisible = (
Boolean(webcamConsumer) &&
!webcamConsumer.locallyPaused &&
!webcamConsumer.remotelyPaused
);
const screenVisible = (
Boolean(screenConsumer) &&
!screenConsumer.locallyPaused &&
!screenConsumer.remotelyPaused
);
let videoProfile;
if (webcamConsumer)
videoProfile = webcamConsumer.profile;
let screenProfile;
if (screenConsumer)
screenProfile = screenConsumer.profile;
return (
<div
data-component='Peer'
className={classnames({
screen : screenConsumer
})}
onMouseOver={this.handleMouseOver}
onMouseOut={this.handleMouseOut}
>
<If condition={videoVisible && !webcamConsumer.supported}>
<div className='incompatible-video'>
<p>incompatible video</p>
</div>
</If>
<If condition={!videoVisible}>
<div className='paused-video'>
<p>this video is paused</p>
</div>
</If>
<div className={classnames('view-container', 'webcam')} style={style}>
<div className='indicators'>
<If condition={peer.raiseHandState}>
<div className={
classnames(
'icon', 'raise-hand', {
on : peer.raiseHandState,
off : !peer.raiseHandState
}
)
}
/>
</If>
</div>
<div
className={classnames('controls', {
visible : this.state.controlsVisible
})}
>
<div
className={classnames('button', 'mic', {
on : micEnabled,
off : !micEnabled,
disabled : peer.peerAudioInProgress
})}
onClick={(e) =>
{
e.stopPropagation();
micEnabled ?
roomClient.modifyPeerConsumer(peer.name, 'mic', true) :
roomClient.modifyPeerConsumer(peer.name, 'mic', false);
}}
/>
<div
className={classnames('button', 'newwindow', {
disabled : !videoVisible ||
(windowConsumer === webcamConsumer.id)
})}
onClick={(e) =>
{
e.stopPropagation();
toggleConsumerWindow(webcamConsumer);
}}
/>
<div
className={classnames('button', 'fullscreen', {
disabled : !videoVisible
})}
onClick={(e) =>
{
e.stopPropagation();
toggleConsumerFullscreen(webcamConsumer);
}}
/>
</div>
<PeerView
advancedMode={advancedMode}
peer={peer}
volume={micConsumer ? micConsumer.volume : null}
videoTrack={webcamConsumer ? webcamConsumer.track : null}
videoVisible={videoVisible}
videoProfile={videoProfile}
audioCodec={micConsumer ? micConsumer.codec : null}
videoCodec={webcamConsumer ? webcamConsumer.codec : null}
/>
</div>
<If condition={screenConsumer}>
<div className={classnames('view-container', 'screen')} style={style}>
<div
className={classnames('controls', {
visible : this.state.controlsVisible
})}
>
<div
className={classnames('button', 'newwindow', {
disabled : !screenVisible ||
(windowConsumer === screenConsumer.id)
})}
onClick={(e) =>
{
e.stopPropagation();
toggleConsumerWindow(screenConsumer);
}}
/>
<div
className={classnames('button', 'fullscreen', {
disabled : !screenVisible
})}
onClick={(e) =>
{
e.stopPropagation();
toggleConsumerFullscreen(screenConsumer);
}}
/>
</div>
<ScreenView
advancedMode={advancedMode}
screenTrack={screenConsumer ? screenConsumer.track : null}
screenVisible={screenVisible}
screenProfile={screenProfile}
screenCodec={screenConsumer ? screenConsumer.codec : null}
/>
</div>
</If>
</div>
);
}
}
Peer.propTypes =
{
roomClient : PropTypes.any.isRequired,
advancedMode : PropTypes.bool,
peer : appPropTypes.Peer.isRequired,
micConsumer : appPropTypes.Consumer,
webcamConsumer : appPropTypes.Consumer,
screenConsumer : appPropTypes.Consumer,
windowConsumer : PropTypes.number,
streamDimensions : PropTypes.object,
style : PropTypes.object,
toggleConsumerFullscreen : PropTypes.func.isRequired,
toggleConsumerWindow : PropTypes.func.isRequired
};
const mapStateToProps = (state, { name }) =>
{
const peer = state.peers[name];
const consumersArray = peer.consumers
.map((consumerId) => state.consumers[consumerId]);
const micConsumer =
consumersArray.find((consumer) => consumer.source === 'mic');
const webcamConsumer =
consumersArray.find((consumer) => consumer.source === 'webcam');
const screenConsumer =
consumersArray.find((consumer) => consumer.source === 'screen');
return {
peer,
micConsumer,
webcamConsumer,
screenConsumer,
windowConsumer : state.room.windowConsumer
};
};
const mapDispatchToProps = (dispatch) =>
{
return {
toggleConsumerFullscreen : (consumer) =>
{
if (consumer)
dispatch(stateActions.toggleConsumerFullscreen(consumer.id));
},
toggleConsumerWindow : (consumer) =>
{
if (consumer)
dispatch(stateActions.toggleConsumerWindow(consumer.id));
}
};
};
const PeerContainer = withRoomContext(connect(
mapStateToProps,
mapDispatchToProps
)(Peer));
export default PeerContainer;

View file

@ -0,0 +1,232 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import classnames from 'classnames';
import * as appPropTypes from '../appPropTypes';
import { withRoomContext } from '../../RoomContext';
import FullScreen from '../FullScreen';
class Sidebar extends Component
{
constructor(props)
{
super(props);
this.fullscreen = new FullScreen(document);
this.state = {
fullscreen : false
};
}
handleToggleFullscreen = () =>
{
if (this.fullscreen.fullscreenElement)
{
this.fullscreen.exitFullscreen();
}
else
{
this.fullscreen.requestFullscreen(document.documentElement);
}
};
handleFullscreenChange = () =>
{
this.setState({
fullscreen : this.fullscreen.fullscreenElement !== null
});
};
componentDidMount()
{
if (this.fullscreen.fullscreenEnabled)
{
this.fullscreen.addEventListener('fullscreenchange', this.handleFullscreenChange);
}
}
componentWillUnmount()
{
if (this.fullscreen.fullscreenEnabled)
{
this.fullscreen.removeEventListener('fullscreenchange', this.handleFullscreenChange);
}
}
render()
{
const {
roomClient,
toolbarsVisible,
me,
screenProducer,
locked
} = this.props;
let screenState;
let screenTip;
let lockState = 'unlocked';
if (me.needExtension)
{
screenState = 'need-extension';
screenTip = 'Install screen sharing extension';
}
else if (!me.canShareScreen)
{
screenState = 'unsupported';
screenTip = 'Screen sharing not supported';
}
else if (screenProducer)
{
screenState = 'on';
screenTip = 'Stop screen sharing';
}
else
{
screenState = 'off';
screenTip = 'Start screen sharing';
}
if (locked)
{
lockState = 'locked';
}
return (
<div
className={classnames('sidebar room-controls', {
'visible' : toolbarsVisible
})}
data-component='Sidebar'
>
<If condition={this.fullscreen.fullscreenEnabled}>
<div
className={classnames('button', 'fullscreen', {
on : this.state.fullscreen
})}
onClick={this.handleToggleFullscreen}
data-tip='Fullscreen'
data-place='right'
data-type='dark'
/>
</If>
<div
className={classnames('button', 'screen', screenState)}
data-tip={screenTip}
data-place='right'
data-type='dark'
onClick={() =>
{
switch (screenState)
{
case 'on':
{
roomClient.disableScreenSharing();
break;
}
case 'off':
{
roomClient.enableScreenSharing();
break;
}
case 'need-extension':
{
roomClient.installExtension();
break;
}
default:
{
break;
}
}
}}
/>
<If condition={me.loginEnabled}>
<Choose>
<When condition={me.loggedIn}>
<div
className='button logout'
data-tip='Logout'
data-place='right'
data-type='dark'
onClick={() => roomClient.logout()}
>
<img src={me.picture || 'resources/images/avatar-empty.jpeg'} />
</div>
</When>
<Otherwise>
<div
className='button login off'
data-tip='Login'
data-place='right'
data-type='dark'
onClick={() => roomClient.login()}
/>
</Otherwise>
</Choose>
</If>
<div
className={classnames('button', 'lock', lockState, {
on : locked
})}
data-tip={`Room is ${lockState}`}
data-place='right'
data-type='dark'
onClick={() =>
{
if (locked)
{
roomClient.unlockRoom();
}
else
{
roomClient.lockRoom();
}
}}
/>
<div
className={classnames('button', 'raise-hand', {
on : me.raiseHand,
disabled : me.raiseHandInProgress
})}
data-tip='Raise hand'
data-place='right'
data-type='dark'
onClick={() => roomClient.sendRaiseHandState(!me.raiseHand)}
/>
<div
className={classnames('button', 'leave-meeting')}
data-tip='Leave meeting'
data-place='right'
data-type='dark'
onClick={() => roomClient.close()}
/>
</div>
);
}
}
Sidebar.propTypes = {
roomClient : PropTypes.any.isRequired,
toolbarsVisible : PropTypes.bool.isRequired,
me : appPropTypes.Me.isRequired,
screenProducer : appPropTypes.Producer,
locked : PropTypes.bool.isRequired
};
const mapStateToProps = (state) =>
({
toolbarsVisible : state.room.toolbarsVisible,
screenProducer : Object.values(state.producers)
.find((producer) => producer.source === 'screen'),
me : state.me,
locked : state.room.locked
});
export default withRoomContext(connect(
mapStateToProps
)(Sidebar));

View file

@ -55,7 +55,6 @@ export default class FullScreen
requestFullscreenFunction(element)
{
// eslint-disable-next-line
element[this.vendor[key.requestFullscreen]];
}

View file

@ -0,0 +1,216 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import ResizeObserver from 'resize-observer-polyfill';
import { connect } from 'react-redux';
import debounce from 'lodash/debounce';
import classnames from 'classnames';
import { withRoomContext } from '../../RoomContext';
import Peer from '../Containers/Peer';
import HiddenPeers from '../Containers/HiddenPeers';
class Filmstrip extends Component
{
constructor(props)
{
super(props);
this.activePeerContainer = React.createRef();
}
state = {
lastSpeaker : null,
width : 400
};
// Find the name of the peer which is currently speaking. This is either
// the latest active speaker, or the manually selected peer, or, if no
// person has spoken yet, the first peer in the list of peers.
getActivePeerName = () =>
{
if (this.props.selectedPeerName)
{
return this.props.selectedPeerName;
}
if (this.state.lastSpeaker)
{
return this.state.lastSpeaker;
}
const peerNames = Object.keys(this.props.peers);
if (peerNames.length > 0)
{
return peerNames[0];
}
};
isSharingCamera = (peerName) => this.props.peers[peerName] &&
this.props.peers[peerName].consumers.some((consumer) =>
this.props.consumers[consumer].source === 'screen');
getRatio = () =>
{
let ratio = 4 / 3;
if (this.isSharingCamera(this.getActivePeerName()))
{
ratio *= 2;
}
return ratio;
};
updateDimensions = debounce(() =>
{
const container = this.activePeerContainer.current;
if (container)
{
const ratio = this.getRatio();
let width = container.clientWidth;
if (width / ratio > container.clientHeight)
{
width = container.clientHeight * ratio;
}
this.setState({
width
});
}
}, 200);
componentDidMount()
{
window.addEventListener('resize', this.updateDimensions);
const observer = new ResizeObserver(this.updateDimensions);
observer.observe(this.activePeerContainer.current);
this.updateDimensions();
}
componentWillUnmount()
{
window.removeEventListener('resize', this.updateDimensions);
}
componentDidUpdate(prevProps)
{
if (prevProps !== this.props)
{
this.updateDimensions();
if (this.props.activeSpeakerName !== this.props.myName)
{
// eslint-disable-next-line react/no-did-update-set-state
this.setState({
lastSpeaker : this.props.activeSpeakerName
});
}
}
}
render()
{
const {
roomClient,
peers,
advancedMode,
spotlights,
spotlightsLength
} = this.props;
const activePeerName = this.getActivePeerName();
return (
<div data-component='Filmstrip'>
<div className='active-peer-container' ref={this.activePeerContainer}>
<If condition={peers[activePeerName]}>
<div
className='active-peer'
style={{
width : this.state.width,
height : this.state.width / this.getRatio()
}}
>
<Peer
advancedMode={advancedMode}
name={activePeerName}
/>
</div>
</If>
</div>
<div className='filmstrip'>
<div className='filmstrip-content'>
{ Object.keys(peers).map((peerName) =>
{
if (spotlights.find((spotlightsElement) => spotlightsElement === peerName))
{
return (
<div
key={peerName}
onClick={() => roomClient.setSelectedPeer(peerName)}
className={classnames('film', {
selected : this.props.selectedPeerName === peerName,
active : this.state.lastSpeaker === peerName
})}
>
<div className='film-content'>
<Peer
advancedMode={advancedMode}
name={peerName}
/>
</div>
</div>
);
}
})}
</div>
</div>
<div className='hidden-peer-container'>
<If condition={(spotlightsLength<Object.keys(peers).length)}>
<HiddenPeers
hiddenPeersCount={Object.keys(peers).length-spotlightsLength}
/>
</If>
</div>
</div>
);
}
}
Filmstrip.propTypes = {
roomClient : PropTypes.any.isRequired,
activeSpeakerName : PropTypes.string,
advancedMode : PropTypes.bool,
peers : PropTypes.object.isRequired,
consumers : PropTypes.object.isRequired,
myName : PropTypes.string.isRequired,
selectedPeerName : PropTypes.string,
spotlightsLength : PropTypes.number,
spotlights : PropTypes.array.isRequired
};
const mapStateToProps = (state) =>
{
const spotlightsLength = state.room.spotlights ? state.room.spotlights.length : 0;
return {
activeSpeakerName : state.room.activeSpeakerName,
selectedPeerName : state.room.selectedPeerName,
peers : state.peers,
consumers : state.consumers,
myName : state.me.name,
spotlights : state.room.spotlights,
spotlightsLength
};
};
export default withRoomContext(connect(
mapStateToProps,
undefined
)(Filmstrip));

View file

@ -0,0 +1,175 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import debounce from 'lodash/debounce';
import { Appear } from '../transitions';
import Peer from '../Containers/Peer';
import HiddenPeers from '../Containers/HiddenPeers';
import ResizeObserver from 'resize-observer-polyfill';
const RATIO = 1.334;
class Peers extends React.Component
{
constructor(props)
{
super(props);
this.state = {
peerWidth : 400,
peerHeight : 300
};
this.peersRef = React.createRef();
}
updateDimensions = debounce(() =>
{
if (!this.peersRef.current)
{
return;
}
const n = this.props.boxes;
if (n === 0)
{
return;
}
const width = this.peersRef.current.clientWidth;
const height = this.peersRef.current.clientHeight;
let x, y, space;
for (let rows = 1; rows < 100; rows = rows + 1)
{
x = width / Math.ceil(n / rows);
y = x / RATIO;
if (height < (y * rows))
{
y = height / rows;
x = RATIO * y;
break;
}
space = height - (y * (rows));
if (space < y)
{
break;
}
}
if (Math.ceil(this.state.peerWidth) !== Math.ceil(0.9 * x))
{
this.setState({
peerWidth : 0.9 * x,
peerHeight : 0.9 * y
});
}
}, 200);
componentDidMount()
{
window.addEventListener('resize', this.updateDimensions);
const observer = new ResizeObserver(this.updateDimensions);
observer.observe(this.peersRef.current);
}
componentWillUnmount()
{
window.removeEventListener('resize', this.updateDimensions);
}
componentDidUpdate()
{
this.updateDimensions();
}
render()
{
const {
advancedMode,
activeSpeakerName,
peers,
spotlights,
spotlightsLength
} = this.props;
const style =
{
'width' : this.state.peerWidth,
'height' : this.state.peerHeight
};
return (
<div data-component='Peers' ref={this.peersRef}>
{ Object.keys(peers).map((peerName) =>
{
if (spotlights.find((spotlightsElement) => spotlightsElement === peerName))
{
return (
<Appear key={peerName} duration={1000}>
<div
className={classnames('peer-container', {
'selected' : this.props.selectedPeerName === peerName,
'active-speaker' : peerName === activeSpeakerName
})}
>
<div className='peer-content'>
<Peer
advancedMode={advancedMode}
name={peerName}
style={style}
/>
</div>
</div>
</Appear>
);
}
})}
<div className='hidden-peer-container'>
<If condition={spotlightsLength < Object.keys(peers).length}>
<HiddenPeers
hiddenPeersCount={Object.keys(peers).length - spotlightsLength}
/>
</If>
</div>
</div>
);
}
}
Peers.propTypes =
{
advancedMode : PropTypes.bool,
peers : PropTypes.object.isRequired,
boxes : PropTypes.number,
activeSpeakerName : PropTypes.string,
selectedPeerName : PropTypes.string,
spotlightsLength : PropTypes.number,
spotlights : PropTypes.array.isRequired
};
const mapStateToProps = (state) =>
{
const spotlights = state.room.spotlights;
const spotlightsLength = spotlights ? state.room.spotlights.length : 0;
const boxes = spotlightsLength + Object.values(state.consumers)
.filter((consumer) => consumer.source === 'screen').length;
return {
peers : state.peers,
boxes,
activeSpeakerName : state.room.activeSpeakerName,
selectedPeerName : state.room.selectedPeerName,
spotlights,
spotlightsLength
};
};
const PeersContainer = connect(
mapStateToProps
)(Peers);
export default PeersContainer;

View file

@ -0,0 +1,70 @@
import React from 'react';
import { connect } from 'react-redux';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import * as appPropTypes from './appPropTypes';
import * as stateActions from '../redux/stateActions';
import { Appear } from './transitions';
const Notifications = ({ notifications, onClick, toolAreaOpen }) =>
{
return (
<div
data-component='Notifications'
className={classnames({
'toolarea-open' : toolAreaOpen
})}
>
{
notifications.map((notification) =>
{
return (
<Appear key={notification.id} duration={250}>
<div
className={classnames('notification', notification.type)}
onClick={() => onClick(notification.id)}
>
<div className='icon' />
<p className='text'>{notification.text}</p>
</div>
</Appear>
);
})
}
</div>
);
};
Notifications.propTypes =
{
notifications : PropTypes.arrayOf(appPropTypes.Notification).isRequired,
onClick : PropTypes.func.isRequired,
toolAreaOpen : PropTypes.bool
};
const mapStateToProps = (state) =>
{
const { notifications } = state;
return {
notifications,
toolAreaOpen : state.toolarea.toolAreaOpen
};
};
const mapDispatchToProps = (dispatch) =>
{
return {
onClick : (notificationId) =>
{
dispatch(stateActions.removeNotification(notificationId));
}
};
};
const NotificationsContainer = connect(
mapStateToProps,
mapDispatchToProps
)(Notifications);
export default NotificationsContainer;

View file

@ -0,0 +1,39 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import * as appPropTypes from '../appPropTypes';
import PeerAudio from './PeerAudio';
const AudioPeer = ({ micConsumer }) =>
{
return (
<PeerAudio
audioTrack={micConsumer ? micConsumer.track : null}
/>
);
};
AudioPeer.propTypes =
{
micConsumer : appPropTypes.Consumer,
name : PropTypes.string
};
const mapStateToProps = (state, { name }) =>
{
const peer = state.peers[name];
const consumersArray = peer.consumers
.map((consumerId) => state.consumers[consumerId]);
const micConsumer =
consumersArray.find((consumer) => consumer.source === 'mic');
return {
micConsumer
};
};
const AudioPeerContainer = connect(
mapStateToProps
)(AudioPeer);
export default AudioPeerContainer;

View file

@ -0,0 +1,44 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import * as appPropTypes from '../appPropTypes';
import AudioPeer from './AudioPeer';
const AudioPeers = ({ peers }) =>
{
return (
<div data-component='AudioPeers'>
{
peers.map((peer) =>
{
return (
<AudioPeer
key={peer.name}
name={peer.name}
/>
);
})
}
</div>
);
};
AudioPeers.propTypes =
{
peers : PropTypes.arrayOf(appPropTypes.Peer).isRequired
};
const mapStateToProps = (state) =>
{
const peers = Object.values(state.peers);
return {
peers
};
};
const AudioPeersContainer = connect(
mapStateToProps
)(AudioPeers);
export default AudioPeersContainer;

View file

@ -0,0 +1,67 @@
import React from 'react';
import PropTypes from 'prop-types';
export default class PeerAudio extends React.Component
{
constructor(props)
{
super(props);
// Latest received audio track.
// @type {MediaStreamTrack}
this._audioTrack = null;
}
render()
{
return (
<audio
ref='audio'
autoPlay
/>
);
}
componentDidMount()
{
const { audioTrack } = this.props;
this._setTrack(audioTrack);
}
componentWillReceiveProps(nextProps)
{
const { audioTrack } = nextProps;
this._setTrack(audioTrack);
}
_setTrack(audioTrack)
{
if (this._audioTrack === audioTrack)
return;
this._audioTrack = audioTrack;
const { audio } = this.refs;
if (audioTrack)
{
const stream = new MediaStream;
if (audioTrack)
stream.addTrack(audioTrack);
audio.srcObject = stream;
}
else
{
audio.srcObject = null;
}
}
}
PeerAudio.propTypes =
{
audioTrack : PropTypes.any
};

263
app/lib/components/Room.jsx Normal file
View file

@ -0,0 +1,263 @@
import React, { Fragment } from 'react';
import { connect } from 'react-redux';
import { withRoomContext } from '../RoomContext';
import ReactTooltip from 'react-tooltip';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import CopyToClipboard from 'react-copy-to-clipboard';
import CookieConsent from 'react-cookie-consent';
import * as appPropTypes from './appPropTypes';
import * as requestActions from '../redux/requestActions';
import * as stateActions from '../redux/stateActions';
import { Appear } from './transitions';
import Me from './Containers/Me';
import Peers from './Layouts/Peers';
import AudioPeers from './PeerAudio/AudioPeers';
import Notifications from './Notifications';
import ToolArea from './ToolArea/ToolArea';
import FullScreenView from './VideoContainers/FullScreenView';
import VideoWindow from './VideoWindow/VideoWindow';
import Draggable from 'react-draggable';
import { idle } from '../utils';
import Sidebar from './Controls/Sidebar';
import Filmstrip from './Layouts/Filmstrip';
// Hide toolbars after 10 seconds of inactivity.
const TIMEOUT = 10 * 1000;
class Room extends React.Component
{
/**
* Hides the different toolbars on the page after a
* given amount of time has passed since the
* last time the cursor was moved.
*/
waitForHide = idle(() =>
{
this.props.setToolbarsVisible(false);
}, TIMEOUT);
handleMovement = () =>
{
// If the toolbars were hidden, show them again when
// the user moves their cursor.
if (!this.props.room.toolbarsVisible)
{
this.props.setToolbarsVisible(true);
}
this.waitForHide();
}
componentDidMount()
{
window.addEventListener('mousemove', this.handleMovement);
window.addEventListener('touchstart', this.handleMovement);
}
componentWillUnmount()
{
window.removeEventListener('mousemove', this.handleMovement);
window.removeEventListener('touchstart', this.handleMovement);
}
render()
{
const {
roomClient,
room,
amActiveSpeaker,
onRoomLinkCopy
} = this.props;
const View = {
filmstrip : Filmstrip,
democratic : Peers
}[room.mode];
if (room.audioSuspended)
{
return (
<Fragment>
<Appear duration={300}>
<div data-component='Room'>
<div className='sound-suspended'>
This webpage required sound and video to play, please click to allow.
<div
onClick={() =>
{
roomClient.notify('Joining.');
roomClient.resumeAudio();
}}
className='button'
>
<span>Allow</span>
</div>
</div>
</div>
</Appear>
</Fragment>
);
}
else if (room.lockedOut)
{
return (
<Fragment>
<Appear duration={300}>
<div data-component='Room'>
<div className='locked-out'>
This room is locked at the moment, try again later.
</div>
</div>
</Appear>
</Fragment>
);
}
else
{
return (
<Fragment>
<Appear duration={300}>
<div data-component='Room'>
<CookieConsent>
This website uses cookies to enhance the user experience.
</CookieConsent>
<FullScreenView advancedMode={room.advancedMode} />
<VideoWindow advancedMode={room.advancedMode} />
<div className='room-wrapper'>
<div data-component='Logo' />
<AudioPeers />
<Notifications />
<If condition={room.advancedMode}>
<div className='state' data-tip='Server status'>
<div className={classnames('icon', room.state)} />
<p className={classnames('text', room.state)}>{room.state}</p>
</div>
</If>
<div
className={classnames('room-link-wrapper room-controls', {
'visible' : this.props.room.toolbarsVisible
})}
>
<div className='room-link'>
<CopyToClipboard
text={room.url}
onCopy={onRoomLinkCopy}
>
<a
className='link'
href={room.url}
target='_blank'
data-tip='Click to copy room link'
rel='noopener noreferrer'
onClick={(event) =>
{
// If this is a 'Open in new window/tab' don't prevent
// click default action.
if (
event.ctrlKey || event.shiftKey || event.metaKey ||
// Middle click (IE > 9 and everyone else).
(event.button && event.button === 1)
)
{
return;
}
event.preventDefault();
}}
>
invitation link
</a>
</CopyToClipboard>
</div>
</div>
<View advancedMode={room.advancedMode} />
<Draggable handle='.me-container' bounds='body' cancel='.display-name'>
<div
className={classnames('me-container', {
'active-speaker' : amActiveSpeaker
})}
>
<Me
advancedMode={room.advancedMode}
/>
</div>
</Draggable>
<Sidebar />
<ReactTooltip
effect='solid'
delayShow={100}
delayHide={100}
/>
</div>
<ToolArea />
</div>
</Appear>
</Fragment>
);
}
}
}
Room.propTypes =
{
roomClient : PropTypes.object.isRequired,
room : appPropTypes.Room.isRequired,
me : appPropTypes.Me.isRequired,
amActiveSpeaker : PropTypes.bool.isRequired,
toolAreaOpen : PropTypes.bool.isRequired,
screenProducer : appPropTypes.Producer,
onRoomLinkCopy : PropTypes.func.isRequired,
setToolbarsVisible : PropTypes.func.isRequired
};
const mapStateToProps = (state) =>
{
const producersArray = Object.values(state.producers);
const screenProducer =
producersArray.find((producer) => producer.source === 'screen');
return {
room : state.room,
me : state.me,
toolAreaOpen : state.toolarea.toolAreaOpen,
amActiveSpeaker : state.me.name === state.room.activeSpeakerName,
screenProducer : screenProducer
};
};
const mapDispatchToProps = (dispatch) =>
{
return {
onRoomLinkCopy : () =>
{
dispatch(requestActions.notify(
{
text : 'Room link copied to the clipboard'
}));
},
setToolbarsVisible : (visible) =>
{
dispatch(stateActions.setToolbarsVisible(visible));
}
};
};
const RoomContainer = withRoomContext(connect(
mapStateToProps,
mapDispatchToProps
)(Room));
export default RoomContainer;

View file

@ -0,0 +1,97 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { withRoomContext } from '../../../RoomContext';
import MessageList from './MessageList';
class Chat extends Component
{
createNewMessage(text, sender, name, picture)
{
return {
type : 'message',
text,
time : Date.now(),
name,
sender,
picture
};
}
render()
{
const {
roomClient,
senderPlaceHolder,
autofocus,
displayName,
picture
} = this.props;
return (
<div data-component='Chat'>
<MessageList />
<form
data-component='Sender'
onSubmit={(e) =>
{
e.preventDefault();
const userInput = e.target.message.value;
if (userInput)
{
const message = this.createNewMessage(userInput, 'response', displayName, picture);
roomClient.sendChatMessage(message);
}
e.target.message.value = '';
}}
>
<input
type='text'
className='new-message'
name='message'
placeholder={senderPlaceHolder}
autoFocus={autofocus}
autoComplete='off'
/>
<input
type='submit'
className='send'
value='Send'
/>
</form>
</div>
);
}
}
Chat.propTypes =
{
roomClient : PropTypes.any.isRequired,
senderPlaceHolder : PropTypes.string,
autofocus : PropTypes.bool,
displayName : PropTypes.string,
picture : PropTypes.string
};
Chat.defaultProps =
{
senderPlaceHolder : 'Type a message...',
autofocus : false,
displayName : null
};
const mapStateToProps = (state) =>
{
return {
displayName : state.me.displayName,
picture : state.me.picture
};
};
const ChatContainer = withRoomContext(connect(
mapStateToProps
)(Chat));
export default ChatContainer;

View file

@ -0,0 +1,98 @@
import React, { Component } from 'react';
import { compose } from 'redux';
import PropTypes from 'prop-types';
import marked from 'marked';
import { connect } from 'react-redux';
import scrollToBottom from '../scrollToBottom';
const linkRenderer = new marked.Renderer();
linkRenderer.link = (href, title, text) =>
{
title = title ? title : href;
text = text ? text : href;
return (`<a target='_blank' href='${ href }' title='${ title }'>${ text }</a>`);
};
class MessageList extends Component
{
getTimeString(time)
{
return `${(time.getHours() < 10 ? '0' : '')}${time.getHours()}:${(time.getMinutes() < 10 ? '0' : '')}${time.getMinutes()}`;
}
render()
{
const {
chatmessages
} = this.props;
return (
<div data-component='MessageList' id='messages'>
<Choose>
<When condition={chatmessages.length > 0}>
{
chatmessages.map((message, i) =>
{
const messageTime = new Date(message.time);
const picture = (message.sender === 'response' ?
message.picture : this.props.myPicture) || 'resources/images/avatar-empty.jpeg';
return (
<div className='message' key={i}>
<div className={message.sender}>
<img className='message-avatar' src={picture} />
<div className='message-content'>
<div
className='message-text'
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html : marked.parse(
message.text,
{ sanitize: true, renderer: linkRenderer }
) }}
/>
<span className='message-time'>
{message.name} - {this.getTimeString(messageTime)}
</span>
</div>
</div>
</div>
);
})
}
</When>
<Otherwise>
<div className='empty'>
<p>No one has said anything yet...</p>
</div>
</Otherwise>
</Choose>
</div>
);
}
}
MessageList.propTypes =
{
chatmessages : PropTypes.arrayOf(PropTypes.object).isRequired,
myPicture : PropTypes.string
};
const mapStateToProps = (state) =>
{
return {
chatmessages : state.chatmessages,
myPicture : state.me.picture
};
};
const MessageListContainer = compose(
connect(mapStateToProps),
scrollToBottom()
)(MessageList);
export default MessageListContainer;

View file

@ -0,0 +1,18 @@
import React from 'react';
import WebTorrent from 'webtorrent';
import dragDrop from 'drag-drop';
import { shareFiles } from './index';
export const configureDragDrop = () =>
{
if (WebTorrent.WEBRTC_SUPPORT)
{
dragDrop('body', async (files) => await shareFiles(files));
}
};
export const HoldingOverlay = () => (
<div id='holding-overlay'>
Drop files here to share them
</div>
);

View file

@ -0,0 +1,113 @@
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { withRoomContext } from '../../../RoomContext';
import magnet from 'magnet-uri';
const DEFAULT_PICTURE = 'resources/images/avatar-empty.jpeg';
class File extends Component
{
render()
{
const {
roomClient,
torrentSupport,
file
} = this.props;
return (
<div className='file-entry'>
<img className='file-avatar' src={file.picture || DEFAULT_PICTURE} />
<div className='file-content'>
<Choose>
<When condition={file.me}>
<p>You shared a file.</p>
</When>
<Otherwise>
<p>{file.displayName} shared a file.</p>
</Otherwise>
</Choose>
<If condition={!file.active && !file.files}>
<div className='file-info'>
<Choose>
<When condition={torrentSupport}>
<span
className='button'
onClick={() =>
{
roomClient.handleDownload(file.magnetUri);
}}
>
<img src='resources/images/download-icon.svg' />
</span>
</When>
<Otherwise>
<p>
Your browser does not support downloading files using WebTorrent.
</p>
</Otherwise>
</Choose>
<p>{magnet.decode(file.magnetUri).dn}</p>
</div>
</If>
<If condition={file.timeout}>
<Fragment>
<p>
If this process takes a long time, there might not be anyone seeding
this torrent. Try asking someone to reupload the file that you want.
</p>
</Fragment>
</If>
<If condition={file.active}>
<progress value={file.progress} />
</If>
<If condition={file.files}>
<Fragment>
<p>File finished downloading.</p>
{file.files.map((sharedFile, i) => (
<div className='file-info' key={i}>
<span
className='button'
onClick={() =>
{
roomClient.saveFile(sharedFile);
}}
>
<img src='resources/images/save-icon.svg' />
</span>
<p>{sharedFile.name}</p>
</div>
))}
</Fragment>
</If>
</div>
</div>
);
}
}
File.propTypes = {
roomClient : PropTypes.object.isRequired,
torrentSupport : PropTypes.bool.isRequired,
file : PropTypes.object.isRequired
};
const mapStateToProps = (state, { magnetUri }) =>
{
return {
file : state.files[magnetUri],
torrentSupport : state.room.torrentSupport
};
};
export default withRoomContext(connect(
mapStateToProps
)(File));

View file

@ -0,0 +1,40 @@
import React, { Component } from 'react';
import { compose } from 'redux';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import scrollToBottom from '../scrollToBottom';
import File from './File';
class FileList extends Component
{
render()
{
const {
files
} = this.props;
return (
<div className='shared-files'>
{ Object.keys(files).map((magnetUri) =>
<File key={magnetUri} magnetUri={magnetUri} />
)}
</div>
);
}
}
FileList.propTypes = {
files : PropTypes.object.isRequired
};
const mapStateToProps = (state) =>
{
return {
files : state.files
};
};
export default compose(
connect(mapStateToProps),
scrollToBottom()
)(FileList);

View file

@ -0,0 +1,88 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { withRoomContext } from '../../../RoomContext';
import FileList from './FileList';
class FileSharing extends Component
{
constructor(props)
{
super(props);
this._fileInput = React.createRef();
}
handleFileChange = async (event) =>
{
if (event.target.files.length > 0)
{
this.props.roomClient.shareFiles(event.target.files);
}
};
handleClick = () =>
{
if (this.props.torrentSupport)
{
// We want to open the file dialog when we click a button
// instead of actually rendering the input element itself.
this._fileInput.current.click();
}
};
render()
{
const {
torrentSupport
} = this.props;
const buttonDescription = torrentSupport ?
'Share file' : 'File sharing not supported';
return (
<div data-component='FileSharing'>
<div className='sharing-toolbar'>
<input
style={{ display: 'none' }}
ref={this._fileInput}
type='file'
onChange={this.handleFileChange}
multiple
/>
<div
type='button'
onClick={this.handleClick}
className={classNames('share-file', {
disabled : !torrentSupport
})}
>
<span>{buttonDescription}</span>
</div>
</div>
<FileList />
</div>
);
}
}
FileSharing.propTypes = {
roomClient : PropTypes.any.isRequired,
torrentSupport : PropTypes.bool.isRequired,
tabOpen : PropTypes.bool.isRequired
};
const mapStateToProps = (state) =>
{
return {
torrentSupport : state.room.torrentSupport,
tabOpen : state.toolarea.currentToolTab === 'files'
};
};
export default withRoomContext(connect(
mapStateToProps
)(FileSharing));

View file

@ -0,0 +1,38 @@
import React from 'react';
import { connect } from 'react-redux';
import { Me } from '../../appPropTypes';
const ListMe = ({ me }) =>
{
const picture = me.picture || 'resources/images/avatar-empty.jpeg';
return (
<li className='list-item me'>
<div data-component='ListPeer'>
<img className='avatar' src={picture} />
<div className='peer-info'>
{me.displayName}
</div>
<div className='indicators'>
<If condition={me.raisedHand}>
<div className='icon raise-hand on' />
</If>
</div>
</div>
</li>
);
};
ListMe.propTypes = {
me : Me.isRequired
};
const mapStateToProps = (state) => ({
me : state.me
});
export default connect(
mapStateToProps
)(ListMe);

View file

@ -0,0 +1,124 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import * as appPropTypes from '../../appPropTypes';
import { withRoomContext } from '../../../RoomContext';
const ListPeer = (props) =>
{
const {
roomClient,
peer,
micConsumer,
screenConsumer
} = props;
const micEnabled = (
Boolean(micConsumer) &&
!micConsumer.locallyPaused &&
!micConsumer.remotelyPaused
);
const screenVisible = (
Boolean(screenConsumer) &&
!screenConsumer.locallyPaused &&
!screenConsumer.remotelyPaused
);
const picture = peer.picture || 'resources/images/avatar-empty.jpeg';
return (
<div data-component='ListPeer'>
<img className='avatar' src={picture} />
<div className='peer-info'>
{peer.displayName}
</div>
<div className='indicators'>
<If condition={peer.raiseHandState}>
<div className={
classnames(
'icon', 'raise-hand', {
on : peer.raiseHandState,
off : !peer.raiseHandState
}
)
}
/>
</If>
</div>
<div className='volume-container'>
<div className={classnames('bar', `level${micEnabled && micConsumer ? micConsumer.volume:0}`)} />
</div>
<div className='controls'>
<If condition={screenConsumer}>
<div
className={classnames('button', 'screen', {
on : screenVisible,
off : !screenVisible,
disabled : peer.peerScreenInProgress
})}
onClick={(e) =>
{
e.stopPropagation();
screenVisible ?
roomClient.modifyPeerConsumer(peer.name, 'screen', true) :
roomClient.modifyPeerConsumer(peer.name, 'screen', false);
}}
/>
</If>
<div
className={classnames('button', 'mic', {
on : micEnabled,
off : !micEnabled,
disabled : peer.peerAudioInProgress
})}
onClick={(e) =>
{
e.stopPropagation();
micEnabled ?
roomClient.modifyPeerConsumer(peer.name, 'mic', true) :
roomClient.modifyPeerConsumer(peer.name, 'mic', false);
}}
/>
</div>
</div>
);
};
ListPeer.propTypes =
{
roomClient : PropTypes.any.isRequired,
advancedMode : PropTypes.bool,
peer : appPropTypes.Peer.isRequired,
micConsumer : appPropTypes.Consumer,
webcamConsumer : appPropTypes.Consumer,
screenConsumer : appPropTypes.Consumer
};
const mapStateToProps = (state, { name }) =>
{
const peer = state.peers[name];
const consumersArray = peer.consumers
.map((consumerId) => state.consumers[consumerId]);
const micConsumer =
consumersArray.find((consumer) => consumer.source === 'mic');
const webcamConsumer =
consumersArray.find((consumer) => consumer.source === 'webcam');
const screenConsumer =
consumersArray.find((consumer) => consumer.source === 'screen');
return {
peer,
micConsumer,
webcamConsumer,
screenConsumer
};
};
const ListPeerContainer = withRoomContext(connect(
mapStateToProps
)(ListPeer));
export default ListPeerContainer;

View file

@ -0,0 +1,89 @@
import React from 'react';
import { connect } from 'react-redux';
import classNames from 'classnames';
import * as appPropTypes from '../../appPropTypes';
import { withRoomContext } from '../../../RoomContext';
import PropTypes from 'prop-types';
import ListPeer from './ListPeer';
import ListMe from './ListMe';
const ParticipantList =
({
roomClient,
advancedMode,
peers,
selectedPeerName,
spotlights
}) => (
<div data-component='ParticipantList'>
<ul className='list'>
<li className='list-header'>Me:</li>
<ListMe />
</ul>
<br />
<ul className='list'>
<li className='list-header'>Participants in Spotlight:</li>
{peers.filter((peer) =>
{
return (spotlights.find((spotlight) =>
{ return (spotlight === peer.name); }));
}).map((peer) => (
<li
key={peer.name}
className={classNames('list-item', {
selected : peer.name === selectedPeerName
})}
onClick={() => roomClient.setSelectedPeer(peer.name)}
>
<ListPeer name={peer.name} advancedMode={advancedMode} />
</li>
))}
</ul>
<br />
<ul className='list'>
<li className='list-header'>Passive Participants:</li>
{peers.filter((peer) =>
{
return !(spotlights.find((spotlight) =>
{ return (spotlight === peer.name); }));
}).map((peer) => (
<li
key={peer.name}
className={classNames('list-item', {
selected : peer.name === selectedPeerName
})}
onClick={() => roomClient.setSelectedPeer(peer.name)}
>
<ListPeer name={peer.name} advancedMode={advancedMode} />
</li>
))}
</ul>
</div>
);
ParticipantList.propTypes =
{
roomClient : PropTypes.any.isRequired,
advancedMode : PropTypes.bool,
peers : PropTypes.arrayOf(appPropTypes.Peer).isRequired,
selectedPeerName : PropTypes.string,
spotlights : PropTypes.array.isRequired
};
const mapStateToProps = (state) =>
{
const peersArray = Object.values(state.peers);
return {
peers : peersArray,
selectedPeerName : state.room.selectedPeerName,
spotlights : state.room.spotlights
};
};
const ParticipantListContainer = withRoomContext(connect(
mapStateToProps
)(ParticipantList));
export default ParticipantListContainer;

View file

@ -0,0 +1,123 @@
import React from 'react';
import { connect } from 'react-redux';
import * as appPropTypes from '../../appPropTypes';
import { withRoomContext } from '../../../RoomContext';
import * as stateActions from '../../../redux/stateActions';
import PropTypes from 'prop-types';
import Dropdown from 'react-dropdown';
import ReactTooltip from 'react-tooltip';
const modes = [ {
value : 'democratic',
label : 'Democratic view'
}, {
value : 'filmstrip',
label : 'Filmstrip view'
} ];
const findOption = (options, value) => options.find((option) => option.value === value);
const Settings = ({
roomClient,
room,
me,
onToggleAdvancedMode,
handleChangeMode
}) =>
{
let webcams;
if (me.webcamDevices)
webcams = Array.from(me.webcamDevices.values());
else
webcams = [];
let audioDevices;
let audioDevicesText;
if (me.canChangeAudioDevice)
audioDevicesText = 'Select audio input device';
else
audioDevicesText = 'Unable to select audio input device';
if (me.audioDevices)
audioDevices = Array.from(me.audioDevices.values());
else
audioDevices = [];
return (
<div className='settings'>
<Dropdown
options={webcams}
value={findOption(webcams, me.selectedWebcam)}
onChange={(webcam) => roomClient.changeWebcam(webcam.value)}
placeholder={'Select camera'}
/>
<Dropdown
disabled={!me.canChangeAudioDevice}
options={audioDevices}
value={findOption(audioDevices, me.selectedAudioDevice)}
onChange={(device) => roomClient.changeAudioDevice(device.value)}
placeholder={audioDevicesText}
/>
<ReactTooltip
effect='solid'
/>
<div
data-tip='keyboard shortcut: &lsquo;a&lsquo;'
data-type='dark'
data-place='left'
>
<input
id='room-mode'
type='checkbox'
checked={room.advancedMode}
onChange={onToggleAdvancedMode}
/>
<label htmlFor='room-mode'>Advanced mode</label>
</div>
<div
data-tip='keyboard shortcut: type a digit'
data-type='dark'
data-place='left'
>
<Dropdown
options={modes}
value={findOption(modes, room.mode)}
onChange={(mode) => handleChangeMode(mode.value)}
/>
</div>
</div>
);
};
Settings.propTypes =
{
roomClient : PropTypes.any.isRequired,
me : appPropTypes.Me.isRequired,
room : appPropTypes.Room.isRequired,
onToggleAdvancedMode : PropTypes.func.isRequired,
handleChangeMode : PropTypes.func.isRequired
};
const mapStateToProps = (state) =>
{
return {
me : state.me,
room : state.room
};
};
const mapDispatchToProps = {
onToggleAdvancedMode : stateActions.toggleAdvancedMode,
handleChangeMode : stateActions.setDisplayMode
};
const SettingsContainer = withRoomContext(connect(
mapStateToProps,
mapDispatchToProps
)(Settings));
export default SettingsContainer;

View file

@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import classNames from 'classnames';
import * as stateActions from '../../redux/stateActions';
const TabHeader = ({ currentToolTab, setToolTab, id, name, badge }) => (
<div
className={classNames('tab-header', {
checked : currentToolTab === id
})}
onClick={() => setToolTab(id)}
>
{name}
<If condition={badge > 0}>
<span className='badge'>{badge}</span>
</If>
</div>
);
TabHeader.propTypes = {
currentToolTab : PropTypes.string.isRequired,
setToolTab : PropTypes.func.isRequired,
id : PropTypes.string.isRequired,
name : PropTypes.string.isRequired,
badge : PropTypes.number
};
const mapStateToProps = (state) => ({
currentToolTab : state.toolarea.currentToolTab
});
const mapDispatchToProps = {
setToolTab : stateActions.setToolTab
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(TabHeader);

View file

@ -0,0 +1,134 @@
import React, { Fragment } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import * as stateActions from '../../redux/stateActions';
import ParticipantList from './ParticipantList/ParticipantList';
import Chat from './Chat/Chat';
import Settings from './Settings/Settings';
import FileSharing from './FileSharing/FileSharing';
import TabHeader from './TabHeader';
class ToolArea extends React.Component
{
constructor(props)
{
super(props);
}
render()
{
const {
currentToolTab,
toolAreaOpen,
unreadMessages,
unreadFiles,
toggleToolArea,
unread
} = this.props;
const VisibleTab = {
chat : Chat,
files : FileSharing,
users : ParticipantList,
settings : Settings
}[currentToolTab];
return (
<Fragment>
<div
className={classNames('toolarea-shade', {
open : toolAreaOpen
})}
onClick={toggleToolArea}
/>
<div
data-component='ToolArea'
className={classNames({
open : toolAreaOpen
})}
>
<div
className='toolarea-button'
onClick={toggleToolArea}
>
<span className='content'>
<div
className='toolarea-icon'
/>
<p>Toolbox</p>
</span>
<If condition={!toolAreaOpen && unread > 0}>
<span className={classNames('badge', { long: unread >= 10 })}>
{unread}
</span>
</If>
</div>
<div className='tab-headers'>
<TabHeader
id='chat'
name='Chat'
badge={unreadMessages}
/>
<TabHeader
id='files'
name='Files'
badge={unreadFiles}
/>
<TabHeader
id='users'
name='Users'
/>
<TabHeader
id='settings'
name='Settings'
/>
</div>
<div className='tab'>
<VisibleTab />
</div>
</div>
</Fragment>
);
}
}
ToolArea.propTypes =
{
advancedMode : PropTypes.bool,
currentToolTab : PropTypes.string.isRequired,
setToolTab : PropTypes.func.isRequired,
unreadMessages : PropTypes.number.isRequired,
unreadFiles : PropTypes.number.isRequired,
toolAreaOpen : PropTypes.bool,
toggleToolArea : PropTypes.func.isRequired,
closeToolArea : PropTypes.func.isRequired,
unread : PropTypes.number.isRequired
};
const mapStateToProps = (state) => ({
currentToolTab : state.toolarea.currentToolTab,
unreadMessages : state.toolarea.unreadMessages,
unreadFiles : state.toolarea.unreadFiles,
toolAreaOpen : state.toolarea.toolAreaOpen,
unread : state.toolarea.unreadMessages +
state.toolarea.unreadFiles
});
const mapDispatchToProps = {
setToolTab : stateActions.setToolTab,
toggleToolArea : stateActions.toggleToolArea,
closeToolArea : stateActions.closeToolArea
};
const ToolAreaContainer = connect(
mapStateToProps,
mapDispatchToProps
)(ToolArea);
export default ToolAreaContainer;

View file

@ -0,0 +1,63 @@
import React, { Component } from 'react';
import { findDOMNode } from 'react-dom';
/**
* A higher order component which scrolls the user to the bottom of the
* wrapped component, provided that the user already was at the bottom
* of the wrapped component. Useful for chats and similar use cases.
* @param {number} treshold The required distance from the bottom required.
*/
const scrollToBottom = (treshold = 0) => (WrappedComponent) =>
{
return class AutoScroller extends Component
{
constructor(props)
{
super(props);
this.ref = React.createRef();
}
getSnapshotBeforeUpdate()
{
// Check if the user has scrolled close enough to the bottom for
// us to scroll to the bottom or not.
return this.elem.scrollHeight - this.elem.scrollTop <=
this.elem.clientHeight - treshold;
}
scrollToBottom = () =>
{
// Scroll the user to the bottom of the wrapped element.
this.elem.scrollTop = this.elem.scrollHeight;
};
componentDidMount()
{
// eslint-disable-next-line react/no-find-dom-node
this.elem = findDOMNode(this.ref.current);
this.scrollToBottom();
}
componentDidUpdate(prevProps, prevState, atBottom)
{
if (atBottom)
{
this.scrollToBottom();
}
}
render()
{
return (
<WrappedComponent
ref={this.ref}
{...this.props}
/>
);
}
};
};
export default scrollToBottom;

View file

@ -0,0 +1,95 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import * as appPropTypes from '../appPropTypes';
import * as stateActions from '../../redux/stateActions';
import FullView from './FullView';
const FullScreenView = (props) =>
{
const {
advancedMode,
consumer,
toggleConsumerFullscreen,
toolbarsVisible
} = props;
if (!consumer)
return null;
const consumerVisible = (
Boolean(consumer) &&
!consumer.locallyPaused &&
!consumer.remotelyPaused
);
let consumerProfile;
if (consumer)
consumerProfile = consumer.profile;
return (
<div data-component='FullScreenView'>
<If condition={consumerVisible && !consumer.supported}>
<div className='incompatible-video'>
<p>incompatible video</p>
</div>
</If>
<div className='controls'>
<div
className={classnames('button', 'exitfullscreen', 'room-controls', {
visible : toolbarsVisible
})}
onClick={(e) =>
{
e.stopPropagation();
toggleConsumerFullscreen(consumer);
}}
/>
</div>
<FullView
advancedMode={advancedMode}
videoTrack={consumer ? consumer.track : null}
videoVisible={consumerVisible}
videoProfile={consumerProfile}
/>
</div>
);
};
FullScreenView.propTypes =
{
advancedMode : PropTypes.bool,
consumer : appPropTypes.Consumer,
toggleConsumerFullscreen : PropTypes.func.isRequired,
toolbarsVisible : PropTypes.bool
};
const mapStateToProps = (state) =>
{
return {
consumer : state.consumers[state.room.fullScreenConsumer],
toolbarsVisible : state.room.toolbarsVisible
};
};
const mapDispatchToProps = (dispatch) =>
{
return {
toggleConsumerFullscreen : (consumer) =>
{
if (consumer)
dispatch(stateActions.toggleConsumerFullscreen(consumer.id));
}
};
};
const FullScreenViewContainer = connect(
mapStateToProps,
mapDispatchToProps
)(FullScreenView);
export default FullScreenViewContainer;

View file

@ -0,0 +1,83 @@
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
export default class FullView extends React.Component
{
constructor(props)
{
super(props);
// Latest received video track.
// @type {MediaStreamTrack}
this._videoTrack = null;
this.video = React.createRef();
}
render()
{
const {
videoVisible,
videoProfile
} = this.props;
return (
<div data-component='FullView'>
<video
ref={this.video}
className={classnames({
hidden : !videoVisible,
loading : videoProfile === 'none'
})}
autoPlay
playsInline
muted={Boolean(true)}
/>
</div>
);
}
componentDidMount()
{
const { videoTrack } = this.props;
this._setTracks(videoTrack);
}
componentDidUpdate()
{
const { videoTrack } = this.props;
this._setTracks(videoTrack);
}
_setTracks(videoTrack)
{
if (this._videoTrack === videoTrack)
return;
this._videoTrack = videoTrack;
const video = this.video.current;
if (videoTrack)
{
const stream = new MediaStream;
stream.addTrack(videoTrack);
video.srcObject = stream;
}
else
{
video.srcObject = null;
}
}
}
FullView.propTypes =
{
videoTrack : PropTypes.any,
videoVisible : PropTypes.bool,
videoProfile : PropTypes.string
};

View file

@ -0,0 +1,222 @@
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import * as appPropTypes from '../appPropTypes';
import EditableInput from '../Controls/EditableInput';
export default class PeerView extends React.Component
{
constructor(props)
{
super(props);
this.state =
{
volume : 0, // Integer from 0 to 10.,
videoWidth : null,
videoHeight : null
};
// Latest received video track.
// @type {MediaStreamTrack}
this._audioTrack = null;
// Latest received video track.
// @type {MediaStreamTrack}
this._videoTrack = null;
// Periodic timer for showing video resolution.
this._videoResolutionTimer = null;
}
render()
{
const {
isMe,
peer,
volume,
advancedMode,
videoVisible,
videoProfile,
audioCodec,
videoCodec,
onChangeDisplayName
} = this.props;
const {
videoWidth,
videoHeight
} = this.state;
return (
<div data-component='PeerView'>
<div className='info'>
<If condition={advancedMode}>
<div className={classnames('media', { 'is-me': isMe })}>
<div className='box'>
<If condition={audioCodec}>
<p className='codec'>{audioCodec}</p>
</If>
<If condition={videoCodec}>
<p className='codec'>{videoCodec} {videoProfile}</p>
</If>
<If condition={(videoVisible && videoWidth !== null)}>
<p className='resolution'>{videoWidth}x{videoHeight}</p>
</If>
</div>
</div>
</If>
<div className={classnames('peer', { 'is-me': isMe })}>
<Choose>
<When condition={isMe}>
<EditableInput
value={peer.displayName}
propName='displayName'
className='display-name editable'
classLoading='loading'
classInvalid='invalid'
shouldBlockWhileLoading
editProps={{
maxLength : 20,
autoCorrect : false,
spellCheck : false
}}
onChange={({ displayName }) => onChangeDisplayName(displayName)}
/>
</When>
<Otherwise>
<span className='display-name'>
{peer.displayName}
</span>
</Otherwise>
</Choose>
<If condition={advancedMode}>
<div className='row'>
<span
className={classnames('device-icon', peer.device.flag)}
/>
<span className='device-version'>
{peer.device.name} {Math.floor(peer.device.version) || null}
</span>
</div>
</If>
</div>
</div>
<video
ref='video'
className={classnames({
hidden : !videoVisible,
'is-me' : isMe,
loading : videoProfile === 'none'
})}
autoPlay
playsInline
muted={isMe}
/>
<div className='volume-container'>
<div className={classnames('bar', `level${volume}`)} />
</div>
</div>
);
}
componentDidMount()
{
const { audioTrack, videoTrack } = this.props;
this._setTracks(audioTrack, videoTrack);
}
componentWillUnmount()
{
clearInterval(this._videoResolutionTimer);
}
componentWillReceiveProps(nextProps)
{
const { audioTrack, videoTrack } = nextProps;
this._setTracks(audioTrack, videoTrack);
}
_setTracks(audioTrack, videoTrack)
{
if (this._audioTrack === audioTrack && this._videoTrack === videoTrack)
return;
this._audioTrack = audioTrack;
this._videoTrack = videoTrack;
clearInterval(this._videoResolutionTimer);
this._hideVideoResolution();
const { video } = this.refs;
if (audioTrack || videoTrack)
{
const stream = new MediaStream;
if (audioTrack)
stream.addTrack(audioTrack);
if (videoTrack)
stream.addTrack(videoTrack);
video.srcObject = stream;
if (videoTrack)
this._showVideoResolution();
}
else
{
video.srcObject = null;
}
}
_showVideoResolution()
{
this._videoResolutionTimer = setInterval(() =>
{
const { videoWidth, videoHeight } = this.state;
const { video } = this.refs;
// Don't re-render if nothing changed.
if (video.videoWidth === videoWidth && video.videoHeight === videoHeight)
return;
this.setState(
{
videoWidth : video.videoWidth,
videoHeight : video.videoHeight
});
}, 1000);
}
_hideVideoResolution()
{
this.setState({ videoWidth: null, videoHeight: null });
}
}
PeerView.propTypes =
{
isMe : PropTypes.bool,
peer : PropTypes.oneOfType(
[ appPropTypes.Me, appPropTypes.Peer ]).isRequired,
advancedMode : PropTypes.bool,
audioTrack : PropTypes.any,
volume : PropTypes.number,
videoTrack : PropTypes.any,
videoVisible : PropTypes.bool.isRequired,
videoProfile : PropTypes.string,
audioCodec : PropTypes.string,
videoCodec : PropTypes.string,
onChangeDisplayName : PropTypes.func
};

View file

@ -0,0 +1,157 @@
import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
export default class ScreenView extends React.Component
{
constructor(props)
{
super(props);
this.state =
{
screenWidth : null,
screenHeight : null
};
// Latest received screen track.
// @type {MediaStreamTrack}
this._screenTrack = null;
// Periodic timer for showing video resolution.
this._screenResolutionTimer = null;
}
render()
{
const {
isMe,
advancedMode,
screenVisible,
screenProfile,
screenCodec
} = this.props;
const {
screenWidth,
screenHeight
} = this.state;
return (
<div data-component='ScreenView'>
<div className='info'>
<If condition={advancedMode}>
<div className={classnames('media', { 'is-me': isMe })}>
<If condition={screenVisible}>
<div className='box'>
<If condition={screenCodec}>
<p className='codec'>{screenCodec} {screenProfile}</p>
</If>
<If condition={(screenVisible && screenWidth !== null)}>
<p className='resolution'>{screenWidth}x{screenHeight}</p>
</If>
</div>
</If>
</div>
</If>
</div>
<video
ref='video'
className={classnames({
hidden : !screenVisible,
'is-me' : isMe,
loading : screenProfile === 'none'
})}
autoPlay
playsInline
muted={Boolean(true)}
/>
</div>
);
}
componentDidMount()
{
const { screenTrack } = this.props;
this._setTracks(screenTrack);
}
componentWillUnmount()
{
clearInterval(this._screenResolutionTimer);
}
componentWillReceiveProps(nextProps)
{
const { screenTrack } = nextProps;
this._setTracks(screenTrack);
}
_setTracks(screenTrack)
{
if (this._screenTrack === screenTrack)
return;
this._screenTrack = screenTrack;
clearInterval(this._screenResolutionTimer);
this._hideScreenResolution();
const { video } = this.refs;
if (screenTrack)
{
const stream = new MediaStream;
if (screenTrack)
stream.addTrack(screenTrack);
video.srcObject = stream;
if (screenTrack)
this._showScreenResolution();
}
else
{
video.srcObject = null;
}
}
_showScreenResolution()
{
this._screenResolutionTimer = setInterval(() =>
{
const { screenWidth, screenHeight } = this.state;
const { video } = this.refs;
// Don't re-render if nothing changed.
if (video.videoWidth === screenWidth && video.videoHeight === screenHeight)
return;
this.setState(
{
screenWidth : video.videoWidth,
screenHeight : video.videoHeight
});
}, 1000);
}
_hideScreenResolution()
{
this.setState({ screenWidth: null, screenHeight: null });
}
}
ScreenView.propTypes =
{
isMe : PropTypes.bool,
advancedMode : PropTypes.bool,
screenTrack : PropTypes.any,
screenVisible : PropTypes.bool,
screenProfile : PropTypes.string,
screenCodec : PropTypes.string
};

View file

@ -1,91 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import { withStyles } from '@material-ui/core/styles';
import classnames from 'classnames';
import FullScreen from '../FullScreen';
import FullScreenIcon from '@material-ui/icons/Fullscreen';
import FullScreenExitIcon from '@material-ui/icons/FullscreenExit';
const styles = (theme) =>
({
root :
{
position : 'absolute',
top : 0,
left : 0,
height : '100%',
width : '100%',
zIndex : 20000
},
controls :
{
position : 'absolute',
zIndex : 20020,
right : 0,
top : 0,
display : 'flex',
flexDirection : 'row',
justifyContent : 'flex-start',
alignItems : 'center',
padding : theme.spacing(1),
'&.hide' :
{
transition : 'opacity 0.1s ease-in-out',
opacity : 0
},
'&.hover' :
{
opacity : 1
}
},
button :
{
flex : '0 0 auto',
margin : '0.2vmin',
borderRadius : 2,
backgroundColor : 'rgba(255, 255, 255, 0.7)',
cursor : 'pointer',
transitionProperty : 'opacity, background-color',
transitionDuration : '0.15s',
width : '5vmin',
height : '5vmin'
},
icon :
{
fontSize : '5vmin'
},
incompatibleVideo :
{
position : 'absolute',
zIndex : 20010,
top : 0,
bottom : 0,
left : 0,
right : 0,
display : 'flex',
flexDirection : 'column',
justifyContent : 'center',
alignItems : 'center',
'& p' :
{
padding : '6px 12px',
borderRadius : 6,
userSelect : 'none',
pointerEvents : 'none',
fontSize : 15,
color : 'rgba(255, 255, 255, 0.55)'
}
}
});
import classnames from 'classnames';
class NewWindow extends React.PureComponent
{
static defaultProps =
{
url : '',
name : 'edumeet',
title : 'edumeet',
name : '',
title : '',
features : { width: '800px', height: '600px' },
onBlock : null,
onUnload : null,
@ -124,67 +49,32 @@ class NewWindow extends React.PureComponent
this.state = {
mounted : false,
fullscreen : false,
hover : false
fullscreen : false
};
}
render()
{
const {
classes
} = this.props;
if (!this.state.mounted)
return null;
let touchTimeout = null;
return ReactDOM.createPortal([
<div key='newwindow' className={classes.root}
onMouseOver={() => this.setState({ hover: true })}
onMouseOut={() => this.setState({ hover: false })}
onTouchStart={() =>
{
if (touchTimeout)
clearTimeout(touchTimeout);
this.setState({ hover: true });
}}
onTouchEnd={() =>
{
if (touchTimeout)
clearTimeout(touchTimeout);
touchTimeout = setTimeout(() =>
{
this.setState({ hover: false });
}, 2000);
}}
>
<div className={classnames(
classes.controls,
'hide',
this.state.hover ? 'hover' : null
)}
>
{ this.fullscreen.fullscreenEnabled &&
<div key='newwindow' data-component='FullScreenView'>
<div className='controls'>
{this.fullscreen.fullscreenEnabled && (
<div
className={classes.button}
className={classnames('button', {
fullscreen : !this.state.fullscreen,
exitFullscreen : this.state.fullscreen
})}
onClick={this.handleToggleFullscreen}
data-tip='Fullscreen'
data-place='right'
data-type='dark'
>
{ this.state.fullscreen ?
<FullScreenExitIcon className={classes.icon} />
:
<FullScreenIcon className={classes.icon} />
}
</div>
}
/>
)}
</div>
{ this.props.children }
{this.props.children}
</div>
], this.container);
}
@ -209,15 +99,11 @@ class NewWindow extends React.PureComponent
url,
title,
name,
aspectRatio,
features,
onBlock,
center
} = this.props;
features.width = '800px';
features.height = `${800 / aspectRatio}px`;
if (center === 'parent')
{
features.left =
@ -228,20 +114,20 @@ class NewWindow extends React.PureComponent
else if (center === 'screen')
{
const screenLeft =
window.screenLeft !== undefined ? window.screenLeft : window.screen.left;
window.screenLeft !== undefined ? window.screenLeft : screen.left;
const screenTop =
window.screenTop !== undefined ? window.screenTop : window.screen.top;
window.screenTop !== undefined ? window.screenTop : screen.top;
const width = window.innerWidth
? window.innerWidth
: document.documentElement.clientWidth
? document.documentElement.clientWidth
: window.screen.width;
: screen.width;
const height = window.innerHeight
? window.innerHeight
: document.documentElement.clientHeight
? document.documentElement.clientHeight
: window.screen.height;
: screen.height;
features.left = (width / 2) - (features.width / 2) + screenLeft;
features.top = (height / 2) - (features.height / 2) + screenTop;
@ -309,17 +195,15 @@ class NewWindow extends React.PureComponent
}
NewWindow.propTypes = {
children : PropTypes.node,
url : PropTypes.string,
name : PropTypes.string,
title : PropTypes.string,
aspectRatio : PropTypes.number,
features : PropTypes.object,
onUnload : PropTypes.func,
onBlock : PropTypes.func,
center : PropTypes.oneOf([ 'parent', 'screen' ]),
copyStyles : PropTypes.bool,
classes : PropTypes.object.isRequired
children : PropTypes.node,
url : PropTypes.string,
name : PropTypes.string,
title : PropTypes.string,
features : PropTypes.object,
onUnload : PropTypes.func,
onBlock : PropTypes.func,
center : PropTypes.oneOf([ 'parent', 'screen' ]),
copyStyles : PropTypes.bool
};
function copyStyles(source, target)
@ -399,4 +283,4 @@ function toWindowFeatures(obj)
.join(',');
}
export default withStyles(styles)(NewWindow);
export default NewWindow;

View file

@ -0,0 +1,72 @@
import React from 'react';
import { connect } from 'react-redux';
import NewWindow from './NewWindow';
import PropTypes from 'prop-types';
import * as appPropTypes from '../appPropTypes';
import * as stateActions from '../../redux/stateActions';
import FullView from '../VideoContainers/FullView';
const VideoWindow = (props) =>
{
const {
advancedMode,
consumer,
toggleConsumerWindow
} = props;
if (!consumer)
return null;
const consumerVisible = (
Boolean(consumer) &&
!consumer.locallyPaused &&
!consumer.remotelyPaused
);
let consumerProfile;
if (consumer)
consumerProfile = consumer.profile;
return (
<NewWindow onUnload={toggleConsumerWindow}>
<FullView
advancedMode={advancedMode}
videoTrack={consumer ? consumer.track : null}
videoVisible={consumerVisible}
videoProfile={consumerProfile}
/>
</NewWindow>
);
};
VideoWindow.propTypes =
{
advancedMode : PropTypes.bool,
consumer : appPropTypes.Consumer,
toggleConsumerWindow : PropTypes.func.isRequired
};
const mapStateToProps = (state) =>
{
return {
consumer : state.consumers[state.room.windowConsumer]
};
};
const mapDispatchToProps = (dispatch) =>
{
return {
toggleConsumerWindow : () =>
{
dispatch(stateActions.toggleConsumerWindow());
}
};
};
const VideoWindowContainer = connect(
mapStateToProps,
mapDispatchToProps
)(VideoWindow);
export default VideoWindowContainer;

View file

@ -0,0 +1,91 @@
import PropTypes from 'prop-types';
export const Room = PropTypes.shape(
{
url : PropTypes.string.isRequired,
state : PropTypes.oneOf(
[ 'new', 'connecting', 'connected', 'closed' ]).isRequired,
activeSpeakerName : PropTypes.string
});
export const Device = PropTypes.shape(
{
flag : PropTypes.string.isRequired,
name : PropTypes.string.isRequired,
version : PropTypes.string
});
export const Me = PropTypes.shape(
{
name : PropTypes.string.isRequired,
displayName : PropTypes.string,
displayNameSet : PropTypes.bool.isRequired,
device : Device.isRequired,
canSendMic : PropTypes.bool.isRequired,
canSendWebcam : PropTypes.bool.isRequired,
webcamInProgress : PropTypes.bool.isRequired,
audioOnly : PropTypes.bool.isRequired,
audioOnlyInProgress : PropTypes.bool.isRequired,
restartIceInProgress : PropTypes.bool.isRequired
});
export const Producer = PropTypes.shape(
{
id : PropTypes.number.isRequired,
source : PropTypes.oneOf([ 'mic', 'webcam', 'screen' ]).isRequired,
deviceLabel : PropTypes.string,
type : PropTypes.oneOf([ 'front', 'back', 'screen' ]),
locallyPaused : PropTypes.bool.isRequired,
remotelyPaused : PropTypes.bool.isRequired,
track : PropTypes.any,
codec : PropTypes.string.isRequired
});
export const Peer = PropTypes.shape(
{
name : PropTypes.string.isRequired,
displayName : PropTypes.string,
device : Device.isRequired,
consumers : PropTypes.arrayOf(PropTypes.number).isRequired
});
export const Consumer = PropTypes.shape(
{
id : PropTypes.number.isRequired,
peerName : PropTypes.string.isRequired,
source : PropTypes.oneOf([ 'mic', 'webcam', 'screen' ]).isRequired,
supported : PropTypes.bool.isRequired,
locallyPaused : PropTypes.bool.isRequired,
remotelyPaused : PropTypes.bool.isRequired,
profile : PropTypes.oneOf([ 'none', 'default', 'low', 'medium', 'high' ]),
track : PropTypes.any,
codec : PropTypes.string
});
export const Notification = PropTypes.shape(
{
id : PropTypes.string.isRequired,
type : PropTypes.oneOf([ 'info', 'error' ]).isRequired,
timeout : PropTypes.number
});
export const Message = PropTypes.shape(
{
type : PropTypes.string,
component : PropTypes.string,
text : PropTypes.string,
sender : PropTypes.string
});
export const FileEntryProps = PropTypes.shape(
{
data : PropTypes.shape({
name : PropTypes.string.isRequired,
picture : PropTypes.string,
file : PropTypes.shape({
magnet : PropTypes.string.isRequired
}).isRequired,
me : PropTypes.bool
}).isRequired,
notify : PropTypes.func.isRequired
});

View file

@ -0,0 +1,22 @@
import React from 'react';
import PropTypes from 'prop-types';
import { CSSTransition } from 'react-transition-group';
const Appear = ({ duration, children }) => (
<CSSTransition
in
classNames='Appear'
timeout={duration || 1000}
appear
>
{children}
</CSSTransition>
);
Appear.propTypes =
{
duration : PropTypes.number,
children : PropTypes.any
};
export { Appear };

34
app/lib/cookiesManager.js Normal file
View file

@ -0,0 +1,34 @@
import jsCookie from 'js-cookie';
const USER_COOKIE = 'multiparty-meeting.user';
const DEVICES_COOKIE = 'multiparty-meeting.devices';
export function getUser()
{
return jsCookie.getJSON(USER_COOKIE);
}
export function setUser({ displayName })
{
jsCookie.set(USER_COOKIE, { displayName });
}
export function getDevices()
{
return jsCookie.getJSON(DEVICES_COOKIE);
}
export function setDevices({ webcamEnabled })
{
jsCookie.set(DEVICES_COOKIE, { webcamEnabled });
}
export function setAudioDevice({ audioDeviceId })
{
jsCookie.set(DEVICES_COOKIE, { audioDeviceId });
}
export function setVideoDevice({ videoDeviceId })
{
jsCookie.set(DEVICES_COOKIE, { videoDeviceId });
}

157
app/lib/index.jsx Normal file
View file

@ -0,0 +1,157 @@
import domready from 'domready';
import UrlParse from 'url-parse';
import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'react-redux';
import { getDeviceInfo } from 'mediasoup-client';
import randomString from 'random-string';
import Logger from './Logger';
import * as utils from './utils';
import RoomClient from './RoomClient';
import RoomContext from './RoomContext';
import * as cookiesManager from './cookiesManager';
import * as stateActions from './redux/stateActions';
import Room from './components/Room';
import { loginEnabled } from '../config/config';
import { store } from './store';
const logger = new Logger();
let roomClient;
RoomClient.init({ store });
domready(() =>
{
logger.debug('DOM ready');
// Load stuff and run
utils.initialize()
.then(run);
});
function run()
{
logger.debug('run() [environment:%s]', process.env.NODE_ENV);
const peerName = randomString({ length: 8 }).toLowerCase();
const urlParser = new UrlParse(window.location.href, true);
let roomId = (urlParser.pathname).substr(1)
? (urlParser.pathname).substr(1).toLowerCase() : urlParser.query.roomId.toLowerCase();
const produce = urlParser.query.produce !== 'false';
let displayName = urlParser.query.displayName;
const isSipEndpoint = urlParser.query.sipEndpoint === 'true';
const useSimulcast = urlParser.query.simulcast === 'true';
if (!roomId)
{
roomId = randomString({ length: 8 }).toLowerCase();
urlParser.query.roomId = roomId;
window.history.pushState('', '', urlParser.toString());
}
// Get the effective/shareable Room URL.
const roomUrlParser = new UrlParse(window.location.href, true);
for (const key of Object.keys(roomUrlParser.query))
{
// Don't keep some custom params.
switch (key)
{
case 'roomId':
case 'simulcast':
break;
default:
delete roomUrlParser.query[key];
}
}
delete roomUrlParser.hash;
const roomUrl = roomUrlParser.toString();
// Get displayName from cookie (if not already given as param).
const userCookie = cookiesManager.getUser() || {};
let displayNameSet;
if (!displayName)
displayName = userCookie.displayName;
if (displayName)
{
displayNameSet = true;
}
else
{
displayName = 'Guest';
displayNameSet = false;
}
// Get current device.
const device = getDeviceInfo();
// If a SIP endpoint mangle device info.
if (isSipEndpoint)
{
device.flag = 'sipendpoint';
device.name = 'SIP Endpoint';
device.version = undefined;
}
store.dispatch(
stateActions.setRoomUrl(roomUrl));
store.dispatch(
stateActions.setMe({ peerName, displayName, displayNameSet, device, loginEnabled }));
roomClient = new RoomClient(
{ roomId, peerName, displayName, device, useSimulcast, produce });
render(
<Provider store={store}>
<RoomContext.Provider value={roomClient}>
<Room />
</RoomContext.Provider>
</Provider>,
document.getElementById('multiparty-meeting')
);
}
// TODO: Debugging stuff.
global.CLIENT = roomClient;
/* setInterval(() =>
{
if (!roomClient._room.peers[0])
{
delete global.CONSUMER;
return;
}
const peer = roomClient._room.peers[0];
global.CONSUMER = peer.consumers[peer.consumers.length - 1];
}, 2000);
*/
global.sendSdp = function()
{
logger.debug('---------- SEND_TRANSPORT LOCAL SDP OFFER:');
logger.debug(
roomClient._sendTransport._handler._pc.localDescription.sdp);
logger.debug('---------- SEND_TRANSPORT REMOTE SDP ANSWER:');
logger.debug(
roomClient._sendTransport._handler._pc.remoteDescription.sdp);
};
global.recvSdp = function()
{
logger.debug('---------- RECV_TRANSPORT REMOTE SDP OFFER:');
logger.debug(
roomClient._recvTransport._handler._pc.remoteDescription.sdp);
logger.debug('---------- RECV_TRANSPORT LOCAL SDP ANSWER:');
logger.debug(
roomClient._recvTransport._handler._pc.localDescription.sdp);
};

101
app/lib/redux/STATE.md Normal file
View file

@ -0,0 +1,101 @@
# APP STATE
```js
{
peerWidth : 200,
peerHeight : 150,
room :
{
url : 'https://example.io/?&roomId=d0el8y34',
state : 'connected', // new/connecting/connected/closed
activeSpeakerName : 'alice'
},
me :
{
name : 'bob',
displayName : 'Bob McFLower',
displayNameSet : false, // true if got from cookie or manually set.
device : { flag: 'firefox', name: 'Firefox', version: '61' },
canSendMic : true,
canSendWebcam : true,
webcamInProgress : false,
audioOnly : false,
audioOnlyInProgress : false,
restartIceInProgress : false
},
producers :
{
1111 :
{
id : 1111,
source : 'mic', // mic/webcam,
locallyPaused : true,
remotelyPaused : false,
track : MediaStreamTrack,
codec : 'opus'
},
1112 :
{
id : 1112,
source : 'webcam', // mic/webcam
deviceLabel : 'Macbook Webcam',
type : 'front', // front/back
locallyPaused : false,
remotelyPaused : false,
track : MediaStreamTrack,
codec : 'vp8',
}
},
peers :
{
'alice' :
{
name : 'alice',
displayName : 'Alice Thomsom',
raiseHandState : false,
device : { flag: 'chrome', name: 'Chrome', version: '58' },
consumers : [ 5551, 5552 ]
}
},
consumers :
{
5551 :
{
id : 5551,
peerName : 'alice',
source : 'mic', // mic/webcam
supported : true,
locallyPaused : false,
remotelyPaused : false,
profile : 'default',
track : MediaStreamTrack,
codec : 'opus'
},
5552 :
{
id : 5552,
peerName : 'alice',
source : 'webcam',
supported : false,
locallyPaused : false,
remotelyPaused : true,
profile : 'medium',
track : null,
codec : 'h264'
}
},
notifications :
[
{
id : 'qweasdw43we',
type : 'info' // info/error
text : 'You joined the room'
},
{
id : 'j7sdhkjjkcc',
type : 'error'
text : 'Could not add webcam'
}
]
}
```

View file

@ -0,0 +1,43 @@
import
{
createNewMessage
} from './helper';
const chatmessages = (state = [], action) =>
{
switch (action.type)
{
case 'ADD_NEW_USER_MESSAGE':
{
const { text } = action.payload;
const message = createNewMessage(text, 'client', 'Me', undefined);
return [ ...state, message ];
}
case 'ADD_NEW_RESPONSE_MESSAGE':
{
const { message } = action.payload;
return [ ...state, message ];
}
case 'ADD_CHAT_HISTORY':
{
const { chatHistory } = action.payload;
return [ ...state, ...chatHistory ];
}
case 'DROP_MESSAGES':
{
return [];
}
default:
return state;
}
};
export default chatmessages;

View file

@ -25,7 +25,6 @@ const consumers = (state = initialState, action) =>
{
const { consumerId, originator } = action.payload;
const consumer = state[consumerId];
let newConsumer;
if (originator === 'local')
@ -36,11 +35,19 @@ const consumers = (state = initialState, action) =>
return { ...state, [consumerId]: newConsumer };
}
case 'SET_CONSUMER_VOLUME':
{
const { consumerId, volume } = action.payload;
const consumer = state[consumerId];
const newConsumer = { ...consumer, volume };
return { ...state, [consumerId]: newConsumer };
}
case 'SET_CONSUMER_RESUMED':
{
const { consumerId, originator } = action.payload;
const consumer = state[consumerId];
let newConsumer;
if (originator === 'local')
@ -51,39 +58,11 @@ const consumers = (state = initialState, action) =>
return { ...state, [consumerId]: newConsumer };
}
case 'SET_CONSUMER_CURRENT_LAYERS':
case 'SET_CONSUMER_EFFECTIVE_PROFILE':
{
const { consumerId, spatialLayer, temporalLayer } = action.payload;
const { consumerId, profile } = action.payload;
const consumer = state[consumerId];
const newConsumer =
{
...consumer,
currentSpatialLayer : spatialLayer,
currentTemporalLayer : temporalLayer
};
return { ...state, [consumerId]: newConsumer };
}
case 'SET_CONSUMER_PREFERRED_LAYERS':
{
const { consumerId, spatialLayer, temporalLayer } = action.payload;
const consumer = state[consumerId];
const newConsumer =
{
...consumer,
preferredSpatialLayer : spatialLayer,
preferredTemporalLayer : temporalLayer
};
return { ...state, [consumerId]: newConsumer };
}
case 'SET_CONSUMER_PRIORITY':
{
const { consumerId, priority } = action.payload;
const consumer = state[consumerId];
const newConsumer = { ...consumer, priority };
const newConsumer = { ...consumer, profile };
return { ...state, [consumerId]: newConsumer };
}
@ -97,46 +76,6 @@ const consumers = (state = initialState, action) =>
return { ...state, [consumerId]: newConsumer };
}
case 'SET_CONSUMER_AUDIO_GAIN':
{
const { consumerId, audioGain } = action.payload;
const consumer = state[consumerId];
const newConsumer = { ...consumer, audioGain };
return { ...state, [consumerId]: newConsumer };
}
case 'SET_CONSUMER_SCORE':
{
const { consumerId, score } = action.payload;
const consumer = state[consumerId];
if (!consumer)
return state;
const newConsumer = { ...consumer, score };
return { ...state, [consumerId]: newConsumer };
}
case 'CLEAR_CONSUMERS':
{
return initialState;
}
case 'SET_CONSUMER_OPUS_CONFIG':
{
const { consumerId, opusConfig } = action.payload;
const consumer = state[consumerId];
const newConsumer =
{
...consumer,
opusConfig
};
return { ...state, [consumerId]: newConsumer };
}
default:
return state;
}

View file

@ -0,0 +1,99 @@
const files = (state = {}, action) =>
{
switch (action.type)
{
case 'ADD_FILE':
{
const { file } = action.payload;
const newFile = {
active : false,
progress : 0,
files : null,
me : false,
...file
};
return { ...state, [file.magnetUri]: newFile };
}
case 'ADD_FILE_HISTORY':
{
const { fileHistory } = action.payload;
const newFileHistory = {};
fileHistory.map((file) =>
{
const newFile = {
active : false,
progress : 0,
files : null,
me : false,
...file
};
newFileHistory[file.magnetUri] = newFile;
});
return { ...state, ...newFileHistory };
}
case 'SET_FILE_ACTIVE':
{
const { magnetUri } = action.payload;
const file = state[magnetUri];
const newFile = { ...file, active: true };
return { ...state, [magnetUri]: newFile };
}
case 'SET_FILE_INACTIVE':
{
const { magnetUri } = action.payload;
const file = state[magnetUri];
const newFile = { ...file, active: false };
return { ...state, [magnetUri]: newFile };
}
case 'SET_FILE_PROGRESS':
{
const { magnetUri, progress } = action.payload;
const file = state[magnetUri];
const newFile = { ...file, progress: progress };
return { ...state, [magnetUri]: newFile };
}
case 'SET_FILE_DONE':
{
const { magnetUri, sharedFiles } = action.payload;
const file = state[magnetUri];
const newFile = {
...file,
files : sharedFiles,
progress : 1,
active : false,
timeout : false
};
return { ...state, [magnetUri]: newFile };
}
case 'REMOVE_FILE':
{
const { magnetUri } = action.payload;
return state.filter((file) => file.magnetUri !== magnetUri);
}
default:
return state;
}
};
export default files;

View file

@ -0,0 +1,11 @@
export function createNewMessage(text, sender, name, picture)
{
return {
type : 'message',
text,
time : Date.now(),
name,
sender,
picture
};
}

View file

@ -0,0 +1,25 @@
import { combineReducers } from 'redux';
import room from './room';
import me from './me';
import producers from './producers';
import peers from './peers';
import consumers from './consumers';
import notifications from './notifications';
import chatmessages from './chatmessages';
import toolarea from './toolarea';
import files from './files';
const reducers = combineReducers(
{
room,
me,
producers,
peers,
consumers,
notifications,
chatmessages,
toolarea,
files
});
export default reducers;

View file

@ -0,0 +1,189 @@
const initialState =
{
name : null,
displayName : null,
displayNameSet : false,
device : null,
canSendMic : false,
canSendWebcam : false,
canShareScreen : false,
needExtension : false,
canChangeAudioDevice : false,
audioDevices : null,
canChangeWebcam : false,
webcamDevices : null,
webcamInProgress : false,
audioInProgress : false,
screenShareInProgress : false,
loginEnabled : false,
audioOnly : false,
audioOnlyInProgress : false,
raiseHand : false,
raiseHandInProgress : false,
restartIceInProgress : false,
picture : null,
selectedWebcam : null,
selectedAudioDevice : null,
loggedIn : false
};
const me = (state = initialState, action) =>
{
switch (action.type)
{
case 'SET_ME':
{
const {
peerName,
displayName,
displayNameSet,
device,
loginEnabled
} = action.payload;
return {
...state,
name : peerName,
displayName,
displayNameSet,
device,
loginEnabled
};
}
case 'LOGGED_IN':
return { ...state, loggedIn: true };
case 'USER_LOGOUT':
return { ...state, loggedIn: false };
case 'CHANGE_WEBCAM':
{
return { ...state, selectedWebcam: action.payload.deviceId };
}
case 'CHANGE_AUDIO_DEVICE':
{
return { ...state, selectedAudioDevice: action.payload.deviceId };
}
case 'SET_MEDIA_CAPABILITIES':
{
const { canSendMic, canSendWebcam } = action.payload;
return { ...state, canSendMic, canSendWebcam };
}
case 'SET_SCREEN_CAPABILITIES':
{
const { canShareScreen, needExtension } = action.payload;
return { ...state, canShareScreen, needExtension };
}
case 'SET_CAN_CHANGE_AUDIO_DEVICE':
{
const canChangeAudioDevice = action.payload;
return { ...state, canChangeAudioDevice };
}
case 'SET_AUDIO_DEVICES':
{
const { devices } = action.payload;
return { ...state, audioDevices: devices };
}
case 'SET_CAN_CHANGE_WEBCAM':
{
const canChangeWebcam = action.payload;
return { ...state, canChangeWebcam };
}
case 'SET_WEBCAM_DEVICES':
{
const { devices } = action.payload;
return { ...state, webcamDevices: devices };
}
case 'SET_AUDIO_IN_PROGRESS':
{
const { flag } = action.payload;
return { ...state, audioInProgress: flag };
}
case 'SET_WEBCAM_IN_PROGRESS':
{
const { flag } = action.payload;
return { ...state, webcamInProgress: flag };
}
case 'SET_SCREEN_SHARE_IN_PROGRESS':
{
const { flag } = action.payload;
return { ...state, screenShareInProgress: flag };
}
case 'SET_DISPLAY_NAME':
{
let { displayName } = action.payload;
// Be ready for undefined displayName (so keep previous one).
if (!displayName)
displayName = state.displayName;
return { ...state, displayName, displayNameSet: true };
}
case 'SET_AUDIO_ONLY_STATE':
{
const { enabled } = action.payload;
return { ...state, audioOnly: enabled };
}
case 'SET_AUDIO_ONLY_IN_PROGRESS':
{
const { flag } = action.payload;
return { ...state, audioOnlyInProgress: flag };
}
case 'SET_MY_RAISE_HAND_STATE':
{
const { flag } = action.payload;
return { ...state, raiseHand: flag };
}
case 'SET_MY_RAISE_HAND_STATE_IN_PROGRESS':
{
const { flag } = action.payload;
return { ...state, raiseHandInProgress: flag };
}
case 'SET_RESTART_ICE_IN_PROGRESS':
{
const { flag } = action.payload;
return { ...state, restartIceInProgress: flag };
}
case 'SET_PICTURE':
{
return { ...state, picture: action.payload.picture };
}
default:
return state;
}
};
export default me;

View file

@ -6,17 +6,6 @@ const notifications = (state = [], action) =>
{
const { notification } = action.payload;
notification.toBeClosed=false;
return [ ...state, notification ];
}
case 'ADD_CONSENT_NOTIFICATION':
{
const { notification } = action.payload;
notification.toBeClosed=false;
return [ ...state, notification ];
}
@ -32,21 +21,6 @@ const notifications = (state = [], action) =>
return [];
}
case 'CLOSE_NOTIFICATION':
{
const { notificationId } = action.payload;
return (state.map((e) =>
{
if (e.id === notificationId)
{
e.toBeClosed=true;
}
return e;
}));
}
default:
return state;
}

View file

@ -0,0 +1,98 @@
import omit from 'lodash/omit';
const peer = (state = {}, action) =>
{
switch (action.type)
{
case 'ADD_PEER':
return action.payload.peer;
case 'SET_PEER_DISPLAY_NAME':
return { ...state, displayName: action.payload.displayName };
case 'SET_PEER_VIDEO_IN_PROGRESS':
return { ...state, peerVideoInProgress: action.payload.flag };
case 'SET_PEER_AUDIO_IN_PROGRESS':
return { ...state, peerAudioInProgress: action.payload.flag };
case 'SET_PEER_SCREEN_IN_PROGRESS':
return { ...state, peerScreenInProgress: action.payload.flag };
case 'SET_PEER_RAISE_HAND_STATE':
return { ...state, raiseHandState: action.payload.raiseHandState };
case 'ADD_CONSUMER':
{
const consumers = [ ...state.consumers, action.payload.consumer.id ];
return { ...state, consumers };
}
case 'REMOVE_CONSUMER':
{
const consumers = state.consumers.filter((consumer) =>
consumer !== action.payload.consumerId);
return { ...state, consumers };
}
case 'SET_PEER_PICTURE':
{
return { ...state, picture: action.payload.picture };
}
default:
return state;
}
};
const peers = (state = {}, action) =>
{
switch (action.type)
{
case 'ADD_PEER':
{
return { ...state, [action.payload.peer.name]: peer(undefined, action) };
}
case 'REMOVE_PEER':
{
return omit(state, [ action.payload.peerName ]);
}
case 'SET_PEER_DISPLAY_NAME':
case 'SET_PEER_VIDEO_IN_PROGRESS':
case 'SET_PEER_AUDIO_IN_PROGRESS':
case 'SET_PEER_SCREEN_IN_PROGRESS':
case 'SET_PEER_RAISE_HAND_STATE':
case 'SET_PEER_PICTURE':
case 'ADD_CONSUMER':
{
const oldPeer = state[action.payload.peerName];
if (!oldPeer)
{
throw new Error('no Peer found');
}
return { ...state, [oldPeer.name]: peer(oldPeer, action) };
}
case 'REMOVE_CONSUMER':
{
const oldPeer = state[action.payload.peerName];
// NOTE: This means that the Peer was closed before, so it's ok.
if (!oldPeer)
return state;
return { ...state, [oldPeer.name]: peer(oldPeer, action) };
}
default:
return state;
}
};
export default peers;

View file

@ -25,7 +25,6 @@ const producers = (state = initialState, action) =>
{
const { producerId, originator } = action.payload;
const producer = state[producerId];
let newProducer;
if (originator === 'local')
@ -36,11 +35,19 @@ const producers = (state = initialState, action) =>
return { ...state, [producerId]: newProducer };
}
case 'SET_PRODUCER_VOLUME':
{
const { producerId, volume } = action.payload;
const producer = state[producerId];
const newProducer = { ...producer, volume };
return { ...state, [producerId]: newProducer };
}
case 'SET_PRODUCER_RESUMED':
{
const { producerId, originator } = action.payload;
const producer = state[producerId];
let newProducer;
if (originator === 'local')
@ -60,17 +67,6 @@ const producers = (state = initialState, action) =>
return { ...state, [producerId]: newProducer };
}
case 'SET_PRODUCER_SCORE':
{
const { producerId, score } = action.payload;
const producer = state[producerId];
const newProducer = { ...producer, score };
return { ...state, [producerId]: newProducer };
}
default:
return state;
}

View file

@ -0,0 +1,144 @@
const initialState =
{
url : null,
state : 'new', // new/connecting/connected/disconnected/closed,
locked : false,
lockedOut : false,
audioSuspended : false,
activeSpeakerName : null,
torrentSupport : false,
showSettings : false,
advancedMode : false,
fullScreenConsumer : null, // ConsumerID
windowConsumer : null, // ConsumerID
toolbarsVisible : true,
mode : 'democratic',
selectedPeerName : null,
spotlights : []
};
const room = (state = initialState, action) =>
{
switch (action.type)
{
case 'SET_ROOM_URL':
{
const { url } = action.payload;
return { ...state, url };
}
case 'SET_ROOM_STATE':
{
const roomState = action.payload.state;
if (roomState == 'connected')
return { ...state, state: roomState };
else
return { ...state, state: roomState, activeSpeakerName: null };
}
case 'SET_ROOM_LOCKED':
{
return { ...state, locked: true };
}
case 'SET_ROOM_UNLOCKED':
{
return { ...state, locked: false };
}
case 'SET_ROOM_LOCKED_OUT':
{
return { ...state, lockedOut: true };
}
case 'SET_AUDIO_SUSPENDED':
{
const { audioSuspended } = action.payload;
return { ...state, audioSuspended };
}
case 'SET_ROOM_ACTIVE_SPEAKER':
{
const { peerName } = action.payload;
return { ...state, activeSpeakerName: peerName };
}
case 'FILE_SHARING_SUPPORTED':
{
const { supported } = action.payload;
return { ...state, torrentSupport: supported };
}
case 'TOGGLE_SETTINGS':
{
const showSettings = !state.showSettings;
return { ...state, showSettings };
}
case 'TOGGLE_ADVANCED_MODE':
{
const advancedMode = !state.advancedMode;
return { ...state, advancedMode };
}
case 'TOGGLE_FULLSCREEN_CONSUMER':
{
const { consumerId } = action.payload;
const currentConsumer = state.fullScreenConsumer;
return { ...state, fullScreenConsumer: currentConsumer ? null : consumerId };
}
case 'TOGGLE_WINDOW_CONSUMER':
{
const { consumerId } = action.payload;
const currentConsumer = state.windowConsumer;
if (currentConsumer === consumerId)
return { ...state, windowConsumer: null };
else
return { ...state, windowConsumer: consumerId };
}
case 'SET_TOOLBARS_VISIBLE':
{
const { toolbarsVisible } = action.payload;
return { ...state, toolbarsVisible };
}
case 'SET_DISPLAY_MODE':
return { ...state, mode: action.payload.mode };
case 'SET_SELECTED_PEER':
{
const { selectedPeerName } = action.payload;
return {
...state,
selectedPeerName : state.selectedPeerName === selectedPeerName ?
null : selectedPeerName
};
}
case 'SET_SPOTLIGHTS':
{
const { spotlights } = action.payload;
return { ...state, spotlights };
}
default:
return state;
}
};
export default room;

View file

@ -14,7 +14,7 @@ const toolarea = (state = initialState, action) =>
{
const toolAreaOpen = !state.toolAreaOpen;
const unreadMessages = toolAreaOpen && state.currentToolTab === 'chat' ? 0 : state.unreadMessages;
const unreadFiles = toolAreaOpen && state.currentToolTab === 'chat' ? 0 : state.unreadFiles;
const unreadFiles = toolAreaOpen && state.currentToolTab === 'files' ? 0 : state.unreadFiles;
return { ...state, toolAreaOpen, unreadMessages, unreadFiles };
}
@ -23,7 +23,7 @@ const toolarea = (state = initialState, action) =>
{
const toolAreaOpen = true;
const unreadMessages = state.currentToolTab === 'chat' ? 0 : state.unreadMessages;
const unreadFiles = state.currentToolTab === 'chat' ? 0 : state.unreadFiles;
const unreadFiles = state.currentToolTab === 'files' ? 0 : state.unreadFiles;
return { ...state, toolAreaOpen, unreadMessages, unreadFiles };
}
@ -39,12 +39,12 @@ const toolarea = (state = initialState, action) =>
{
const { toolTab } = action.payload;
const unreadMessages = toolTab === 'chat' ? 0 : state.unreadMessages;
const unreadFiles = toolTab === 'chat' ? 0 : state.unreadFiles;
const unreadFiles = toolTab === 'files' ? 0 : state.unreadFiles;
return { ...state, currentToolTab: toolTab, unreadMessages, unreadFiles };
}
case 'ADD_MESSAGE':
case 'ADD_NEW_RESPONSE_MESSAGE':
{
if (state.toolAreaOpen && state.currentToolTab === 'chat')
{
@ -56,7 +56,7 @@ const toolarea = (state = initialState, action) =>
case 'ADD_FILE':
{
if (state.toolAreaOpen && state.currentToolTab === 'chat')
if (state.toolAreaOpen && state.currentToolTab === 'files')
{
return state;
}

View file

@ -1,5 +1,5 @@
import randomString from 'random-string';
import * as notificationActions from './notificationActions';
import * as stateActions from './stateActions';
// This returns a redux-thunk action (a function).
export const notify = ({ type = 'info', text, timeout }) =>
@ -14,9 +14,6 @@ export const notify = ({ type = 'info', text, timeout }) =>
case 'error':
timeout = 5000;
break;
default:
timeout = 3000;
break;
}
}
@ -30,11 +27,11 @@ export const notify = ({ type = 'info', text, timeout }) =>
return (dispatch) =>
{
dispatch(notificationActions.addNotification(notification));
dispatch(stateActions.addNotification(notification));
setTimeout(() =>
{
dispatch(notificationActions.removeNotification(notification.id));
dispatch(stateActions.removeNotification(notification.id));
}, timeout);
};
};

View file

@ -0,0 +1,570 @@
export const setRoomUrl = (url) =>
{
return {
type : 'SET_ROOM_URL',
payload : { url }
};
};
export const setRoomState = (state) =>
{
return {
type : 'SET_ROOM_STATE',
payload : { state }
};
};
export const setRoomActiveSpeaker = (peerName) =>
{
return {
type : 'SET_ROOM_ACTIVE_SPEAKER',
payload : { peerName }
};
};
export const setRoomLocked = () =>
{
return {
type : 'SET_ROOM_LOCKED'
};
};
export const setRoomUnLocked = () =>
{
return {
type : 'SET_ROOM_UNLOCKED'
};
};
export const setRoomLockedOut = () =>
{
return {
type : 'SET_ROOM_LOCKED_OUT'
};
};
export const setAudioSuspended = ({ audioSuspended }) =>
{
return {
type : 'SET_AUDIO_SUSPENDED',
payload : { audioSuspended }
};
};
export const setMe = ({ peerName, displayName, displayNameSet, device, loginEnabled }) =>
{
return {
type : 'SET_ME',
payload : { peerName, displayName, displayNameSet, device, loginEnabled }
};
};
export const setMediaCapabilities = ({ canSendMic, canSendWebcam }) =>
{
return {
type : 'SET_MEDIA_CAPABILITIES',
payload : { canSendMic, canSendWebcam }
};
};
export const setScreenCapabilities = ({ canShareScreen, needExtension }) =>
{
return {
type : 'SET_SCREEN_CAPABILITIES',
payload : { canShareScreen, needExtension }
};
};
export const setCanChangeAudioDevice = (flag) =>
{
return {
type : 'SET_CAN_CHANGE_AUDIO_DEVICE',
payload : flag
};
};
export const setAudioDevices = (devices) =>
{
return {
type : 'SET_AUDIO_DEVICES',
payload : { devices }
};
};
export const setWebcamDevices = (devices) =>
{
return {
type : 'SET_WEBCAM_DEVICES',
payload : { devices }
};
};
export const setFileSharingSupported = (supported) =>
{
return {
type : 'FILE_SHARING_SUPPORTED',
payload : { supported }
};
};
export const setDisplayName = (displayName) =>
{
return {
type : 'SET_DISPLAY_NAME',
payload : { displayName }
};
};
export const toggleAdvancedMode = () =>
{
return {
type : 'TOGGLE_ADVANCED_MODE'
};
};
export const setDisplayMode = (mode) =>
({
type : 'SET_DISPLAY_MODE',
payload : { mode }
});
export const setAudioOnlyState = (enabled) =>
{
return {
type : 'SET_AUDIO_ONLY_STATE',
payload : { enabled }
};
};
export const setAudioOnlyInProgress = (flag) =>
{
return {
type : 'SET_AUDIO_ONLY_IN_PROGRESS',
payload : { flag }
};
};
export const setPeerVideoInProgress = (peerName, flag) =>
{
return {
type : 'SET_PEER_VIDEO_IN_PROGRESS',
payload : { peerName, flag }
};
};
export const setPeerAudioInProgress = (peerName, flag) =>
{
return {
type : 'SET_PEER_AUDIO_IN_PROGRESS',
payload : { peerName, flag }
};
};
export const setPeerScreenInProgress = (peerName, flag) =>
{
return {
type : 'SET_PEER_SCREEN_IN_PROGRESS',
payload : { peerName, flag }
};
};
export const setMyRaiseHandState = (flag) =>
{
return {
type : 'SET_MY_RAISE_HAND_STATE',
payload : { flag }
};
};
export const toggleSettings = () =>
{
return {
type : 'TOGGLE_SETTINGS'
};
};
export const toggleToolArea = () =>
{
return {
type : 'TOGGLE_TOOL_AREA'
};
};
export const openToolArea = () =>
{
return {
type : 'OPEN_TOOL_AREA'
};
};
export const closeToolArea = () =>
{
return {
type : 'CLOSE_TOOL_AREA'
};
};
export const setToolTab = (toolTab) =>
{
return {
type : 'SET_TOOL_TAB',
payload : { toolTab }
};
};
export const setMyRaiseHandStateInProgress = (flag) =>
{
return {
type : 'SET_MY_RAISE_HAND_STATE_IN_PROGRESS',
payload : { flag }
};
};
export const setPeerRaiseHandState = (peerName, raiseHandState) =>
{
return {
type : 'SET_PEER_RAISE_HAND_STATE',
payload : { peerName, raiseHandState }
};
};
export const setRestartIceInProgress = (flag) =>
{
return {
type : 'SET_RESTART_ICE_IN_PROGRESS',
payload : { flag }
};
};
export const addProducer = (producer) =>
{
return {
type : 'ADD_PRODUCER',
payload : { producer }
};
};
export const removeProducer = (producerId) =>
{
return {
type : 'REMOVE_PRODUCER',
payload : { producerId }
};
};
export const setProducerPaused = (producerId, originator) =>
{
return {
type : 'SET_PRODUCER_PAUSED',
payload : { producerId, originator }
};
};
export const setProducerResumed = (producerId, originator) =>
{
return {
type : 'SET_PRODUCER_RESUMED',
payload : { producerId, originator }
};
};
export const setProducerTrack = (producerId, track) =>
{
return {
type : 'SET_PRODUCER_TRACK',
payload : { producerId, track }
};
};
export const setAudioInProgress = (flag) =>
{
return {
type : 'SET_AUDIO_IN_PROGRESS',
payload : { flag }
};
};
export const setWebcamInProgress = (flag) =>
{
return {
type : 'SET_WEBCAM_IN_PROGRESS',
payload : { flag }
};
};
export const setScreenShareInProgress = (flag) =>
{
return {
type : 'SET_SCREEN_SHARE_IN_PROGRESS',
payload : { flag }
};
};
export const addPeer = (peer) =>
{
return {
type : 'ADD_PEER',
payload : { peer }
};
};
export const removePeer = (peerName) =>
{
return {
type : 'REMOVE_PEER',
payload : { peerName }
};
};
export const setPeerDisplayName = (displayName, peerName) =>
{
return {
type : 'SET_PEER_DISPLAY_NAME',
payload : { displayName, peerName }
};
};
export const addConsumer = (consumer, peerName) =>
{
return {
type : 'ADD_CONSUMER',
payload : { consumer, peerName }
};
};
export const removeConsumer = (consumerId, peerName) =>
{
return {
type : 'REMOVE_CONSUMER',
payload : { consumerId, peerName }
};
};
export const setConsumerPaused = (consumerId, originator) =>
{
return {
type : 'SET_CONSUMER_PAUSED',
payload : { consumerId, originator }
};
};
export const setConsumerResumed = (consumerId, originator) =>
{
return {
type : 'SET_CONSUMER_RESUMED',
payload : { consumerId, originator }
};
};
export const setConsumerEffectiveProfile = (consumerId, profile) =>
{
return {
type : 'SET_CONSUMER_EFFECTIVE_PROFILE',
payload : { consumerId, profile }
};
};
export const setConsumerTrack = (consumerId, track) =>
{
return {
type : 'SET_CONSUMER_TRACK',
payload : { consumerId, track }
};
};
export const setConsumerVolume = (consumerId, volume) =>
{
return {
type : 'SET_CONSUMER_VOLUME',
payload : { consumerId, volume }
};
};
export const setProducerVolume = (producerId, volume) =>
{
return {
type : 'SET_PRODUCER_VOLUME',
payload : { producerId, volume }
};
};
export const addNotification = (notification) =>
{
return {
type : 'ADD_NOTIFICATION',
payload : { notification }
};
};
export const removeNotification = (notificationId) =>
{
return {
type : 'REMOVE_NOTIFICATION',
payload : { notificationId }
};
};
export const removeAllNotifications = () =>
{
return {
type : 'REMOVE_ALL_NOTIFICATIONS'
};
};
export const toggleChat = () =>
{
return {
type : 'TOGGLE_CHAT'
};
};
export const toggleConsumerFullscreen = (consumerId) =>
{
return {
type : 'TOGGLE_FULLSCREEN_CONSUMER',
payload : { consumerId }
};
};
export const toggleConsumerWindow = (consumerId) =>
{
return {
type : 'TOGGLE_WINDOW_CONSUMER',
payload : { consumerId }
};
};
export const setToolbarsVisible = (toolbarsVisible) => ({
type : 'SET_TOOLBARS_VISIBLE',
payload : { toolbarsVisible }
});
export const increaseBadge = () =>
{
return {
type : 'INCREASE_BADGE'
};
};
export const toggleInputDisabled = () =>
{
return {
type : 'TOGGLE_INPUT_DISABLED'
};
};
export const addUserMessage = (text) =>
{
return {
type : 'ADD_NEW_USER_MESSAGE',
payload : { text }
};
};
export const addUserFile = (file) =>
{
return {
type : 'ADD_NEW_USER_FILE',
payload : { file }
};
};
export const addResponseMessage = (message) =>
{
return {
type : 'ADD_NEW_RESPONSE_MESSAGE',
payload : { message }
};
};
export const addChatHistory = (chatHistory) =>
{
return {
type : 'ADD_CHAT_HISTORY',
payload : { chatHistory }
};
};
export const dropMessages = () =>
{
return {
type : 'DROP_MESSAGES'
};
};
export const addFile = (file) =>
{
return {
type : 'ADD_FILE',
payload : { file }
};
};
export const addFileHistory = (fileHistory) =>
{
return {
type : 'ADD_FILE_HISTORY',
payload : { fileHistory }
};
};
export const setFileActive = (magnetUri) =>
{
return {
type : 'SET_FILE_ACTIVE',
payload : { magnetUri }
};
};
export const setFileInActive = (magnetUri) =>
{
return {
type : 'SET_FILE_INACTIVE',
payload : { magnetUri }
};
};
export const setFileProgress = (magnetUri, progress) =>
{
return {
type : 'SET_FILE_PROGRESS',
payload : { magnetUri, progress }
};
};
export const setFileDone = (magnetUri, sharedFiles) =>
{
return {
type : 'SET_FILE_DONE',
payload : { magnetUri, sharedFiles }
};
};
export const setPicture = (picture) =>
({
type : 'SET_PICTURE',
payload : { picture }
});
export const setPeerPicture = (peerName, picture) =>
({
type : 'SET_PEER_PICTURE',
payload : { peerName, picture }
});
export const loggedIn = () =>
({
type : 'LOGGED_IN'
});
export const setSelectedPeer = (selectedPeerName) =>
({
type : 'SET_SELECTED_PEER',
payload : { selectedPeerName }
});
export const setSpotlights = (spotlights) =>
({
type : 'SET_SPOTLIGHTS',
payload : { spotlights }
});

47
app/lib/store.js Normal file
View file

@ -0,0 +1,47 @@
import {
applyMiddleware,
createStore,
compose
} from 'redux';
import thunk from 'redux-thunk';
import { createLogger } from 'redux-logger';
import reducers from './redux/reducers';
const reduxMiddlewares =
[
thunk
];
if (process.env.NODE_ENV === 'development')
{
const reduxLogger = createLogger(
{
// filter VOLUME level actions from log
predicate : (getState, action) => ! (action.type == 'SET_PRODUCER_VOLUME'
|| action.type == 'SET_CONSUMER_VOLUME'),
duration : true,
timestamp : false,
level : 'log',
logErrors : true
});
reduxMiddlewares.push(reduxLogger);
}
const composeEnhancers =
typeof window === 'object' &&
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ?
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({
// Specify extensions options like name, actionsBlacklist, actionsCreators, serialize...
}) : compose;
const enhancer = composeEnhancers(
applyMiddleware(...reduxMiddlewares)
// other store enhancers if any
);
export const store = createStore(
reducers,
undefined,
enhancer
);

8
app/lib/urlFactory.js Normal file
View file

@ -0,0 +1,8 @@
export function getSignalingUrl(peerName, roomId)
{
const hostname = window.location.hostname;
const port = window.location.port;
const url = `wss://${hostname}:${port}/?peerName=${peerName}&roomId=${roomId}`;
return url;
}

40
app/lib/utils.js Normal file
View file

@ -0,0 +1,40 @@
let mediaQueryDetectorElem;
export function initialize()
{
// Media query detector stuff.
mediaQueryDetectorElem =
document.getElementById('multiparty-meeting-media-query-detector');
return Promise.resolve();
}
export function isDesktop()
{
return Boolean(mediaQueryDetectorElem.offsetParent);
}
export function isMobile()
{
return !mediaQueryDetectorElem.offsetParent;
}
/**
* Create a function which will call the callback function
* after the given amount of milliseconds has passed since
* the last time the callback function was called.
*/
export const idle = (callback, delay) =>
{
let handle;
return () =>
{
if (handle)
{
clearTimeout(handle);
}
handle = setTimeout(callback, delay);
};
};

View file

@ -1,116 +1,82 @@
{
"name": "edumeet",
"version": "3.5.5",
"private": true,
"description": "edumeet meeting service",
"author": "Håvar Aambø Fosstveit <h@fosstveit.net>",
"contributors": [
"Stefan Otto",
"Mészáros Mihály",
"Roman Drozd",
"Rémai Gábor László",
"Piotr Pawałowski"
],
"license": "MIT",
"homepage": "./",
"main": "src/electron-starter.js",
"type": "module",
"dependencies": {
"@material-ui/core": "^4.11.3",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "^4.0.0-alpha.57",
"@react-hook/window-size": "^3.0.7",
"@types/node": "^14.14.37",
"@types/react": "^17.0.3",
"@types/react-dom": "^17.0.3",
"bowser": "^2.7.0",
"chroma-js": "^2.1.1",
"classnames": "^2.2.6",
"convict": "^6.0.1",
"convict-format-with-validator": "^6.0.1",
"create-torrent": "^4.4.1",
"deep-object-diff": "^1.1.0",
"dompurify": "^2.0.7",
"domready": "^1.0.8",
"draft-js": "^0.11.7",
"draft-js-export-html": "^1.4.1",
"draft-js-plugins-editor": "^3.0.0",
"draft-js-single-line-plugin": "^2.0.5",
"end-of-stream": "1.4.1",
"file-saver": "^2.0.2",
"hark": "^1.2.3",
"idb": "^6.0.0",
"is-electron": "^2.2.0",
"marked": "^0.8.0",
"material-ui-popup-state": "^1.8.0",
"mediasoup-client": "^3.6.55",
"notistack": "^0.9.5",
"prop-types": "^15.7.2",
"random-string": "^0.2.0",
"react": "^16.10.2",
"react-cookie-consent": "^2.5.0",
"react-dom": "^16.10.2",
"react-flip-toolkit": "^7.0.9",
"react-image-file-resizer": "^0.3.8",
"react-images-upload": "^1.2.0",
"react-intl": "^3.4.0",
"react-intl-redux": "^2.2.0",
"react-redux": "^7.2.3",
"react-router-dom": "^5.1.2",
"react-scripts": "^4.0.3",
"react-wakelock-react16": "0.0.7",
"redux": "^4.0.4",
"redux-logger": "^3.0.6",
"redux-persist": "^6.0.0",
"redux-persist-transform-filter": "0.0.20",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"riek": "^1.1.0",
"socket.io-client": "^2.4.0",
"source-map-explorer": "^2.1.0",
"streamsaver": "^2.0.5",
"typescript": "^4.2.4",
"universal-cookie": "4.0.4",
"web-streams-polyfill": "^3.0.2",
"webtorrent": "^0.108.1"
},
"scripts": {
"analyze": "source-map-explorer build/static/js/*",
"start": "HTTPS=true PORT=4443 react-scripts start",
"build": "react-scripts build && rm -rf ../server/public && DEST='../server/dist/public' && rm -rf $DEST && mkdir -p $DEST && mv -T build/ $DEST",
"test": "react-scripts test",
"eject": "react-scripts eject",
"electron": "electron --no-sandbox .",
"dev": "nf start -p 3000",
"lint": "eslint ./ --ext .js,.jsx,.ts,.tsx; exit 0",
"lint-fix": "eslint ./ --fix --ext .js,.jsx,.ts,.tsx; exit 0",
"gen-config-docs": "node --loader ts-node/esm src/config.ts && eslint -c .eslintrc.json public/config/config.example.js --fix"
},
"browserslist": [
">0.2%",
"not dead",
"not ie > 0",
"not op_mini all"
],
"devDependencies": {
"@types/chroma-js": "^2.1.3",
"@types/convict": "^6.0.1",
"@types/convict-format-with-validator": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^4.20.0",
"@typescript-eslint/parser": "^4.20.0",
"babel-eslint": "^10.1.0",
"electron": "^12.0.0",
"eslint-config-react-app": "^6.0.0",
"eslint-import-resolver-typescript": "^2.4.0",
"eslint-plugin-flowtype": "^5.4.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jest": "^24.3.3",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-react": "^7.23.1",
"eslint-plugin-react-hooks": "^4.2.0",
"eslint-webpack-plugin": "^2.5.3",
"foreman": "^3.0.1",
"redux-mock-store": "^1.5.3",
"ts-node": "^10.9.1"
}
"name": "multiparty-meeting",
"version": "1.1.0",
"private": true,
"description": "multiparty meeting service",
"author": "Håvar Aambø Fosstveit <h@fosstveit.net>",
"license": "MIT",
"main": "lib/index.jsx",
"dependencies": {
"@babel/runtime": "^7.1.2",
"classnames": "^2.2.6",
"create-torrent": "^3.32.1",
"debug": "^4.1.0",
"domready": "^1.0.8",
"drag-drop": "^4.2.0",
"file-saver": "^1.3.8",
"hark": "^1.2.2",
"js-cookie": "^2.2.0",
"magnet-uri": "^5.2.3",
"marked": "^0.5.1",
"mediasoup-client": "^2.4.9",
"prop-types": "^15.6.2",
"random-string": "^0.2.0",
"react": "^16.5.2",
"react-cookie-consent": "^1.9.0",
"react-copy-to-clipboard": "^5.0.1",
"react-dom": "^16.5.2",
"react-draggable": "^3.0.5",
"react-dropdown": "^1.5.0",
"react-redux": "^5.0.7",
"react-spinner": "^0.2.7",
"react-tooltip": "^3.9.0",
"react-transition-group": "^2.5.0",
"redux": "^4.0.1",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0",
"resize-observer-polyfill": "^1.5.0",
"riek": "^1.1.0",
"socket.io-client": "^2.1.1",
"url-parse": "^1.4.3",
"webtorrent": "^0.102.4"
},
"devDependencies": {
"@babel/core": "^7.1.2",
"@babel/plugin-proposal-class-properties": "^7.1.0",
"@babel/plugin-proposal-object-rest-spread": "^7.0.0",
"@babel/plugin-transform-runtime": "^7.1.0",
"@babel/preset-env": "^7.1.0",
"@babel/preset-react": "^7.0.0",
"babel-plugin-jsx-control-statements": "^3.2.8",
"babel-eslint": "^10.0.1",
"babelify": "^10.0.0",
"browser-sync": "^2.26.3",
"browserify": "^16.2.3",
"del": "^3.0.0",
"envify": "^4.1.0",
"eslint": "^5.7.0",
"eslint-plugin-import": "^2.14.0",
"eslint-plugin-jsx-control-statements": "^2.2.1",
"eslint-plugin-react": "^7.11.1",
"gulp": "^4.0.0",
"gulp-change": "^1.0.0",
"gulp-css-base64": "^1.3.4",
"gulp-eslint": "^5.0.0",
"gulp-header": "^2.0.5",
"gulp-if": "^2.0.2",
"gulp-plumber": "^1.2.0",
"gulp-rename": "^1.4.0",
"gulp-stylus": "^2.7.0",
"gulp-touch-cmd": "0.0.1",
"gulp-uglify-es": "^1.0.4",
"gulp-util": "^3.0.8",
"lodash": "^4.17.10",
"mkdirp": "^0.5.1",
"ncp": "^2.0.0",
"nib": "^1.1.2",
"supports-color": "^5.5.0",
"vinyl-buffer": "^1.0.1",
"vinyl-source-stream": "^2.0.0",
"watchify": "^3.11.0"
}
}

View file

@ -1,65 +0,0 @@
# ![edumeet logo](/app/public/images/logo.edumeet.svg) App Configuration properties list:
| Name | Description | Format | Default value |
| :--- | :---------- | :----- | :------------ |
| loginEnabled | If the login is enabled. | `"boolean"` | ``false`` |
| developmentPort | The development server listening port. | `"port"` | ``3443`` |
| productionPort | The production server listening port. | `"port"` | ``443`` |
| serverHostname | If the server component runs on a different host than the app you can specify the host name. | `"string"` | ``""`` |
| supportedBrowsers | Supported browsers version in bowser satisfy format. | `"object"` | ``{ "windows": { "internet explorer": ">12", "microsoft edge": ">18" }, "microsoft edge": ">18", "safari": ">12", "firefox": ">=60", "chrome": ">=74", "chromium": ">=74", "opera": ">=62", "samsung internet for android": ">=11.1.1.52"}`` |
| networkPriorities | Network priorities. | `"object"` | ``{ "audio": "high", "mainVideo": "high", "additionalVideos": "medium", "screenShare": "medium"}`` |
| aspectRatio | The aspect ratio of the videos as shown on the screen. This value must match exactly one of the values defined in aspectRatios. | `"float"` | ``1.777`` |
| aspectRatios | The selectable aspect ratios in the user settings. | `"array"` | ``[ { "value": 1.333, "label": "4 : 3" }, { "value": 1.777, "label": "16 : 9" }]`` |
| resolution | The default video camera capture resolution. | `[ "low", "medium", "high", "veryhigh", "ultra"]` | ``"medium"`` |
| frameRate | The default video camera capture framerate. | `"nat"` | ``15`` |
| screenResolution | The default screen sharing resolution. | `[ "low", "medium", "high", "veryhigh", "ultra"]` | ``"veryhigh"`` |
| screenSharingFrameRate | The default screen sharing framerate. | `"nat"` | ``5`` |
| simulcast | Enable or disable simulcast for webcam video. | `"boolean"` | ``true`` |
| simulcastSharing | Enable or disable simulcast for screen sharing video. | `"boolean"` | ``false`` |
| simulcastProfiles | Define different encodings for various resolutions of the video. | `"object"` | ``{ "320": [ { "scaleResolutionDownBy": 1, "maxBitRate": 150000 } ], "640": [ { "scaleResolutionDownBy": 2, "maxBitRate": 150000 }, { "scaleResolutionDownBy": 1, "maxBitRate": 500000 } ], "1280": [ { "scaleResolutionDownBy": 4, "maxBitRate": 150000 }, { "scaleResolutionDownBy": 2, "maxBitRate": 500000 }, { "scaleResolutionDownBy": 1, "maxBitRate": 1200000 } ], "1920": [ { "scaleResolutionDownBy": 6, "maxBitRate": 150000 }, { "scaleResolutionDownBy": 3, "maxBitRate": 500000 }, { "scaleResolutionDownBy": 1, "maxBitRate": 3500000 } ], "3840": [ { "scaleResolutionDownBy": 12, "maxBitRate": 150000 }, { "scaleResolutionDownBy": 6, "maxBitRate": 500000 }, { "scaleResolutionDownBy": 1, "maxBitRate": 10000000 } ]}`` |
| adaptiveScalingFactor | The adaptive spatial layer selection scaling factor in the range [0.5, 1.0]. | | ``0.75`` |
| localRecordingEnabled | If set to true Local Recording feature will be enabled. | `"boolean"` | ``false`` |
| audioOutputSupportedBrowsers | White listing browsers that support audio output device selection. | `"array"` | ``[ "chrome", "opera"]`` |
| requestTimeout | The Socket.io request timeout. | `"nat"` | ``20000`` |
| requestRetries | The Socket.io request maximum retries. | `"nat"` | ``3`` |
| transportOptions | The Mediasoup transport options. | `"object"` | ``{ "tcp": true}`` |
| autoGainControl | Auto gain control enabled. | `"boolean"` | ``true`` |
| echoCancellation | Echo cancellation enabled. | `"boolean"` | ``true`` |
| noiseSuppression | Noise suppression enabled. | `"boolean"` | ``true`` |
| voiceActivatedUnmute | Automatically unmute speaking above noiseThreshold. | `"boolean"` | ``false`` |
| noiseThreshold | This is only for voiceActivatedUnmute and audio-indicator. | `"int"` | ``-60`` |
| sampleRate | The audio sample rate. | `[ 8000, 16000, 24000, 44100, 48000]` | ``48000`` |
| channelCount | The audio channels count. | `[ 1, 2]` | ``1`` |
| sampleSize | The audio sample size count. | `[ 8, 16, 24, 32]` | ``16`` |
| opusStereo | If OPUS FEC stereo be enabled. | `"boolean"` | ``false`` |
| opusDtx | If OPUS DTX should be enabled. | `"boolean"` | ``true`` |
| opusFec | If OPUS FEC should be enabled. | `"boolean"` | ``true`` |
| opusPtime | The OPUS packet time. | `[ 3, 5, 10, 20, 30, 40, 50, 60]` | ``20`` |
| opusMaxPlaybackRate | The OPUS playback rate. | `[ 8000, 16000, 24000, 44100, 48000]` | ``48000`` |
| audioPreset | The audio preset | `"string"` | ``"conference"`` |
| audioPresets | The audio presets. | `"object"` | ``{ "conference": { "name": "Conference audio", "autoGainControl": true, "echoCancellation": true, "noiseSuppression": true, "voiceActivatedUnmute": false, "noiseThreshold": -60, "sampleRate": 48000, "channelCount": 1, "sampleSize": 16, "opusStereo": false, "opusDtx": true, "opusFec": true, "opusPtime": 20, "opusMaxPlaybackRate": 48000 }, "hifi": { "name": "HiFi streaming", "autoGainControl": false, "echoCancellation": false, "noiseSuppression": false, "voiceActivatedUnmute": false, "noiseThreshold": -60, "sampleRate": 48000, "channelCount": 2, "sampleSize": 16, "opusStereo": true, "opusDtx": false, "opusFec": true, "opusPtime": 60, "opusMaxPlaybackRate": 48000 }}`` |
| autoMuteThreshold | It sets the maximum number of participants in one room that can join unmuted. The next participant will join automatically muted. Set it to 0 to auto mute all. Set it to negative (-1) to never automatically auto mute but use it with caution, full mesh audio strongly decrease room capacity! | `"nat"` | ``4`` |
| background | The page background image URL | `"string"` | ``"images/background.jpg"`` |
| defaultLayout | The default layout. | `[ "democratic", "filmstrip"]` | ``"democratic"`` |
| buttonControlBar | If true, the media control buttons will be shown in separate control bar, not in the ME container. | `"boolean"` | ``false`` |
| drawerOverlayed | If false, will push videos away to make room for side drawer. If true, will overlay side drawer over videos. | `"boolean"` | ``true`` |
| notificationPosition | The position of the notifications. | `[ "left", "right"]` | ``"right"`` |
| notificationSounds | It sets the notifications sounds. Valid keys are: 'parkedPeer', 'parkedPeers', 'raisedHand', 'chatMessage', 'sendFile', 'newPeer' and 'default'. Not defining a key is equivalent to using the default notification sound. Setting 'play' to null disables the sound notification. | `"object"` | ``{ "chatMessage": { "play": "/sounds/notify-chat.mp3" }, "raisedHand": { "play": "/sounds/notify-hand.mp3" }, "default": { "delay": 5000, "play": "/sounds/notify.mp3" }}`` |
| hideTimeout | Timeout for auto hiding the topbar and the buttons control bar. | `"int"` | ``3000`` |
| lastN | The maximum number of participants that will be visible in as speaker. | `"nat"` | ``4`` |
| mobileLastN | The maximum number of participants that will be visible in as speaker for mobile users. | `"nat"` | ``1`` |
| maxLastN | The highest number of lastN the user can select manually in the user interface. | `"nat"` | ``5`` |
| lockLastN | If true, the users can not change the number of visible speakers. | `"boolean"` | ``false`` |
| logo | If not null, it shows the logo loaded from the specified URL, otherwise it shows the title. | `"url"` | ``"images/logo.edumeet.svg"`` |
| title | The title to show if the logo is not specified. | `"string"` | ``"edumeet"`` |
| infoTooltipText | Informative text on the join page. If empty hidden. | `"string"` | ``""`` |
| infoTooltipLink | Informative text link site on the join page. If empty hidden. | `"string"` | ``""`` |
| infoTooltipDesc | Informative text description on the join page. If empty hidden. | `"string"` | ``""`` |
| supportUrl | The service & Support URL; if `null`, it will be not displayed on the about dialogs. | `"url"` | ``"https://support.example.com"`` |
| privacyUrl | The privacy and data protection external URL or local HTML path. | `"string"` | ``"privacy/privacy.html"`` |
| theme | UI theme elements colors. | `"object"` | ``{ "palette": { "primary": { "main": "#313131" } }, "overrides": { "MuiAppBar": { "colorPrimary": { "backgroundColor": "#313131" } }, "MuiButton": { "containedPrimary": { "backgroundColor": "#5F9B2D", "&:hover": { "backgroundColor": "#5F9B2D" } }, "containedSecondary": { "backgroundColor": "#f50057", "&:hover": { "backgroundColor": "#f50057" } } }, "MuiFab": { "primary": { "backgroundColor": "#518029", "&:hover": { "backgroundColor": "#518029" }, "&:disabled": { "color": "#999898", "backgroundColor": "#323131" } }, "secondary": { "backgroundColor": "#f50057", "&:hover": { "backgroundColor": "#f50057" }, "&:disabled": { "color": "#999898", "backgroundColor": "#323131" } } }, "MuiBadge": { "colorPrimary": { "backgroundColor": "#5F9B2D", "&:hover": { "backgroundColor": "#518029" } } } }, "typography": { "useNextVariants": true }}`` |
---
*Document generated with:* `yarn gen-config-docs` *from:* [config.ts](src/config.ts).

View file

@ -1,386 +0,0 @@
/**
* Edumeet App Configuration
*
* The configuration documentation is available also:
* - in the app/README.md file in the source tree
* - visiting the /?config=true page in a running instance
*/
// eslint-disable-next-line
var config = {
// If the login is enabled.
loginEnabled : false,
// The development server listening port.
developmentPort : 3443,
// The production server listening port.
productionPort : 443,
// If the server component runs on a different host than the app you can specify the host name.
serverHostname : '',
// Supported browsers version in bowser satisfy format.
supportedBrowsers : {
'windows' : {
'internet explorer' : '>12',
'microsoft edge' : '>18'
},
'microsoft edge' : '>18',
'safari' : '>12',
'firefox' : '>=60',
'chrome' : '>=74',
'chromium' : '>=74',
'opera' : '>=62',
'samsung internet for android' : '>=11.1.1.52'
},
// Network priorities.
networkPriorities : {
'audio' : 'high',
'mainVideo' : 'high',
'additionalVideos' : 'medium',
'screenShare' : 'medium'
},
// The aspect ratio of the videos as shown on the screen.
// This value must match exactly one of the values defined in aspectRatios.
aspectRatio : 1.777,
// The selectable aspect ratios in the user settings.
aspectRatios : [
{
'value' : 1.333,
'label' : '4 : 3'
},
{
'value' : 1.777,
'label' : '16 : 9'
}
],
// The default video camera capture resolution.
resolution : 'medium',
// The default video camera capture framerate.
frameRate : 15,
// The default screen sharing resolution.
screenResolution : 'veryhigh',
// The default screen sharing framerate.
screenSharingFrameRate : 5,
// Enable or disable simulcast for webcam video.
simulcast : true,
// Enable or disable simulcast for screen sharing video.
simulcastSharing : false,
// Define different encodings for various resolutions of the video.
simulcastProfiles : {
'320' : [
{
'scaleResolutionDownBy' : 1,
'maxBitRate' : 150000
}
],
'640' : [
{
'scaleResolutionDownBy' : 2,
'maxBitRate' : 150000
},
{
'scaleResolutionDownBy' : 1,
'maxBitRate' : 500000
}
],
'1280' : [
{
'scaleResolutionDownBy' : 4,
'maxBitRate' : 150000
},
{
'scaleResolutionDownBy' : 2,
'maxBitRate' : 500000
},
{
'scaleResolutionDownBy' : 1,
'maxBitRate' : 1200000
}
],
'1920' : [
{
'scaleResolutionDownBy' : 6,
'maxBitRate' : 150000
},
{
'scaleResolutionDownBy' : 3,
'maxBitRate' : 500000
},
{
'scaleResolutionDownBy' : 1,
'maxBitRate' : 3500000
}
],
'3840' : [
{
'scaleResolutionDownBy' : 12,
'maxBitRate' : 150000
},
{
'scaleResolutionDownBy' : 6,
'maxBitRate' : 500000
},
{
'scaleResolutionDownBy' : 1,
'maxBitRate' : 10000000
}
]
},
// The adaptive spatial layer selection scaling factor in the range [0.5, 1.0].
adaptiveScalingFactor : 0.75,
// If set to true Local Recording feature will be enabled.
localRecordingEnabled : false,
// White listing browsers that support audio output device selection.
audioOutputSupportedBrowsers : [
'chrome',
'opera'
],
// The Socket.io request timeout.
requestTimeout : 20000,
// The Socket.io request maximum retries.
requestRetries : 3,
// The Mediasoup transport options.
transportOptions : {
'tcp' : true
},
// Auto gain control enabled.
autoGainControl : true,
// Echo cancellation enabled.
echoCancellation : true,
// Noise suppression enabled.
noiseSuppression : true,
// Automatically unmute speaking above noiseThreshold.
voiceActivatedUnmute : false,
// This is only for voiceActivatedUnmute and audio-indicator.
noiseThreshold : -60,
// The audio sample rate.
sampleRate : 48000,
// The audio channels count.
channelCount : 1,
// The audio sample size count.
sampleSize : 16,
// If OPUS FEC stereo be enabled.
opusStereo : false,
// If OPUS DTX should be enabled.
opusDtx : true,
// If OPUS FEC should be enabled.
opusFec : true,
// The OPUS packet time.
opusPtime : 20,
// The OPUS playback rate.
opusMaxPlaybackRate : 48000,
// The audio preset
audioPreset : 'conference',
// The audio presets.
audioPresets : {
'conference' : {
'name' : 'Conference audio',
'autoGainControl' : true,
'echoCancellation' : true,
'noiseSuppression' : true,
'voiceActivatedUnmute' : false,
'noiseThreshold' : -60,
'sampleRate' : 48000,
'channelCount' : 1,
'sampleSize' : 16,
'opusStereo' : false,
'opusDtx' : true,
'opusFec' : true,
'opusPtime' : 20,
'opusMaxPlaybackRate' : 48000
},
'hifi' : {
'name' : 'HiFi streaming',
'autoGainControl' : false,
'echoCancellation' : false,
'noiseSuppression' : false,
'voiceActivatedUnmute' : false,
'noiseThreshold' : -60,
'sampleRate' : 48000,
'channelCount' : 2,
'sampleSize' : 16,
'opusStereo' : true,
'opusDtx' : false,
'opusFec' : true,
'opusPtime' : 60,
'opusMaxPlaybackRate' : 48000
}
},
// It sets the maximum number of participants in one room that can join unmuted.
// The next participant will join automatically muted.
// Set it to 0 to auto mute all.
// Set it to negative (-1) to never automatically auto mute but use it with caution,
// full mesh audio strongly decrease room capacity!
autoMuteThreshold : 4,
// The page background image URL
background : 'images/background.jpg',
// The default layout.
defaultLayout : 'democratic',
// If true, the media control buttons will be shown in separate control bar, not in the ME container.
buttonControlBar : false,
// If false, will push videos away to make room for side drawer.
// If true, will overlay side drawer over videos.
drawerOverlayed : true,
// The position of the notifications.
notificationPosition : 'right',
// It sets the notifications sounds.
// Valid keys are: 'parkedPeer', 'parkedPeers', 'raisedHand',
// 'chatMessage', 'sendFile', 'newPeer' and 'default'.
// Not defining a key is equivalent to using the default notification sound.
// Setting 'play' to null disables the sound notification.
//
notificationSounds : {
'chatMessage' : {
'play' : '/sounds/notify-chat.mp3'
},
'raisedHand' : {
'play' : '/sounds/notify-hand.mp3'
},
'default' : {
'delay' : 5000,
'play' : '/sounds/notify.mp3'
}
},
// Timeout for auto hiding the topbar and the buttons control bar.
hideTimeout : 3000,
// The maximum number of participants that will be visible in as speaker.
lastN : 4,
// The maximum number of participants that will be visible in as speaker for mobile users.
mobileLastN : 1,
// The highest number of lastN the user can select manually in the user interface.
maxLastN : 5,
// If true, the users can not change the number of visible speakers.
lockLastN : false,
// If not null, it shows the logo loaded from the specified URL, otherwise it shows the title.
logo : 'images/logo.edumeet.svg',
// The title to show if the logo is not specified.
title : 'edumeet',
// Informative text on the join page. If empty hidden.
infoTooltipText : '',
// Informative text link site on the join page. If empty hidden.
infoTooltipLink : '',
// Informative text description on the join page. If empty hidden.
infoTooltipDesc : '',
// The service & Support URL; if `null`, it will be not displayed on the about dialogs.
supportUrl : 'https://support.example.com',
// The privacy and data protection external URL or local HTML path.
privacyUrl : 'privacy/privacy.html',
// UI theme elements colors.
theme : {
'palette' : {
'primary' : {
'main' : '#313131'
}
},
'overrides' : {
'MuiAppBar' : {
'colorPrimary' : {
'backgroundColor' : '#313131'
}
},
'MuiButton' : {
'containedPrimary' : {
'backgroundColor' : '#5F9B2D',
'&:hover' : {
'backgroundColor' : '#5F9B2D'
}
},
'containedSecondary' : {
'backgroundColor' : '#f50057',
'&:hover' : {
'backgroundColor' : '#f50057'
}
}
},
'MuiFab' : {
'primary' : {
'backgroundColor' : '#518029',
'&:hover' : {
'backgroundColor' : '#518029'
},
'&:disabled' : {
'color' : '#999898',
'backgroundColor' : '#323131'
}
},
'secondary' : {
'backgroundColor' : '#f50057',
'&:hover' : {
'backgroundColor' : '#f50057'
},
'&:disabled' : {
'color' : '#999898',
'backgroundColor' : '#323131'
}
}
},
'MuiBadge' : {
'colorPrimary' : {
'backgroundColor' : '#5F9B2D',
'&:hover' : {
'backgroundColor' : '#518029'
}
}
}
},
'typography' : {
'useNextVariants' : true
}
}
};
// Generated with: `yarn gen-config-docs` from app/src/config.ts

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

View file

@ -1,88 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="svg2"
xml:space="preserve"
width="139"
height="30"
viewBox="0 0 799.66669 172.29333"
sodipodi:docname="edumeet_logo.eps"
inkscape:version="0.92.4 (5da689c313, 2019-01-14)"><metadata
id="metadata8"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
id="defs6" /><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1916"
inkscape:window-height="1014"
id="namedview4"
showgrid="false"
inkscape:zoom="0.53772404"
inkscape:cx="399.83334"
inkscape:cy="86.146667"
inkscape:window-x="0"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="g10" /><g
id="g10"
inkscape:groupmode="layer"
inkscape:label="ink_ext_XXXXXX"
transform="matrix(1.3333333,0,0,-1.3333333,0,172.29333)"><g
id="g12"
transform="scale(0.1)"><path
d="m 1667.64,373.02 c 0,0 -193.65,11.343 -394.11,119.109 72.23,293.047 -69.56,605.841 -353.264,738.851 C 597.211,1382.37 212.637,1243.22 61.2031,920.25 -90.1719,597.238 48.9063,212.57 371.965,61.1602 659.195,-73.4609 995.113,21.8086 1172.46,271.719 c 248.85,5.543 495.18,101.301 495.18,101.301"
style="fill:#3871c1;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path14"
inkscape:connector-curvature="0" /><path
d="m 2241.9,497.863 c 1.02,45.887 -19.37,121.34 -102.98,121.34 -77.5,0 -110.14,-70.355 -115.23,-121.34 z m -218.21,-87.687 c 3.06,-89.742 73.42,-128.485 152.96,-128.485 58.12,0 99.91,8.161 137.64,22.434 l 18.37,-86.676 c -42.83,-17.34 -101.98,-30.59 -173.35,-30.59 -161.11,0 -255.95,98.922 -255.95,250.844 0,137.652 83.62,267.16 242.68,267.16 161.12,0 214.15,-132.562 214.15,-241.672 0,-23.453 -2.05,-41.8 -4.09,-53.015 h -332.41"
style="fill:#004d7b;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path16"
inkscape:connector-curvature="0" /><path
d="m 2793.57,486.648 c 0,10.196 -1.01,22.434 -3.06,32.629 -11.2,48.946 -50.97,88.711 -108.08,88.711 -80.55,0 -125.42,-71.375 -125.42,-164.168 0,-90.75 44.87,-157.031 124.41,-157.031 50.98,0 95.85,34.672 108.08,88.715 3.06,11.215 4.07,23.449 4.07,36.703 z m 125.42,434.379 V 336.754 c 0,-50.973 2.05,-106.043 4.09,-139.703 h -112.17 l -5.1,78.527 h -2.05 c -29.55,-55.066 -89.71,-89.738 -162.11,-89.738 -118.29,0 -212.1,100.949 -212.1,253.898 -1.01,166.211 102.98,265.125 222.29,265.125 68.32,0 117.27,-28.554 139.69,-65.261 h 2.04 v 281.425 h 125.42"
style="fill:#004d7b;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path18"
inkscape:connector-curvature="0" /><path
d="m 3498.2,345.93 c 0,-59.141 2.04,-108.078 4.09,-148.879 h -110.13 l -6.12,75.461 h -2.05 c -21.4,-35.684 -70.36,-86.672 -159.06,-86.672 -90.75,0 -173.35,54.051 -173.35,216.172 V 693.641 H 3177 V 423.422 c 0,-82.586 26.52,-135.613 92.79,-135.613 49.98,0 82.6,35.691 95.85,67.3 4.09,11.211 7.14,24.469 7.14,38.746 V 693.641 H 3498.2 V 345.93"
style="fill:#004d7b;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path20"
inkscape:connector-curvature="0" /><path
d="m 4219.13,477.465 c -4.09,88.715 -9.19,195.781 -8.15,288.57 h -3.06 c -22.44,-83.609 -51,-176.398 -81.58,-263.07 l -99.93,-297.754 h -96.87 l -91.78,293.672 c -26.5,87.687 -50.97,181.5 -69.34,267.152 h -2.02 c -3.08,-89.726 -7.14,-198.836 -12.24,-292.648 L 3738.87,197.051 H 3621.6 l 45.88,687.273 h 165.19 l 89.73,-276.336 c 24.48,-80.558 45.88,-162.129 63.23,-238.609 h 3.04 c 18.37,74.441 41.82,159.07 68.32,239.629 l 94.84,275.316 h 163.14 l 39.78,-687.273 h -122.38 l -13.24,280.414"
style="fill:#3871c1;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path22"
inkscape:connector-curvature="0" /><path
d="M 4866.67,501.941 H 4607.68 V 300.043 h 289.58 V 197.051 h -415 v 687.273 h 399.7 V 781.336 H 4607.68 V 603.91 h 258.99 V 501.941"
style="fill:#3871c1;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path24"
inkscape:connector-curvature="0" /><path
d="m 5391.83,501.941 h -259 V 300.043 h 289.59 V 197.051 h -415.01 v 687.273 h 399.71 V 781.336 H 5132.83 V 603.91 h 259 V 501.941"
style="fill:#3871c1;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path26"
inkscape:connector-curvature="0" /><path
d="m 5674.31,779.297 h -195.78 v 105.027 h 519.02 V 779.297 H 5799.73 V 197.051 h -125.42 v 582.246"
style="fill:#3871c1;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path28"
inkscape:connector-curvature="0" /><path
d="m 804.438,984.711 c -187.032,87.649 -409.68,7.086 -497.36,-179.902 -55.531,-118.496 -43.469,-251.282 20.344,-355 -108.164,-67.911 -203.207,-92.024 -203.207,-92.024 0,0 143.82,-26.316 302.43,-14.781 18.66,-13.457 38.761,-25.453 60.351,-35.578 186.984,-87.625 409.707,-7.102 497.359,179.906 87.675,187.047 7.114,409.691 -179.917,497.379"
style="fill:#f9a72b;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path30"
inkscape:connector-curvature="0" /><path
d="M 831.895,558.988 C 783.809,456.395 661.621,412.215 559.043,460.293 456.438,508.387 412.262,630.559 460.344,733.152 c 48.094,102.582 170.242,146.778 272.847,98.696 102.61,-48.106 146.797,-170.246 98.704,-272.86"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
id="path32"
inkscape:connector-curvature="0" /></g></g></svg>

Before

Width:  |  Height:  |  Size: 6.2 KiB

View file

@ -1,63 +0,0 @@
<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='utf-8' />
<meta
name='viewport'
content='width=device-width, initial-scale=1, shrink-to-fit=no'
/>
<meta name='description' content='edumeet - Simple web meetings'>
<meta name="keywords" content="edumeet,letsmeet,multiparty,videoconference,meet,WebRTC,room,rooms,geant,video conferencing, video conference, online meetings, web meeting, video meeting, cloud meeting, cloud video, group video call, group video chat, screen share, application share, mobility, mobile collaboration, desktop share, video collaboration">
<meta name='theme-color' content='#000000' />
<link rel='preconnect' href='https://fonts.googleapis.com/css?family=Roboto' rel='stylesheet'>
<link rel='shortcut icon' href='%PUBLIC_URL%/favicon.png' />
<link rel='manifest' href='%PUBLIC_URL%/manifest.json' />
<title>edumeet</title>
<script src='%PUBLIC_URL%/config/config.js' type='text/javascript'></script>
<!-- Show an error page to IE browsers -->
<script type='text/javascript'>
var fallback = '<style type="text/css">body{margin:40px auto;max-width:650px;line-height:1.6;font-size:18px;color:#444;padding:0 10px}h1,h2,h3{line-height:1.2}</style><header><h1>Your browser is not supported</h1><aside>You need to change to a different browser.</aside></header><h3>Supported browsers</h3><ul><li>Google Chrome/Chromium 55 +</li><li>Microsoft Edge 18 +</li><li>Mozilla Firefox 60 +</li><li>Apple Safari 12 +</li><li>Opera 62 +</li><li>Samsung Internet 11.1.1.52 +</li></ul>';
var fallbackCall = function() {
document.body.innerHTML = fallback;
};
if(navigator.userAgent.indexOf('MSIE') !== -1)
{
document.attachEvent('onreadystatechange', function() {
if (document.readyState === 'complete')
{
document.detachEvent('onreadystatechange', arguments.callee);
fallbackCall();
}
});
}
if (navigator.appVersion.indexOf('Trident/') > -1)
{
if (
document.readyState === 'complete' ||
(document.readyState !== 'loading' && !document.documentElement.doScroll)
)
{
document.removeEventListener('DOMContentLoaded', fallbackCall);
fallbackCall();
}
else
{
document.addEventListener('DOMContentLoaded', fallbackCall);
}
}
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id='edumeet'></div>
</body>
</html>

View file

@ -1,15 +0,0 @@
{
"short_name": "edumeet",
"name": "edumeet - Simple web meetings",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View file

@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='UTF-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<title>Placeholder for Privacy Statement / Policy, AUP</title>
<style type='text/css'>body{margin:40px auto;max-width:650px;line-height:1.6;font-size:18px;color:#444;padding:0 10px}h1,h2,h3{line-height:1.2}</style>
</head>
<body>
<header><h1>Privacy Statement</h1></header>
<h2>Privacy Policy</h2>
<ul>
<li>User consent</li>
<li>Data deletion</li>
</ul>
<h2>Acceptable use policy (AUP)</h2>
</body>
</html>

View file

@ -1,3 +0,0 @@
# Allow crawling of all content
User-agent: *
Disallow:

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show more