ソースを参照

initial release of galene plugin for converse

Dele Olajide 3 年 前
コミット
6b76da38a5
36 ファイル変更8540 行追加0 行削除
  1. 3 0
      index.html
  2. 44 0
      packages/galene/common.css
  3. 0 0
      packages/galene/converse-galene.js
  4. 21 0
      packages/galene/external/contextual/LICENSE
  5. 144 0
      packages/galene/external/contextual/contextual.css
  6. 234 0
      packages/galene/external/contextual/contextual.js
  7. 5 0
      packages/galene/external/fontawesome/css/all.min.css
  8. 5 0
      packages/galene/external/fontawesome/css/brands.min.css
  9. 5 0
      packages/galene/external/fontawesome/css/fontawesome.min.css
  10. 6 0
      packages/galene/external/fontawesome/css/regular.min.css
  11. 6 0
      packages/galene/external/fontawesome/css/solid.min.css
  12. 5 0
      packages/galene/external/fontawesome/css/svg-with-js.min.css
  13. 6 0
      packages/galene/external/fontawesome/css/v4-font-face.min.css
  14. 5 0
      packages/galene/external/fontawesome/css/v4-shims.min.css
  15. 6 0
      packages/galene/external/fontawesome/css/v5-font-face.min.css
  16. BIN
      packages/galene/external/fontawesome/webfonts/fa-brands-400.ttf
  17. BIN
      packages/galene/external/fontawesome/webfonts/fa-brands-400.woff2
  18. BIN
      packages/galene/external/fontawesome/webfonts/fa-regular-400.ttf
  19. BIN
      packages/galene/external/fontawesome/webfonts/fa-regular-400.woff2
  20. BIN
      packages/galene/external/fontawesome/webfonts/fa-solid-900.ttf
  21. BIN
      packages/galene/external/fontawesome/webfonts/fa-solid-900.woff2
  22. BIN
      packages/galene/external/fontawesome/webfonts/fa-v4compatibility.ttf
  23. BIN
      packages/galene/external/fontawesome/webfonts/fa-v4compatibility.woff2
  24. 21 0
      packages/galene/external/toastify/LICENSE
  25. 85 0
      packages/galene/external/toastify/toastify.css
  26. 438 0
      packages/galene/external/toastify/toastify.js
  27. 40 0
      packages/galene/galene-socket.js
  28. 3984 0
      packages/galene/galene-ui.js
  29. 1351 0
      packages/galene/galene.css
  30. 374 0
      packages/galene/galene.js
  31. BIN
      packages/galene/galene.png
  32. 290 0
      packages/galene/index.html
  33. 17 0
      packages/galene/package.json
  34. 1421 0
      packages/galene/protocol.js
  35. 24 0
      packages/galene/readme.md
  36. 0 0
      packages/galene/stophe.min.js

+ 3 - 0
index.html

@@ -51,6 +51,8 @@
 
     <link type="text/css" rel="stylesheet" media="screen" href="packages/polls/polls.css" />	
     <script src="packages/polls/polls.js"></script>	
+		
+    <script src="packages/galene/galene.js"></script>	
 
 </head>
 <body class="reset">
@@ -118,6 +120,7 @@
 			"http-auth", 
 			"actions", 
 			"voicechat", 
+			"galene", 			
 			// "polls" 
 			"adaptive-cards"
 		]

+ 44 - 0
packages/galene/common.css

@@ -0,0 +1,44 @@
+h1 {
+    font-size: 160%;
+}
+
+.inline {
+    display: inline;
+}
+
+.signature {
+    border-top: solid;
+    padding-top: 0;
+    border-width: thin;
+    clear: both;
+    min-height: 3.125rem;
+    text-align: center;
+}
+
+body {
+    overflow-x: hidden;
+}
+body, html {
+    height: 100%;
+}
+body {
+    margin: 0;
+    font-family: Metropolis,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;
+    font-size: 1rem;
+    font-weight: 400;
+    line-height: 1.5;
+    color: #687281;
+    text-align: left;
+    background-color: #eff3f9;
+}
+
+*, :after, :before {
+    box-sizing: border-box;
+}
+
+textarea {
+    font-family: Metropolis,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;
+    font-size: 1rem;
+    font-weight: 400;
+    line-height: 1.5;
+}

+ 0 - 0
packages/galene/converse-galene.js


+ 21 - 0
packages/galene/external/contextual/LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2019 Lucas Reade
+
+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.

+ 144 - 0
packages/galene/external/contextual/contextual.css

@@ -0,0 +1,144 @@
+/* Main context menu outer */
+.contextualMenu{
+    font-size: 13px;
+    position: absolute;
+    padding: 8px 0;
+    background: var(--contextualMenuBg);
+    box-shadow: var(--contextualMenuShadow);
+    border-radius: var(--contextualMenuRadius);
+    margin:0;
+    list-style: none;
+    color: var(--contextualMenuText);
+}
+
+/* Menu seperator item */
+.contextualMenuSeperator{
+    display: block;
+    position: relative;
+    padding: 5px 5px;
+}
+    .contextualMenuSeperator span{
+        display: block;
+        width:100%;
+        height:1px;
+        background: var(--contextualSeperator);
+    }
+
+/* Default menu item */
+.contextualMenuItemOuter {
+    position: relative;
+}
+.contextualMenuItem{
+    display: block;
+    padding: 5px 8px;
+    cursor: default;
+}
+    .contextualMenuItem:hover{
+        background: var(--contextualHover);
+    }
+    .contextualMenuItemIcon{
+        float: left;
+        width:16px;
+        height: 16px;
+    }
+    .contextualMenuItemTitle{
+        text-align: left;
+        line-height: 16px;
+        display: inline-block;
+        padding: 0px 0px 0px 7px;
+    }
+    .contextualMenuItemTip{
+        float: right;
+        padding: 0px 0px 0px 50px;
+        text-align: right;
+        line-height: 16px;
+    }
+    .contextualMenuItemOverflow{
+        float: right;
+        width:16px;
+        height: 16px;
+        padding: 1px 0px 0px 7px;
+    }
+
+        .contextualMenuItemOverflow .contextualMenuItemOverflowLine{
+            display: block;
+            height: 1px;
+            margin: 3px 2px;
+            background: var(--contextualOverflowIcon);
+        }
+        .contextualMenuItemOverflow.hidden{
+            display: none;
+        }
+        
+    .contextualMenuItem.disabled{
+        opacity: 0.4;
+    }
+    .contextualMenuItem.disabled:hover{
+        background: none;
+    }
+
+/* Submenu item */ 
+.contextualSubMenu{
+    padding: 0;
+    margin: 0;
+    background: var(--contextualSubMenuBg);
+    border-radius: var(--contextualMenuRadius);
+    width: 100%;
+    height: auto;
+    max-height: 1000px;
+    transition: max-height 0.5s;
+    overflow: hidden;
+}
+    .contextualSubMenu .contextualMenuItem:hover{
+        background: var(--contextualHover);
+    }
+
+.contextualMenuHidden{
+    max-height: 0;
+}
+
+/* Multi item button */
+.contextualMultiItem{
+    display: flex;
+    position: relative;
+}
+    .contextualMultiItem .contextualMenuItemOuter{
+        flex: auto;
+        display: inline-block;
+    }
+
+/* Hover menu */
+.contextualHoverMenuOuter{
+    position: relative;
+}
+.contextualHoverMenuItem{
+    display: block;
+    padding: 5px 8px;
+    cursor: default;
+}
+.contextualHoverMenuItem.disabled{
+    opacity: 0.4;
+}
+.contextualHoverMenuItem.disabled:hover{
+    background: none;
+}
+.contextualHoverMenuItem:hover{
+    background: var(--contextualHover);
+}
+
+.contextualHoverMenuOuter > .contextualHoverMenu{
+    display: none;
+}
+.contextualHoverMenuOuter:hover > .contextualHoverMenu{
+    display: block;
+    position: absolute;
+    left: 100%;
+    top: 0;
+    background: var(--contextualMenuBg);
+    box-shadow: var(--contextualMenuShadow);
+    border-radius: var(--contextualMenuRadius);
+    padding: 8px 0;
+    width: 100%;
+    z-index: 1000;
+    list-style: none;
+}

+ 234 - 0
packages/galene/external/contextual/contextual.js

@@ -0,0 +1,234 @@
+class Contextual{
+    /**
+     * Creates a new contextual menu
+     * @param {object} opts options which build the menu e.g. position and items
+     * @param {number} opts.width sets the width of the menu including children
+     * @param {boolean} opts.isSticky sets how the menu apears, follow the mouse or sticky
+     * @param {Array<ContextualItem>} opts.items sets the default items in the menu
+     */
+    constructor(opts){   
+        contextualCore.CloseMenu();
+
+        this.position = opts.isSticky != null ? opts.isSticky : false;
+        this.menuControl = contextualCore.CreateEl(`<ul class='contextualJs contextualMenu'></ul>`);
+        this.menuControl.style.width = opts.width != null ? opts.width : '200px';
+        opts.items.forEach(i => {
+            let item = new ContextualItem(i);
+            this.menuControl.appendChild(item.element);
+        });
+            
+        if(event != undefined){
+            event.stopPropagation()
+            document.body.appendChild(this.menuControl);
+            contextualCore.PositionMenu(this.position, event, this.menuControl);        
+        }
+
+        document.onclick = function(e){
+            if(!e.target.classList.contains('contextualJs')){
+                contextualCore.CloseMenu();
+            }
+        }    
+    }
+    /**
+     * Adds item to this contextual menu instance
+     * @param {ContextualItem} item item to add to the contextual menu
+     */
+    add(item){
+        this.menuControl.appendChild(item.element);
+    }
+    /**
+     * Makes this contextual menu visible
+     */
+    show(){
+        event.stopPropagation()
+        document.body.appendChild(this.menuControl);
+        contextualCore.PositionMenu(this.position, event, this.menuControl);    
+    }
+    /**
+     * Hides this contextual menu
+     */
+    hide(){
+        event.stopPropagation()
+        contextualCore.CloseMenu();
+    }
+    /**
+     * Toggle visibility of menu
+     */
+    toggle(){
+        event.stopPropagation()
+        if(this.menuControl.parentElement != document.body){
+            document.body.appendChild(this.menuControl);
+            contextualCore.PositionMenu(this.position, event, this.menuControl);        
+        }else{
+            contextualCore.CloseMenu();
+        }
+    }
+}  
+class ContextualItem{
+    element;
+    /**
+     * 
+     * @param {Object} opts
+     * @param {string} [opts.label]
+     * @param {string} [opts.type]
+     * @param {string} [opts.markup]
+     * @param {string} [opts.icon]
+     * @param {string} [opts.cssIcon]
+     * @param {string} [opts.shortcut]
+     * @param {void} [opts.onClick]
+     * @param {boolean} [opts.enabled]
+     * @param {Array<ContextualItem>} [opts.items]
+     * 
+     */
+    constructor(opts){
+        switch(opts.type){
+            case 'seperator':
+                this.seperator();
+                break;
+            case 'custom':
+                this.custom(opts.markup);
+                break;
+            case 'multi': 
+                this.multiButton(opts.items);
+                break;
+            case 'submenu':
+                this.subMenu(opts.label, opts.items, (opts.icon !== undefined ? opts.icon : ''), (opts.cssIcon !== undefined ? opts.cssIcon : ''), (opts.enabled !== undefined ? opts.enabled : true));
+                break;
+            case 'hovermenu': 
+                this.hoverMenu(opts.label, opts.items, (opts.icon !== undefined ? opts.icon : ''), (opts.cssIcon !== undefined ? opts.cssIcon : ''), (opts.enabled !== undefined ? opts.enabled : true));
+                break;
+            case 'normal':
+            default:
+                this.button(opts.label, opts.onClick, (opts.shortcut !== undefined ? opts.shortcut : ''), (opts.icon !== undefined ? opts.icon : ''), (opts.cssIcon !== undefined ? opts.cssIcon : ''), (opts.enabled !== undefined ? opts.enabled : true));       
+        }
+    }
+
+    button(label, onClick, shortcut = '', icon = '', cssIcon = '', enabled = true){
+        this.element = contextualCore.CreateEl( `
+            <li class='contextualJs contextualMenuItemOuter'>
+                <div class='contextualJs contextualMenuItem ${enabled == true ? '' : 'disabled'}'>
+                    ${icon != ''? `<img src='${icon}' class='contextualJs contextualMenuItemIcon'/>` : `<div class='contextualJs contextualMenuItemIcon ${cssIcon != '' ? cssIcon : ''}'></div>`}
+                    <span class='contextualJs contextualMenuItemTitle'>${label == undefined? 'No label' : label}</span>
+                    <span class='contextualJs contextualMenuItemTip'>${shortcut == ''? '' : shortcut}</span>
+                </div>
+            </li>`);               
+
+            if(enabled == true){
+                this.element.addEventListener('click', () => {
+                    event.stopPropagation();
+                    if(onClick !== undefined){ onClick(); }  
+                    contextualCore.CloseMenu();
+                }, false);
+            } 
+    }
+    custom(markup){
+        this.element = contextualCore.CreateEl(`<li class='contextualJs contextualCustomEl'>${markup}</li>`);
+    }
+    hoverMenu(label, items, icon = '', cssIcon = '', enabled = true){
+        this.element = contextualCore.CreateEl(`
+            <li class='contextualJs contextualHoverMenuOuter'>
+                <div class='contextualJs contextualHoverMenuItem ${enabled == true ? '' : 'disabled'}'>
+                    ${icon != ''? `<img src='${icon}' class='contextualJs contextualMenuItemIcon'/>` : `<div class='contextualJs contextualMenuItemIcon ${cssIcon != '' ? cssIcon : ''}'></div>`}
+                    <span class='contextualJs contextualMenuItemTitle'>${label == undefined? 'No label' : label}</span>
+                    <span class='contextualJs contextualMenuItemOverflow'>></span>
+                </div>
+                <ul class='contextualJs contextualHoverMenu'>
+                </ul>
+            </li>
+        `);
+
+        let childMenu = this.element.querySelector('.contextualHoverMenu'),
+        menuItem = this.element.querySelector('.contextualHoverMenuItem');
+
+        if(items !== undefined) {
+            items.forEach(i => {
+                let item = new ContextualItem(i);
+                childMenu.appendChild(item.element);
+            });
+        }
+        if(enabled == true){
+            menuItem.addEventListener('mouseenter', () => {
+
+            });
+            menuItem.addEventListener('mouseleave', () => {
+                
+            });
+        }
+    }
+    multiButton(buttons) {
+        this.element = contextualCore.CreateEl(`
+            <li class='contextualJs contextualMultiItem'>
+            </li>
+        `);
+        buttons.forEach(i => {
+            let item = new ContextualItem(i);
+            this.element.appendChild(item.element);
+        });
+    }
+    subMenu(label, items, icon = '', cssIcon = '', enabled = true){
+        this.element = contextualCore.CreateEl( `
+            <li class='contextualJs contextualMenuItemOuter'>
+                <div class='contextualJs contextualMenuItem ${enabled == true ? '' : 'disabled'}'>
+                    ${icon != ''? `<img src='${icon}' class='contextualJs contextualMenuItemIcon'/>` : `<div class='contextualJs contextualMenuItemIcon ${cssIcon != '' ? cssIcon : ''}'></div>`}
+                    <span class='contextualJs contextualMenuItemTitle'>${label == undefined? 'No label' : label}</span>
+                    <span class='contextualJs contextualMenuItemOverflow'>
+                        <span class='contextualJs contextualMenuItemOverflowLine'></span>
+                        <span class='contextualJs contextualMenuItemOverflowLine'></span>
+                        <span class='contextualJs contextualMenuItemOverflowLine'></span>
+                    </span>
+                </div>
+                <ul class='contextualJs contextualSubMenu contextualMenuHidden'>
+                </ul>
+            </li>`); 
+
+        let childMenu = this.element.querySelector('.contextualSubMenu'),
+            menuItem = this.element.querySelector('.contextualMenuItem');
+
+        if(items !== undefined) {
+            items.forEach(i => {
+                let item = new ContextualItem(i);
+                childMenu.appendChild(item.element);
+            });
+        }
+        if(enabled == true){
+            menuItem.addEventListener('click',() => {
+                menuItem.classList.toggle('SubMenuActive');
+                childMenu.classList.toggle('contextualMenuHidden');
+            }, false);
+        }
+    }
+    seperator(label, items) {
+        this.element = contextualCore.CreateEl(`<li class='contextualJs contextualMenuSeperator'><span></span></li>`);
+    }
+}
+
+const contextualCore = {
+    PositionMenu: (docked, el, menu) => {
+        if(docked){
+            menu.style.left = ((el.target.offsetLeft + menu.offsetWidth) >= window.innerWidth) ? 
+                ((el.target.offsetLeft - menu.offsetWidth) + el.target.offsetWidth)+"px"
+                    : (el.target.offsetLeft)+"px";
+
+            menu.style.top = ((el.target.offsetTop + menu.offsetHeight) >= window.innerHeight) ?
+                (el.target.offsetTop - menu.offsetHeight)+"px"    
+                    : (el.target.offsetHeight + el.target.offsetTop)+"px";
+        }else{
+            menu.style.left = ((el.clientX + menu.offsetWidth) >= window.innerWidth) ?
+                ((el.clientX - menu.offsetWidth))+"px"
+                    : (el.clientX)+"px";
+
+            menu.style.top = ((el.clientY + menu.offsetHeight) >= window.innerHeight) ?
+                (el.clientY - menu.offsetHeight)+"px"    
+                    : (el.clientY)+"px";
+        }
+    },
+    CloseMenu: () => {
+        let openMenuItem = document.querySelector('.contextualMenu:not(.contextualMenuHidden)');
+        if(openMenuItem != null){ document.body.removeChild(openMenuItem); }      
+    },
+    CreateEl: (template) => {
+        var el = document.createElement('div');
+        el.innerHTML = template;
+        return el.firstElementChild;
+    }
+};

ファイルの差分が大きいため隠しています
+ 5 - 0
packages/galene/external/fontawesome/css/all.min.css


ファイルの差分が大きいため隠しています
+ 5 - 0
packages/galene/external/fontawesome/css/brands.min.css


ファイルの差分が大きいため隠しています
+ 5 - 0
packages/galene/external/fontawesome/css/fontawesome.min.css


+ 6 - 0
packages/galene/external/fontawesome/css/regular.min.css

@@ -0,0 +1,6 @@
+/*!
+ * Font Awesome Free 6.0.0 by @fontawesome - https://fontawesome.com
+ * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
+ * Copyright 2022 Fonticons, Inc.
+ */
+:host,:root{--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-family:"Font Awesome 6 Free";font-weight:400}

+ 6 - 0
packages/galene/external/fontawesome/css/solid.min.css

@@ -0,0 +1,6 @@
+/*!
+ * Font Awesome Free 6.0.0 by @fontawesome - https://fontawesome.com
+ * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
+ * Copyright 2022 Fonticons, Inc.
+ */
+:host,:root{--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-family:"Font Awesome 6 Free";font-weight:900}

ファイルの差分が大きいため隠しています
+ 5 - 0
packages/galene/external/fontawesome/css/svg-with-js.min.css


+ 6 - 0
packages/galene/external/fontawesome/css/v4-font-face.min.css

@@ -0,0 +1,6 @@
+/*!
+ * Font Awesome Free 6.0.0 by @fontawesome - https://fontawesome.com
+ * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
+ * Copyright 2022 Fonticons, Inc.
+ */
+@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype");unicode-range:u+f003,u+f006,u+f014,u+f016-f017,u+f01a-f01b,u+f01d,u+f022,u+f03e,u+f044,u+f046,u+f05c-f05d,u+f06e,u+f070,u+f087-f088,u+f08a,u+f094,u+f096-f097,u+f09d,u+f0a0,u+f0a2,u+f0a4-f0a7,u+f0c5,u+f0c7,u+f0e5-f0e6,u+f0eb,u+f0f6-f0f8,u+f10c,u+f114-f115,u+f118-f11a,u+f11c-f11d,u+f133,u+f147,u+f14e,u+f150-f152,u+f185-f186,u+f18e,u+f190-f192,u+f196,u+f1c1-f1c9,u+f1d9,u+f1db,u+f1e3,u+f1ea,u+f1f7,u+f1f9,u+f20a,u+f247-f248,u+f24a,u+f24d,u+f255-f25b,u+f25d,u+f271-f274,u+f278,u+f27b,u+f28c,u+f28e,u+f29c,u+f2b5,u+f2b7,u+f2ba,u+f2bc,u+f2be,u+f2c0-f2c1,u+f2c3,u+f2d0,u+f2d2,u+f2d4,u+f2dc}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-v4compatibility.woff2) format("woff2"),url(../webfonts/fa-v4compatibility.ttf) format("truetype");unicode-range:u+f041,u+f047,u+f065-f066,u+f07d-f07e,u+f080,u+f08b,u+f08e,u+f090,u+f09a,u+f0ac,u+f0ae,u+f0b2,u+f0d0,u+f0d6,u+f0e4,u+f0ec,u+f10a-f10b,u+f123,u+f13e,u+f148-f149,u+f14c,u+f156,u+f15e,u+f160-f161,u+f163,u+f175-f178,u+f195,u+f1f8,u+f219,u+f250,u+f252,u+f27a}

ファイルの差分が大きいため隠しています
+ 5 - 0
packages/galene/external/fontawesome/css/v4-shims.min.css


+ 6 - 0
packages/galene/external/fontawesome/css/v5-font-face.min.css

@@ -0,0 +1,6 @@
+/*!
+ * Font Awesome Free 6.0.0 by @fontawesome - https://fontawesome.com
+ * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
+ * Copyright 2022 Fonticons, Inc.
+ */
+@font-face{font-family:"Font Awesome 5 Brands";font-display:block;font-weight:400;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:900;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:400;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}

BIN
packages/galene/external/fontawesome/webfonts/fa-brands-400.ttf


BIN
packages/galene/external/fontawesome/webfonts/fa-brands-400.woff2


BIN
packages/galene/external/fontawesome/webfonts/fa-regular-400.ttf


BIN
packages/galene/external/fontawesome/webfonts/fa-regular-400.woff2


BIN
packages/galene/external/fontawesome/webfonts/fa-solid-900.ttf


BIN
packages/galene/external/fontawesome/webfonts/fa-solid-900.woff2


BIN
packages/galene/external/fontawesome/webfonts/fa-v4compatibility.ttf


BIN
packages/galene/external/fontawesome/webfonts/fa-v4compatibility.woff2


+ 21 - 0
packages/galene/external/toastify/LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2018 apvarun
+
+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.

+ 85 - 0
packages/galene/external/toastify/toastify.css

@@ -0,0 +1,85 @@
+/*!
+ * Toastify js 1.11.2
+ * https://github.com/apvarun/toastify-js
+ * @license MIT licensed
+ *
+ * Copyright (C) 2018 Varun A P
+ */
+
+.toastify {
+    padding: 12px 20px;
+    color: #ffffff;
+    display: inline-block;
+    box-shadow: 0 3px 6px -1px rgba(0, 0, 0, 0.12), 0 10px 36px -4px rgba(77, 96, 232, 0.3);
+    background: -webkit-linear-gradient(315deg, #73a5ff, #5477f5);
+    background: linear-gradient(135deg, #73a5ff, #5477f5);
+    position: fixed;
+    opacity: 0;
+    transition: all 0.4s cubic-bezier(0.215, 0.61, 0.355, 1);
+    border-radius: 2px;
+    cursor: pointer;
+    text-decoration: none;
+    max-width: calc(50% - 20px);
+    z-index: 2147483647;
+}
+
+.toastify.on {
+    opacity: 1;
+}
+
+.toast-close {
+    background: transparent;
+    border: 0;
+    color: white;
+    cursor: pointer;
+    font-family: inherit;
+    font-size: 1em;
+    opacity: 0.4;
+    padding: 0 5px;
+}
+
+.toastify-right {
+    right: 15px;
+}
+
+.toastify-left {
+    left: 15px;
+}
+
+.toastify-top {
+    top: -150px;
+}
+
+.toastify-bottom {
+    bottom: -150px;
+}
+
+.toastify-rounded {
+    border-radius: 25px;
+}
+
+.toastify-avatar {
+    width: 1.5em;
+    height: 1.5em;
+    margin: -7px 5px;
+    border-radius: 2px;
+}
+
+.toastify-center {
+    margin-left: auto;
+    margin-right: auto;
+    left: 0;
+    right: 0;
+    max-width: fit-content;
+    max-width: -moz-fit-content;
+}
+
+@media only screen and (max-width: 360px) {
+    .toastify-right, .toastify-left {
+        margin-left: auto;
+        margin-right: auto;
+        left: 0;
+        right: 0;
+        max-width: fit-content;
+    }
+}

+ 438 - 0
packages/galene/external/toastify/toastify.js

@@ -0,0 +1,438 @@
+/*!
+ * Toastify js 1.11.2
+ * https://github.com/apvarun/toastify-js
+ * @license MIT licensed
+ *
+ * Copyright (C) 2018 Varun A P
+ */
+(function(root, factory) {
+  if (typeof module === "object" && module.exports) {
+    module.exports = factory();
+  } else {
+    root.Toastify = factory();
+  }
+})(this, function(global) {
+  // Object initialization
+  var Toastify = function(options) {
+      // Returning a new init object
+      return new Toastify.lib.init(options);
+    },
+    // Library version
+    version = "1.11.2";
+
+  // Set the default global options
+  Toastify.defaults = {
+    oldestFirst: true,
+    text: "Toastify is awesome!",
+    node: undefined,
+    duration: 3000,
+    selector: undefined,
+    callback: function () {
+    },
+    destination: undefined,
+    newWindow: false,
+    close: false,
+    gravity: "toastify-top",
+    positionLeft: false,
+    position: '',
+    backgroundColor: '',
+    avatar: "",
+    className: "",
+    stopOnFocus: true,
+    onClick: function () {
+    },
+    offset: {x: 0, y: 0},
+    escapeMarkup: true,
+    style: {background: ''}
+  };
+
+  // Defining the prototype of the object
+  Toastify.lib = Toastify.prototype = {
+    toastify: version,
+
+    constructor: Toastify,
+
+    // Initializing the object with required parameters
+    init: function(options) {
+      // Verifying and validating the input object
+      if (!options) {
+        options = {};
+      }
+
+      // Creating the options object
+      this.options = {};
+
+      this.toastElement = null;
+
+      // Validating the options
+      this.options.text = options.text || Toastify.defaults.text; // Display message
+      this.options.node = options.node || Toastify.defaults.node;  // Display content as node
+      this.options.duration = options.duration === 0 ? 0 : options.duration || Toastify.defaults.duration; // Display duration
+      this.options.selector = options.selector || Toastify.defaults.selector; // Parent selector
+      this.options.callback = options.callback || Toastify.defaults.callback; // Callback after display
+      this.options.destination = options.destination || Toastify.defaults.destination; // On-click destination
+      this.options.newWindow = options.newWindow || Toastify.defaults.newWindow; // Open destination in new window
+      this.options.close = options.close || Toastify.defaults.close; // Show toast close icon
+      this.options.gravity = options.gravity === "bottom" ? "toastify-bottom" : Toastify.defaults.gravity; // toast position - top or bottom
+      this.options.positionLeft = options.positionLeft || Toastify.defaults.positionLeft; // toast position - left or right
+      this.options.position = options.position || Toastify.defaults.position; // toast position - left or right
+      this.options.backgroundColor = options.backgroundColor || Toastify.defaults.backgroundColor; // toast background color
+      this.options.avatar = options.avatar || Toastify.defaults.avatar; // img element src - url or a path
+      this.options.className = options.className || Toastify.defaults.className; // additional class names for the toast
+      this.options.stopOnFocus = options.stopOnFocus === undefined ? Toastify.defaults.stopOnFocus : options.stopOnFocus; // stop timeout on focus
+      this.options.onClick = options.onClick || Toastify.defaults.onClick; // Callback after click
+      this.options.offset = options.offset || Toastify.defaults.offset; // toast offset
+      this.options.escapeMarkup = options.escapeMarkup !== undefined ? options.escapeMarkup : Toastify.defaults.escapeMarkup;
+      this.options.style = options.style || Toastify.defaults.style;
+      if(options.backgroundColor) {
+        this.options.style.background = options.backgroundColor;
+      }
+
+      // Returning the current object for chaining functions
+      return this;
+    },
+
+    // Building the DOM element
+    buildToast: function() {
+      // Validating if the options are defined
+      if (!this.options) {
+        throw "Toastify is not initialized";
+      }
+
+      // Creating the DOM object
+      var divElement = document.createElement("div");
+      divElement.className = "toastify on " + this.options.className;
+
+      // Positioning toast to left or right or center
+      if (!!this.options.position) {
+        divElement.className += " toastify-" + this.options.position;
+      } else {
+        // To be depreciated in further versions
+        if (this.options.positionLeft === true) {
+          divElement.className += " toastify-left";
+          console.warn('Property `positionLeft` will be depreciated in further versions. Please use `position` instead.')
+        } else {
+          // Default position
+          divElement.className += " toastify-right";
+        }
+      }
+
+      // Assigning gravity of element
+      divElement.className += " " + this.options.gravity;
+
+      if (this.options.backgroundColor) {
+        // This is being deprecated in favor of using the style HTML DOM property
+        console.warn('DEPRECATION NOTICE: "backgroundColor" is being deprecated. Please use the "style.background" property.');
+      }
+
+      // Loop through our style object and apply styles to divElement
+      for (var property in this.options.style) {
+        divElement.style[property] = this.options.style[property];
+      }
+
+      // Adding the toast message/node
+      if (this.options.node && this.options.node.nodeType === Node.ELEMENT_NODE) {
+        // If we have a valid node, we insert it
+        divElement.appendChild(this.options.node)
+      } else {
+        if (this.options.escapeMarkup) {
+          divElement.innerText = this.options.text;
+        } else {
+          divElement.innerHTML = this.options.text;
+        }
+
+        if (this.options.avatar !== "") {
+          var avatarElement = document.createElement("img");
+          avatarElement.src = this.options.avatar;
+
+          avatarElement.className = "toastify-avatar";
+
+          if (this.options.position == "left" || this.options.positionLeft === true) {
+            // Adding close icon on the left of content
+            divElement.appendChild(avatarElement);
+          } else {
+            // Adding close icon on the right of content
+            divElement.insertAdjacentElement("afterbegin", avatarElement);
+          }
+        }
+      }
+
+      // Adding a close icon to the toast
+      if (this.options.close === true) {
+        // Create a span for close element
+        var closeElement = document.createElement("button");
+        closeElement.type = "button";
+        closeElement.setAttribute("aria-label", "Close");
+        closeElement.className = "toast-close";
+        closeElement.innerHTML = "&#10006;";
+
+        // Triggering the removal of toast from DOM on close click
+        closeElement.addEventListener(
+          "click",
+          function(event) {
+            event.stopPropagation();
+            this.removeElement(this.toastElement);
+            window.clearTimeout(this.toastElement.timeOutValue);
+          }.bind(this)
+        );
+
+        //Calculating screen width
+        var width = window.innerWidth > 0 ? window.innerWidth : screen.width;
+
+        // Adding the close icon to the toast element
+        // Display on the right if screen width is less than or equal to 360px
+        if ((this.options.position == "left" || this.options.positionLeft === true) && width > 360) {
+          // Adding close icon on the left of content
+          divElement.insertAdjacentElement("afterbegin", closeElement);
+        } else {
+          // Adding close icon on the right of content
+          divElement.appendChild(closeElement);
+        }
+      }
+
+      // Clear timeout while toast is focused
+      if (this.options.stopOnFocus && this.options.duration > 0) {
+        var self = this;
+        // stop countdown
+        divElement.addEventListener(
+          "mouseover",
+          function(event) {
+            window.clearTimeout(divElement.timeOutValue);
+          }
+        )
+        // add back the timeout
+        divElement.addEventListener(
+          "mouseleave",
+          function() {
+            divElement.timeOutValue = window.setTimeout(
+              function() {
+                // Remove the toast from DOM
+                self.removeElement(divElement);
+              },
+              self.options.duration
+            )
+          }
+        )
+      }
+
+      // Adding an on-click destination path
+      if (typeof this.options.destination !== "undefined") {
+        divElement.addEventListener(
+          "click",
+          function(event) {
+            event.stopPropagation();
+            if (this.options.newWindow === true) {
+              window.open(this.options.destination, "_blank");
+            } else {
+              window.location = this.options.destination;
+            }
+          }.bind(this)
+        );
+      }
+
+      if (typeof this.options.onClick === "function" && typeof this.options.destination === "undefined") {
+        divElement.addEventListener(
+          "click",
+          function(event) {
+            event.stopPropagation();
+            this.options.onClick();
+          }.bind(this)
+        );
+      }
+
+      // Adding offset
+      if(typeof this.options.offset === "object") {
+
+        var x = getAxisOffsetAValue("x", this.options);
+        var y = getAxisOffsetAValue("y", this.options);
+
+        var xOffset = this.options.position == "left" ? x : "-" + x;
+        var yOffset = this.options.gravity == "toastify-top" ? y : "-" + y;
+
+        divElement.style.transform = "translate(" + xOffset + "," + yOffset + ")";
+
+      }
+
+      // Returning the generated element
+      return divElement;
+    },
+
+    // Displaying the toast
+    showToast: function() {
+      // Creating the DOM object for the toast
+      this.toastElement = this.buildToast();
+
+      // Getting the root element to with the toast needs to be added
+      var rootElement;
+      if (typeof this.options.selector === "string") {
+        rootElement = document.getElementById(this.options.selector);
+      } else if (this.options.selector instanceof HTMLElement || (typeof ShadowRoot !== 'undefined' && this.options.selector instanceof ShadowRoot)) {
+        rootElement = this.options.selector;
+      } else {
+        rootElement = document.body;
+      }
+
+      // Validating if root element is present in DOM
+      if (!rootElement) {
+        throw "Root element is not defined";
+      }
+
+      // Adding the DOM element
+      var elementToInsert = Toastify.defaults.oldestFirst ? rootElement.firstChild : rootElement.lastChild;
+      rootElement.insertBefore(this.toastElement, elementToInsert);
+
+      // Repositioning the toasts in case multiple toasts are present
+      Toastify.reposition();
+
+      if (this.options.duration > 0) {
+        this.toastElement.timeOutValue = window.setTimeout(
+          function() {
+            // Remove the toast from DOM
+            this.removeElement(this.toastElement);
+          }.bind(this),
+          this.options.duration
+        ); // Binding `this` for function invocation
+      }
+
+      // Supporting function chaining
+      return this;
+    },
+
+    hideToast: function() {
+      if (this.toastElement.timeOutValue) {
+        clearTimeout(this.toastElement.timeOutValue);
+      }
+      this.removeElement(this.toastElement);
+    },
+
+    // Removing the element from the DOM
+    removeElement: function(toastElement) {
+      // Hiding the element
+      // toastElement.classList.remove("on");
+      toastElement.className = toastElement.className.replace(" on", "");
+
+      // Removing the element from DOM after transition end
+      window.setTimeout(
+        function() {
+          // remove options node if any
+          if (this.options.node && this.options.node.parentNode) {
+            this.options.node.parentNode.removeChild(this.options.node);
+          }
+
+          // Remove the element from the DOM, only when the parent node was not removed before.
+          if (toastElement.parentNode) {
+            toastElement.parentNode.removeChild(toastElement);
+          }
+
+          // Calling the callback function
+          this.options.callback.call(toastElement);
+
+          // Repositioning the toasts again
+          Toastify.reposition();
+        }.bind(this),
+        400
+      ); // Binding `this` for function invocation
+    },
+  };
+
+  // Positioning the toasts on the DOM
+  Toastify.reposition = function() {
+
+    // Top margins with gravity
+    var topLeftOffsetSize = {
+      top: 15,
+      bottom: 15,
+    };
+    var topRightOffsetSize = {
+      top: 15,
+      bottom: 15,
+    };
+    var offsetSize = {
+      top: 15,
+      bottom: 15,
+    };
+
+    // Get all toast messages on the DOM
+    var allToasts = document.getElementsByClassName("toastify");
+
+    var classUsed;
+
+    // Modifying the position of each toast element
+    for (var i = 0; i < allToasts.length; i++) {
+      // Getting the applied gravity
+      if (containsClass(allToasts[i], "toastify-top") === true) {
+        classUsed = "toastify-top";
+      } else {
+        classUsed = "toastify-bottom";
+      }
+
+      var height = allToasts[i].offsetHeight;
+      classUsed = classUsed.substr(9, classUsed.length-1)
+      // Spacing between toasts
+      var offset = 15;
+
+      var width = window.innerWidth > 0 ? window.innerWidth : screen.width;
+
+      // Show toast in center if screen with less than or equal to 360px
+      if (width <= 360) {
+        // Setting the position
+        allToasts[i].style[classUsed] = offsetSize[classUsed] + "px";
+
+        offsetSize[classUsed] += height + offset;
+      } else {
+        if (containsClass(allToasts[i], "toastify-left") === true) {
+          // Setting the position
+          allToasts[i].style[classUsed] = topLeftOffsetSize[classUsed] + "px";
+
+          topLeftOffsetSize[classUsed] += height + offset;
+        } else {
+          // Setting the position
+          allToasts[i].style[classUsed] = topRightOffsetSize[classUsed] + "px";
+
+          topRightOffsetSize[classUsed] += height + offset;
+        }
+      }
+    }
+
+    // Supporting function chaining
+    return this;
+  };
+
+  // Helper function to get offset.
+  function getAxisOffsetAValue(axis, options) {
+
+    if(options.offset[axis]) {
+      if(isNaN(options.offset[axis])) {
+        return options.offset[axis];
+      }
+      else {
+        return options.offset[axis] + 'px';
+      }
+    }
+
+    return '0px';
+
+  }
+
+  function containsClass(elem, yourClass) {
+    if (!elem || typeof yourClass !== "string") {
+      return false;
+    } else if (
+      elem.className &&
+      elem.className
+        .trim()
+        .split(/\s+/gi)
+        .indexOf(yourClass) > -1
+    ) {
+      return true;
+    } else {
+      return false;
+    }
+  }
+
+  // Setting up the prototype for the init object
+  Toastify.lib.init.prototype = Toastify.lib;
+
+  // Returning the Toastify function to be assigned to the window object/module
+  return Toastify;
+});

+ 40 - 0
packages/galene/galene-socket.js

@@ -0,0 +1,40 @@
+class GaleneSocket
+{
+    constructor(connection) {
+		this.OPEN = connection.connected;
+		this.connection = connection;	
+		this.readyState = this.OPEN;
+		
+		this.connection.addHandler((iq) => {
+			const json_ele = iq.querySelector("json");
+			console.debug('GaleneSocket handler', json_ele.innerHTML);				
+			
+			if (this.onmessage) this.onmessage({data: json_ele.innerHTML});			
+			return true;
+			
+		}, "urn:xmpp:sfu:galene:0", 'iq', 'set');	
+		
+		setTimeout(() => {
+			console.debug('GaleneSocket start');			
+			if (this.onopen) this.onopen();				
+		});
+
+		console.debug('GaleneSocket constructor', this);			
+	}
+	
+	close(code, reason) {
+		console.debug('GaleneSocket close', code, reason);
+
+		this.connection.sendIQ($iq({type: 'set', to: this.connection.domain}).c('c2s', {xmlns: 'urn:xmpp:sfu:galene:0'}), (res) => {
+			if (this.onclose) this.onclose({code, reason});		
+		});	
+	}
+	
+	send(text) {
+		console.debug('GaleneSocket send', text);			
+		this.connection.sendIQ($iq({type: 'set', to: this.connection.domain}).c('c2s', {xmlns: 'urn:xmpp:sfu:galene:0'}).c('json', {xmlns: 'urn:xmpp:json:0'}).t(text), (res) => {
+			//console.debug('GaleneSocket send response', res);	
+		});			
+	}
+	
+}

+ 3984 - 0
packages/galene/galene-ui.js

@@ -0,0 +1,3984 @@
+// Copyright (c) 2020 by Juliusz Chroboczek.
+
+// 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.
+
+'use strict';
+
+/** @type {string} */
+let group;
+
+/** @type {ServerConnection} */
+let serverConnection;
+
+/** @type {Object} */
+let groupStatus = {};
+
+/** @type {string} */
+let token = null;
+
+/**
+ * @typedef {Object} settings
+ * @property {boolean} [localMute]
+ * @property {string} [video]
+ * @property {string} [audio]
+ * @property {string} [simulcast]
+ * @property {string} [send]
+ * @property {string} [request]
+ * @property {boolean} [activityDetection]
+ * @property {Array.<number>} [resolution]
+ * @property {boolean} [mirrorView]
+ * @property {boolean} [blackboardMode]
+ * @property {string} [filter]
+ * @property {boolean} [preprocessing]
+ * @property {boolean} [hqaudio]
+ * @property {boolean} [forceRelay]
+ */
+
+/** @type{settings} */
+let fallbackSettings = null;
+
+/**
+ * @param {settings} settings
+ */
+function storeSettings(settings) {
+    try {
+        window.sessionStorage.setItem('settings', JSON.stringify(settings));
+        fallbackSettings = null;
+    } catch(e) {
+        console.warn("Couldn't store settings:", e);
+        fallbackSettings = settings;
+    }
+}
+
+/**
+ * This always returns a dictionary.
+ *
+ * @returns {settings}
+ */
+function getSettings() {
+    /** @type {settings} */
+    let settings;
+    try {
+        let json = window.sessionStorage.getItem('settings');
+        settings = JSON.parse(json);
+    } catch(e) {
+        console.warn("Couldn't retrieve settings:", e);
+        settings = fallbackSettings;
+    }
+    return settings || {};
+}
+
+/**
+ * @param {settings} settings
+ */
+function updateSettings(settings) {
+    let s = getSettings();
+    for(let key in settings)
+        s[key] = settings[key];
+    storeSettings(s);
+}
+
+/**
+ * @param {string} key
+ * @param {any} value
+ */
+function updateSetting(key, value) {
+    let s = {};
+    s[key] = value;
+    updateSettings(s);
+}
+
+/**
+ * @param {string} key
+ */
+function delSetting(key) {
+    let s = getSettings();
+    if(!(key in s))
+        return;
+    delete(s[key]);
+    storeSettings(s);
+}
+
+/**
+ * @param {string} id
+ */
+function getSelectElement(id) {
+    let elt = document.getElementById(id);
+    if(!elt || !(elt instanceof HTMLSelectElement))
+        throw new Error(`Couldn't find ${id}`);
+    return elt;
+}
+
+/**
+ * @param {string} id
+ */
+function getInputElement(id) {
+    let elt = document.getElementById(id);
+    if(!elt || !(elt instanceof HTMLInputElement))
+        throw new Error(`Couldn't find ${id}`);
+    return elt;
+}
+
+/**
+ * @param {string} id
+ */
+function getButtonElement(id) {
+    let elt = document.getElementById(id);
+    if(!elt || !(elt instanceof HTMLButtonElement))
+        throw new Error(`Couldn't find ${id}`);
+    return elt;
+}
+
+function reflectSettings() {
+    let settings = getSettings();
+    let store = false;
+
+    setLocalMute(settings.localMute);
+
+    let videoselect = getSelectElement('videoselect');
+    if(!settings.hasOwnProperty('video') ||
+       !selectOptionAvailable(videoselect, settings.video)) {
+        settings.video = selectOptionDefault(videoselect);
+        store = true;
+    }
+    videoselect.value = settings.video;
+
+    let audioselect = getSelectElement('audioselect');
+    if(!settings.hasOwnProperty('audio') ||
+       !selectOptionAvailable(audioselect, settings.audio)) {
+        settings.audio = selectOptionDefault(audioselect);
+        store = true;
+    }
+    audioselect.value = settings.audio;
+
+    if(settings.hasOwnProperty('filter')) {
+        getSelectElement('filterselect').value = settings.filter;
+    } else {
+        let s = getSelectElement('filterselect').value;
+        if(s) {
+            settings.filter = s;
+            store = true;
+        }
+    }
+
+    if(settings.hasOwnProperty('request')) {
+        getSelectElement('requestselect').value = settings.request;
+    } else {
+        settings.request = getSelectElement('requestselect').value;
+        store = true;
+    }
+
+    if(settings.hasOwnProperty('send')) {
+        getSelectElement('sendselect').value = settings.send;
+    } else {
+        settings.send = getSelectElement('sendselect').value;
+        store = true;
+    }
+
+    if(settings.hasOwnProperty('simulcast')) {
+        getSelectElement('simulcastselect').value = settings.simulcast
+    } else {
+        settings.simulcast = getSelectElement('simulcastselect').value;
+        store = true;
+    }
+
+    if(settings.hasOwnProperty('blackboardMode')) {
+        getInputElement('blackboardbox').checked = settings.blackboardMode;
+    } else {
+        settings.blackboardMode = getInputElement('blackboardbox').checked;
+        store = true;
+    }
+
+    if(settings.hasOwnProperty('mirrorView')) {
+        getInputElement('mirrorbox').checked = settings.mirrorView;
+    } else {
+        settings.mirrorView = getInputElement('mirrorbox').checked;
+        store = true;
+    }
+
+    if(settings.hasOwnProperty('activityDetection')) {
+        getInputElement('activitybox').checked = settings.activityDetection;
+    } else {
+        settings.activityDetection = getInputElement('activitybox').checked;
+        store = true;
+    }
+
+    if(settings.hasOwnProperty('preprocessing')) {
+        getInputElement('preprocessingbox').checked = settings.preprocessing;
+    } else {
+        settings.preprocessing = getInputElement('preprocessingbox').checked;
+        store = true;
+    }
+
+    if(settings.hasOwnProperty('hqaudio')) {
+        getInputElement('hqaudiobox').checked = settings.hqaudio;
+    } else {
+        settings.hqaudio = getInputElement('hqaudiobox').checked;
+        store = true;
+    }
+
+    if(store)
+        storeSettings(settings);
+}
+
+function isMobileLayout() {
+    if (window.matchMedia('only screen and (max-width: 1024px)').matches)
+        return true;
+    return false;
+}
+
+/**
+ * @param {boolean} [force]
+ */
+function hideVideo(force) {
+    let mediadiv = document.getElementById('peers');
+    if(mediadiv.childElementCount > 0 && !force)
+        return;
+    setVisibility('video-container', false);
+    scheduleReconsiderDownRate();
+}
+
+function showVideo() {
+    let hasmedia = document.getElementById('peers').childElementCount > 0;
+    if(isMobileLayout()) {
+        setVisibility('show-video', false);
+        setVisibility('collapse-video', hasmedia);
+    }
+    setVisibility('video-container', hasmedia);
+    scheduleReconsiderDownRate();
+}
+
+/**
+  * @param{boolean} connected
+  */
+function setConnected(connected) {
+    let userbox = document.getElementById('profile');
+    let connectionbox = document.getElementById('login-container');
+    if(connected) {
+        clearChat();
+        userbox.classList.remove('invisible');
+        connectionbox.classList.add('invisible');
+        displayUsername();
+        window.onresize = function(e) {
+            scheduleReconsiderDownRate();
+        }
+    } else {
+        userbox.classList.add('invisible');
+        connectionbox.classList.remove('invisible');
+        displayError('Disconnected', 'error');
+        hideVideo();
+        window.onresize = null;
+    }
+}
+
+/**
+  * @this {ServerConnection}
+  * @param {string} [username]
+  */
+async function gotConnected(username) {
+    let credentials;
+    if(token) {
+        credentials = {
+            type: 'token',
+            token: token,
+        };
+        token = null;
+    } else {
+        setConnected(true);
+
+        username = getInputElement('username').value.trim();
+        let pw = getInputElement('password').value;
+        getInputElement('password').value = '';
+        if(!groupStatus.authServer)
+            credentials = pw;
+        else
+            credentials = {
+                type: 'authServer',
+                authServer: groupStatus.authServer,
+                location: location.href,
+                password: pw,
+            };
+    }
+
+    try {
+        await this.join(group, username, credentials);
+    } catch(e) {
+        console.error(e);
+        displayError(e);
+        serverConnection.close();
+    }
+}
+
+/**
+ * @this {ServerConnection}
+ */
+function onPeerConnection() {
+    if(!getSettings().forceRelay)
+        return null;
+    let old = this.rtcConfiguration;
+    /** @type {RTCConfiguration} */
+    let conf = {};
+    for(let key in old)
+        conf[key] = old[key];
+    conf.iceTransportPolicy = 'relay';
+    return conf;
+}
+
+/**
+ * @this {ServerConnection}
+ * @param {number} code
+ * @param {string} reason
+ */
+function gotClose(code, reason) {
+    closeUpMedia();
+    setConnected(false);
+    if(code != 1000) {
+        console.warn('Socket close', code, reason);
+    }
+	
+	closeConnection();
+}
+
+/**
+ * @this {ServerConnection}
+ * @param {Stream} c
+ */
+function gotDownStream(c) {
+    c.onclose = function(replace) {
+        if(!replace)
+            delMedia(c.localId);
+    };
+    c.onerror = function(e) {
+        console.error(e);
+        displayError(e);
+    };
+    c.ondowntrack = function(track, transceiver, label, stream) {
+        setMedia(c, false);
+    };
+    c.onnegotiationcompleted = function() {
+        resetMedia(c);
+    }
+    c.onstatus = function(status) {
+        setMediaStatus(c);
+    };
+    c.onstats = gotDownStats;
+    if(getSettings().activityDetection)
+        c.setStatsInterval(activityDetectionInterval);
+
+    setMedia(c, false);
+}
+
+// Store current browser viewport height in css variable
+function setViewportHeight() {
+    document.documentElement.style.setProperty(
+        '--vh', `${window.innerHeight/100}px`,
+    );
+    showVideo();
+    // Ajust video component size
+    resizePeers();
+}
+
+/**
+ * @param {string} id
+ * @param {boolean} visible
+ */
+function setVisibility(id, visible) {
+    let elt = document.getElementById(id);
+    if(visible)
+        elt.classList.remove('invisible');
+    else
+        elt.classList.add('invisible');
+}
+
+function setButtonsVisibility() {
+    let connected = serverConnection && serverConnection.socket;
+    let permissions = serverConnection.permissions;
+    let present = permissions.indexOf('present') >= 0;
+    let local = !!findUpMedia('camera');
+    let canWebrtc = !(typeof RTCPeerConnection === 'undefined');
+    let mediacount = document.getElementById('peers').childElementCount;
+    let mobilelayout = isMobileLayout();
+
+    // don't allow multiple presentations
+    setVisibility('presentbutton', canWebrtc && present && !local);
+    setVisibility('unpresentbutton', local);
+
+    setVisibility('mutebutton', !connected || present);
+
+    // allow multiple shared documents
+    setVisibility('sharebutton', canWebrtc && present &&
+                  ('getDisplayMedia' in navigator.mediaDevices));
+
+    setVisibility('mediaoptions', present);
+    setVisibility('sendform', present);
+    setVisibility('simulcastform', present);
+
+    setVisibility('collapse-video', mediacount && mobilelayout);
+}
+
+/**
+ * @param {boolean} mute
+ * @param {boolean} [reflect]
+ */
+function setLocalMute(mute, reflect) {
+    muteLocalTracks(mute);
+    let button = document.getElementById('mutebutton');
+    let icon = button.querySelector("span .fas");
+    if(mute){
+        icon.classList.add('fa-microphone-slash');
+        icon.classList.remove('fa-microphone');
+        button.classList.add('muted');
+    } else {
+        icon.classList.remove('fa-microphone-slash');
+        icon.classList.add('fa-microphone');
+        button.classList.remove('muted');
+    }
+    if(reflect)
+        updateSettings({localMute: mute});
+}
+
+getSelectElement('videoselect').onchange = function(e) {
+    e.preventDefault();
+    if(!(this instanceof HTMLSelectElement))
+        throw new Error('Unexpected type for this');
+    updateSettings({video: this.value});
+    replaceCameraStream();
+};
+
+getSelectElement('audioselect').onchange = function(e) {
+    e.preventDefault();
+    if(!(this instanceof HTMLSelectElement))
+        throw new Error('Unexpected type for this');
+    updateSettings({audio: this.value});
+    replaceCameraStream();
+};
+
+getInputElement('mirrorbox').onchange = function(e) {
+    e.preventDefault();
+    if(!(this instanceof HTMLInputElement))
+        throw new Error('Unexpected type for this');
+    updateSettings({mirrorView: this.checked});
+    // no need to reopen the camera
+    replaceUpStreams('camera');
+};
+
+getInputElement('blackboardbox').onchange = function(e) {
+    e.preventDefault();
+    if(!(this instanceof HTMLInputElement))
+        throw new Error('Unexpected type for this');
+    updateSettings({blackboardMode: this.checked});
+    replaceCameraStream();
+};
+
+getInputElement('preprocessingbox').onchange = function(e) {
+    e.preventDefault();
+    if(!(this instanceof HTMLInputElement))
+        throw new Error('Unexpected type for this');
+    updateSettings({preprocessing: this.checked});
+    replaceCameraStream();
+};
+
+getInputElement('hqaudiobox').onchange = function(e) {
+    e.preventDefault();
+    if(!(this instanceof HTMLInputElement))
+        throw new Error('Unexpected type for this');
+    updateSettings({hqaudio: this.checked});
+    replaceCameraStream();
+};
+
+document.getElementById('mutebutton').onclick = function(e) {
+    e.preventDefault();
+    let localMute = getSettings().localMute;
+    localMute = !localMute;
+    setLocalMute(localMute, true);
+};
+
+document.getElementById('sharebutton').onclick = function(e) {
+    e.preventDefault();
+    addShareMedia();
+};
+
+document.getElementById('closebutton').onclick = async function(e) {
+    e.preventDefault();
+	console.debug("closebutton - click");
+	
+	await serverConnection.leave(group);
+	closeConnection();
+};
+
+getSelectElement('filterselect').onchange = async function(e) {
+    if(!(this instanceof HTMLSelectElement))
+        throw new Error('Unexpected type for this');
+    updateSettings({filter: this.value});
+    let c = findUpMedia('camera');
+    if(c) {
+        let filter = (this.value && filters[this.value]) || null;
+        if(filter)
+            c.userdata.filterDefinition = filter;
+        else
+            delete c.userdata.filterDefinition;
+        replaceUpStream(c);
+    }
+};
+
+/** @returns {number} */
+function getMaxVideoThroughput() {
+    let v = getSettings().send;
+    switch(v) {
+    case 'lowest':
+        return 150000;
+    case 'low':
+        return 300000;
+    case 'normal':
+        return 700000;
+    case 'unlimited':
+        return null;
+    default:
+        console.error('Unknown video quality', v);
+        return 700000;
+    }
+}
+
+getSelectElement('sendselect').onchange = async function(e) {
+    if(!(this instanceof HTMLSelectElement))
+        throw new Error('Unexpected type for this');
+    updateSettings({send: this.value});
+    await reconsiderSendParameters();
+};
+
+getSelectElement('simulcastselect').onchange = async function(e) {
+    if(!(this instanceof HTMLSelectElement))
+        throw new Error('Unexpected type for this');
+    updateSettings({simulcast: this.value});
+    await reconsiderSendParameters();
+};
+
+/**
+ * @param {string} what
+ * @returns {Object<string,Array<string>>}
+ */
+
+function mapRequest(what) {
+    switch(what) {
+    case '':
+        return {};
+        break;
+    case 'audio':
+        return {'': ['audio']};
+        break;
+    case 'screenshare-low':
+        return {screenshare: ['audio','video-low'], '': ['audio']};
+        break;
+    case 'screenshare':
+        return {screenshare: ['audio','video'], '': ['audio']};
+        break;
+    case 'everything-low':
+        return {'': ['audio','video-low']};
+        break;
+    case 'everything':
+        return {'': ['audio','video']}
+        break;
+    default:
+        throw new Error(`Unknown value ${what} in request`);
+    }
+}
+
+/**
+ * @param {string} what
+ * @param {string} label
+ * @returns {Array<string>}
+ */
+
+function mapRequestLabel(what, label) {
+    let r = mapRequest(what);
+    if(label in r)
+        return r[label];
+    else
+        return r[''];
+}
+
+
+getSelectElement('requestselect').onchange = function(e) {
+    e.preventDefault();
+    if(!(this instanceof HTMLSelectElement))
+        throw new Error('Unexpected type for this');
+    updateSettings({request: this.value});
+    serverConnection.request(mapRequest(this.value));
+    reconsiderDownRate();
+};
+
+const activityDetectionInterval = 200;
+const activityDetectionPeriod = 700;
+const activityDetectionThreshold = 0.2;
+
+getInputElement('activitybox').onchange = function(e) {
+    if(!(this instanceof HTMLInputElement))
+        throw new Error('Unexpected type for this');
+    updateSettings({activityDetection: this.checked});
+    for(let id in serverConnection.down) {
+        let c = serverConnection.down[id];
+        if(this.checked)
+            c.setStatsInterval(activityDetectionInterval);
+        else {
+            c.setStatsInterval(0);
+            setActive(c, false);
+        }
+    }
+};
+
+/**
+ * @this {Stream}
+ * @param {Object<string,any>} stats
+ */
+function gotUpStats(stats) {
+    let c = this;
+
+    let values = [];
+
+    for(let id in stats) {
+        if(stats[id] && stats[id]['outbound-rtp']) {
+            let rate = stats[id]['outbound-rtp'].rate;
+            if(typeof rate === 'number') {
+                values.push(rate);
+            }
+        }
+    }
+
+    if(values.length === 0) {
+        setLabel(c, '');
+    } else {
+        values.sort((x,y) => x - y);
+        setLabel(c, values
+                 .map(x => Math.round(x / 1000).toString())
+                 .reduce((x, y) => x + '+' + y));
+    }
+}
+
+/**
+ * @param {Stream} c
+ * @param {boolean} value
+ */
+function setActive(c, value) {
+    let peer = document.getElementById('peer-' + c.localId);
+    if(value)
+        peer.classList.add('peer-active');
+    else
+        peer.classList.remove('peer-active');
+}
+
+/**
+ * @this {Stream}
+ * @param {Object<string,any>} stats
+ */
+function gotDownStats(stats) {
+    if(!getInputElement('activitybox').checked)
+        return;
+
+    let c = this;
+
+    let maxEnergy = 0;
+
+    c.pc.getReceivers().forEach(r => {
+        let tid = r.track && r.track.id;
+        let s = tid && stats[tid];
+        let energy = s && s['track'] && s['track'].audioEnergy;
+        if(typeof energy === 'number')
+            maxEnergy = Math.max(maxEnergy, energy);
+    });
+
+    // totalAudioEnergy is defined as the integral of the square of the
+    // volume, so square the threshold.
+    if(maxEnergy > activityDetectionThreshold * activityDetectionThreshold) {
+        c.userdata.lastVoiceActivity = Date.now();
+        setActive(c, true);
+    } else {
+        let last = c.userdata.lastVoiceActivity;
+        if(!last || Date.now() - last > activityDetectionPeriod)
+            setActive(c, false);
+    }
+}
+
+/**
+ * @param {HTMLSelectElement} select
+ * @param {string} label
+ * @param {string} [value]
+ */
+function addSelectOption(select, label, value) {
+    if(!value)
+        value = label;
+    for(let i = 0; i < select.children.length; i++) {
+        let child = select.children[i];
+        if(!(child instanceof HTMLOptionElement)) {
+            console.warn('Unexpected select child');
+            continue;
+        }
+        if(child.value === value) {
+            if(child.label !== label) {
+                child.label = label;
+            }
+            return;
+        }
+    }
+
+    let option = document.createElement('option');
+    option.value = value;
+    option.textContent = label;
+    select.appendChild(option);
+}
+
+/**
+ * @param {HTMLSelectElement} select
+ * @param {string} value
+ */
+function selectOptionAvailable(select, value) {
+    let children = select.children;
+    for(let i = 0; i < children.length; i++) {
+        let child = select.children[i];
+        if(!(child instanceof HTMLOptionElement)) {
+            console.warn('Unexpected select child');
+            continue;
+        }
+        if(child.value === value)
+            return true;
+    }
+    return false;
+}
+
+/**
+ * @param {HTMLSelectElement} select
+ * @returns {string}
+ */
+function selectOptionDefault(select) {
+    /* First non-empty option. */
+    for(let i = 0; i < select.children.length; i++) {
+        let child = select.children[i];
+        if(!(child instanceof HTMLOptionElement)) {
+            console.warn('Unexpected select child');
+            continue;
+        }
+        if(child.value)
+            return child.value;
+    }
+    /* The empty option is always available. */
+    return '';
+}
+
+/* media names might not be available before we call getDisplayMedia.  So
+   we call this twice, the second time to update the menu with user-readable
+   labels. */
+/** @type {boolean} */
+let mediaChoicesDone = false;
+
+/**
+ * @param{boolean} done
+ */
+async function setMediaChoices(done) {
+    if(mediaChoicesDone)
+        return;
+
+    let devices = [];
+    try {
+        devices = await navigator.mediaDevices.enumerateDevices();
+    } catch(e) {
+        console.error(e);
+        return;
+    }
+
+    let cn = 1, mn = 1;
+
+    devices.forEach(d => {
+        let label = d.label;
+        if(d.kind === 'videoinput') {
+            if(!label)
+                label = `Camera ${cn}`;
+            addSelectOption(getSelectElement('videoselect'),
+                            label, d.deviceId);
+            cn++;
+        } else if(d.kind === 'audioinput') {
+            if(!label)
+                label = `Microphone ${mn}`;
+            addSelectOption(getSelectElement('audioselect'),
+                            label, d.deviceId);
+            mn++;
+        }
+    });
+
+    mediaChoicesDone = done;
+}
+
+
+/**
+ * @param {string} [localId]
+ */
+function newUpStream(localId) {
+    let c = serverConnection.newUpStream(localId);
+    c.onstatus = function(status) {
+        setMediaStatus(c);
+    };
+    c.onerror = function(e) {
+        console.error(e);
+        displayError(e);
+    };
+    return c;
+}
+
+/**
+ * Sets an up stream's video throughput and simulcast parameters.
+ *
+ * @param {Stream} c
+ * @param {number} bps
+ * @param {boolean} simulcast
+ */
+async function setSendParameters(c, bps, simulcast) {
+    if(!c.up)
+        throw new Error('Setting throughput of down stream');
+    let senders = c.pc.getSenders();
+    for(let i = 0; i < senders.length; i++) {
+        let s = senders[i];
+        if(!s.track || s.track.kind !== 'video')
+            continue;
+        let p = s.getParameters();
+        if((!p.encodings ||
+            !simulcast && p.encodings.length != 1) ||
+           (simulcast && p.encodings.length != 2)) {
+            await replaceUpStream(c);
+            return;
+        }
+        p.encodings.forEach(e => {
+            if(!e.rid || e.rid === 'h')
+                e.maxBitrate = bps || unlimitedRate;
+        });
+        await s.setParameters(p);
+    }
+}
+
+let reconsiderParametersTimer = null;
+
+/**
+ * Sets the send parameters for all up streams.
+ */
+async function reconsiderSendParameters() {
+    cancelReconsiderParameters();
+    let t = getMaxVideoThroughput();
+    let s = doSimulcast();
+    let promises = [];
+    for(let id in serverConnection.up) {
+        let c = serverConnection.up[id];
+        promises.push(setSendParameters(c, t, s));
+    }
+    await Promise.all(promises);
+}
+
+/**
+ * Schedules a call to reconsiderSendParameters after a delay.
+ * The delay avoids excessive flapping.
+ */
+function scheduleReconsiderParameters() {
+    cancelReconsiderParameters();
+    reconsiderParametersTimer =
+        setTimeout(reconsiderSendParameters, 10000 + Math.random() * 10000);
+}
+
+function cancelReconsiderParameters() {
+    if(reconsiderParametersTimer) {
+        clearTimeout(reconsiderParametersTimer);
+        reconsiderParametersTimer = null;
+    }
+}
+
+/**
+ * @typedef {Object} filterDefinition
+ * @property {string} [description]
+ * @property {string} [contextType]
+ * @property {Object} [contextAttributes]
+ * @property {(this: Filter, ctx: RenderingContext) => void} [init]
+ * @property {(this: Filter) => void} [cleanup]
+ * @property {(this: Filter, src: CanvasImageSource, width: number, height: number, ctx: RenderingContext) => boolean} f
+ */
+
+/**
+ * @param {MediaStream} stream
+ * @param {filterDefinition} definition
+ * @constructor
+ */
+function Filter(stream, definition) {
+    /** @ts-ignore */
+    if(!HTMLCanvasElement.prototype.captureStream) {
+        throw new Error('Filters are not supported on this platform');
+    }
+
+    /** @type {MediaStream} */
+    this.inputStream = stream;
+    /** @type {filterDefinition} */
+    this.definition = definition;
+    /** @type {number} */
+    this.frameRate = 30;
+    /** @type {HTMLVideoElement} */
+    this.video = document.createElement('video');
+    /** @type {HTMLCanvasElement} */
+    this.canvas = document.createElement('canvas');
+    /** @type {any} */
+    this.context = this.canvas.getContext(
+        definition.contextType || '2d',
+        definition.contextAttributes || null);
+    /** @type {MediaStream} */
+    this.captureStream = null;
+    /** @type {MediaStream} */
+    this.outputStream = null;
+    /** @type {number} */
+    this.timer = null;
+    /** @type {number} */
+    this.count = 0;
+    /** @type {boolean} */
+    this.fixedFramerate = false;
+    /** @type {Object} */
+    this.userdata = {}
+    /** @type {MediaStream} */
+    this.captureStream = this.canvas.captureStream(0);
+
+    /** @ts-ignore */
+    if(!this.captureStream.getTracks()[0].requestFrame) {
+        console.warn('captureFrame not supported, using fixed framerate');
+        /** @ts-ignore */
+        this.captureStream = this.canvas.captureStream(this.frameRate);
+        this.fixedFramerate = true;
+    }
+
+    this.outputStream = new MediaStream();
+    this.outputStream.addTrack(this.captureStream.getTracks()[0]);
+    this.inputStream.getTracks().forEach(t => {
+        t.onended = e => this.stop();
+        if(t.kind != 'video')
+            this.outputStream.addTrack(t);
+    });
+    this.video.srcObject = stream;
+    this.video.muted = true;
+    this.video.play();
+    if(this.definition.init)
+        this.definition.init.call(this, this.context);
+    this.timer = setInterval(() => this.draw(), 1000 / this.frameRate);
+}
+
+Filter.prototype.draw = function() {
+    // check framerate every 30 frames
+    if((this.count % 30) === 0) {
+        let frameRate = 0;
+        this.inputStream.getTracks().forEach(t => {
+            if(t.kind === 'video') {
+                let r = t.getSettings().frameRate;
+                if(r)
+                    frameRate = r;
+            }
+        });
+        if(frameRate && frameRate != this.frameRate) {
+            clearInterval(this.timer);
+            this.timer = setInterval(() => this.draw(), 1000 / this.frameRate);
+        }
+    }
+
+    let ok = false;
+    try {
+        ok = this.definition.f.call(this, this.video,
+                                    this.video.videoWidth,
+                                    this.video.videoHeight,
+                                    this.context);
+    } catch(e) {
+        console.error(e);
+    }
+    if(ok && !this.fixedFramerate) {
+        /** @ts-ignore */
+        this.captureStream.getTracks()[0].requestFrame();
+    }
+
+    this.count++;
+};
+
+Filter.prototype.stop = function() {
+    if(!this.timer)
+        return;
+    this.captureStream.getTracks()[0].stop();
+    clearInterval(this.timer);
+    this.timer = null;
+    if(this.definition.cleanup)
+        this.definition.cleanup.call(this);
+};
+
+/**
+ * Removes any filter set on c.
+ *
+ * @param {Stream} c
+ */
+function removeFilter(c) {
+    let old = c.userdata.filter;
+    if(!old)
+        return;
+
+    if(!(old instanceof Filter))
+        throw new Error('userdata.filter is not a filter');
+
+    c.setStream(old.inputStream);
+    old.stop();
+    c.userdata.filter = null;
+}
+
+/**
+ * Sets the filter described by c.userdata.filterDefinition on c.
+ *
+ * @param {Stream} c
+ */
+function setFilter(c) {
+    removeFilter(c);
+
+    if(!c.userdata.filterDefinition)
+        return;
+
+    let filter = new Filter(c.stream, c.userdata.filterDefinition);
+    c.setStream(filter.outputStream);
+    c.userdata.filter = filter;
+}
+
+/**
+ * @type {Object.<string,filterDefinition>}
+ */
+let filters = {
+    'mirror-h': {
+        description: "Horizontal mirror",
+        f: function(src, width, height, ctx) {
+            if(!(ctx instanceof CanvasRenderingContext2D))
+                throw new Error('bad context type');
+            if(ctx.canvas.width !== width || ctx.canvas.height !== height) {
+                ctx.canvas.width = width;
+                ctx.canvas.height = height;
+            }
+            ctx.scale(-1, 1);
+            ctx.drawImage(src, -width, 0);
+            ctx.resetTransform();
+            return true;
+        },
+    },
+    'mirror-v': {
+        description: "Vertical mirror",
+        f: function(src, width, height, ctx) {
+            if(!(ctx instanceof CanvasRenderingContext2D))
+                throw new Error('bad context type');
+            if(ctx.canvas.width !== width || ctx.canvas.height !== height) {
+                ctx.canvas.width = width;
+                ctx.canvas.height = height;
+            }
+            ctx.scale(1, -1);
+            ctx.drawImage(src, 0, -height);
+            ctx.resetTransform();
+            return true;
+        },
+    },
+};
+
+function addFilters() {
+    for(let name in filters) {
+        let f = filters[name];
+        let d = f.description || name;
+        addSelectOption(getSelectElement('filterselect'), d, name);
+    }
+}
+
+function isSafari() {
+    let ua = navigator.userAgent.toLowerCase();
+    return ua.indexOf('safari') >= 0 && ua.indexOf('chrome') < 0;
+}
+
+const unlimitedRate = 1000000000;
+const simulcastRate = 100000;
+const hqAudioRate = 128000;
+
+/**
+ * Decide whether we want to send simulcast.
+ *
+ * @returns {boolean}
+ */
+function doSimulcast() {
+    switch(getSettings().simulcast) {
+    case 'on':
+        return true;
+    case 'off':
+        return false;
+    default:
+        let count = 0;
+        for(let n in serverConnection.users) {
+            if(!serverConnection.users[n].permissions["system"]) {
+                count++;
+                if(count > 2)
+                    break;
+            }
+        }
+        if(count <= 2)
+            return false;
+        let bps = getMaxVideoThroughput();
+        return bps <= 0 || bps >= 2 * simulcastRate;
+    }
+}
+
+/**
+ * Sets up c to send the given stream.  Some extra parameters are stored
+ * in c.userdata.
+ *
+ * @param {Stream} c
+ * @param {MediaStream} stream
+ */
+
+function setUpStream(c, stream) {
+    if(c.stream != null)
+        throw new Error("Setting nonempty stream");
+
+    c.setStream(stream);
+
+    try {
+        setFilter(c);
+    } catch(e) {
+        displayWarning("Couldn't set filter: " + e);
+    }
+
+    c.onclose = replace => {
+        removeFilter(c);
+        if(!replace) {
+            stopStream(c.stream);
+            if(c.userdata.onclose)
+                c.userdata.onclose.call(c);
+            delMedia(c.localId);
+        }
+    }
+
+    /**
+     * @param {MediaStreamTrack} t
+     */
+    function addUpTrack(t) {
+        let settings = getSettings();
+        if(c.label === 'camera') {
+            if(t.kind == 'audio') {
+                if(settings.localMute)
+                    t.enabled = false;
+            } else if(t.kind == 'video') {
+                if(settings.blackboardMode) {
+                    t.contentHint = 'detail';
+                }
+            }
+        }
+        t.onended = e => {
+            stream.onaddtrack = null;
+            stream.onremovetrack = null;
+            c.close();
+        };
+
+        let encodings = [];
+        let simulcast = doSimulcast();
+        if(t.kind === 'video') {
+            let bps = getMaxVideoThroughput();
+            // Firefox doesn't like us setting the RID if we're not
+            // simulcasting.
+            if(simulcast) {
+                encodings.push({
+                    rid: 'h',
+                    maxBitrate: bps || unlimitedRate,
+                });
+                encodings.push({
+                    rid: 'l',
+                    scaleResolutionDownBy: 2,
+                    maxBitrate: simulcastRate,
+                });
+            } else {
+                encodings.push({
+                    maxBitrate: bps || unlimitedRate,
+                });
+            }
+        } else {
+            if(settings.hqaudio) {
+                encodings.push({
+                    maxBitrate: hqAudioRate,
+                });
+            }
+        }
+        let tr = c.pc.addTransceiver(t, {
+            direction: 'sendonly',
+            streams: [stream],
+            sendEncodings: encodings,
+        });
+
+        // Firefox workaround
+        function match(a, b) {
+            if(!a || !b)
+                return false;
+            if(a.length !== b.length)
+                return false;
+            for(let i = 0; i < a.length; i++) {
+                if(a.maxBitrate !== b.maxBitrate)
+                    return false;
+            }
+            return true;
+        }
+
+        let p = tr.sender.getParameters();
+        if(!p || !match(p.encodings, encodings)) {
+            p.encodings = encodings;
+            tr.sender.setParameters(p);
+        }
+    }
+
+    // c.stream might be different from stream if there's a filter
+    c.stream.getTracks().forEach(addUpTrack);
+
+    stream.onaddtrack = function(e) {
+        addUpTrack(e.track);
+    };
+
+    stream.onremovetrack = function(e) {
+        let t = e.track;
+
+        /** @type {RTCRtpSender} */
+        let sender;
+        c.pc.getSenders().forEach(s => {
+            if(s.track === t)
+                sender = s;
+        });
+        if(sender) {
+            c.pc.removeTrack(sender);
+        } else {
+            console.warn('Removing unknown track');
+        }
+
+        let found = false;
+        c.pc.getSenders().forEach(s => {
+            if(s.track)
+                found = true;
+        });
+        if(!found) {
+            stream.onaddtrack = null;
+            stream.onremovetrack = null;
+            c.close();
+        }
+    };
+
+    c.onstats = gotUpStats;
+    c.setStatsInterval(2000);
+}
+
+/**
+ * Replaces c with a freshly created stream, duplicating any relevant
+ * parameters in c.userdata.
+ *
+ * @param {Stream} c
+ * @returns {Promise<Stream>}
+ */
+async function replaceUpStream(c) {
+    removeFilter(c);
+    let cn = newUpStream(c.localId);
+    cn.label = c.label;
+    if(c.userdata.filterDefinition)
+        cn.userdata.filterDefinition = c.userdata.filterDefinition;
+    if(c.userdata.onclose)
+        cn.userdata.onclose = c.userdata.onclose;
+    let media = /** @type{HTMLVideoElement} */
+        (document.getElementById('media-' + c.localId));
+    setUpStream(cn, c.stream);
+    await setMedia(cn, true,
+                   cn.label == 'camera' && getSettings().mirrorView,
+                   cn.label == 'video' && media);
+    return cn;
+}
+
+/**
+ * Replaces all up streams with the given label.  If label is null,
+ * replaces all up stream.
+ *
+ * @param {string} label
+ */
+async function replaceUpStreams(label) {
+    let promises = [];
+    for(let id in serverConnection.up) {
+        let c = serverConnection.up[id];
+        if(label && c.label !== label)
+            continue
+        promises.push(replaceUpStream(c));
+    }
+    await Promise.all(promises);
+}
+
+/**
+ * Closes and reopens the camera then replaces the camera stream.
+ */
+function replaceCameraStream() {
+    let c = findUpMedia('camera');
+    if(c)
+        addLocalMedia(c.localId);
+}
+
+/**
+ * @param {string} [localId]
+ */
+async function addLocalMedia(localId) {
+    let settings = getSettings();
+
+    let audio = settings.audio ? {deviceId: settings.audio} : false;
+    let video = settings.video ? {deviceId: settings.video} : false;
+
+    if(video) {
+        let resolution = settings.resolution;
+        if(resolution) {
+            video.width = { ideal: resolution[0] };
+            video.height = { ideal: resolution[1] };
+        } else if(settings.blackboardMode) {
+            video.width = { min: 640, ideal: 1920 };
+            video.height = { min: 400, ideal: 1080 };
+        }
+    }
+
+    if(audio) {
+        if(!settings.preprocessing) {
+            audio.echoCancellation = false;
+            audio.noiseSuppression = false;
+            audio.autoGainControl = false;
+        }
+    }
+
+    let old = serverConnection.findByLocalId(localId);
+    if(old) {
+        // make sure that the camera is released before we try to reopen it
+        removeFilter(old);
+        stopStream(old.stream);
+    }
+
+    let constraints = {audio: audio, video: video};
+    /** @type {MediaStream} */
+    let stream = null;
+    try {
+        stream = await navigator.mediaDevices.getUserMedia(constraints);
+    } catch(e) {
+        displayError(e);
+        return;
+    }
+
+    setMediaChoices(true);
+
+    let c;
+
+    try {
+        c = newUpStream(localId);
+    } catch(e) {
+        console.log(e);
+        displayError(e);
+        return;
+    }
+
+    c.label = 'camera';
+
+    if(settings.filter) {
+        let filter = filters[settings.filter];
+        if(filter)
+            c.userdata.filterDefinition = filter;
+        else
+            displayWarning(`Unknown filter ${settings.filter}`);
+    }
+
+    setUpStream(c, stream);
+    await setMedia(c, true, settings.mirrorView);
+    setButtonsVisibility();
+}
+
+let safariScreenshareDone = false;
+
+async function addShareMedia() {
+    /** @type {MediaStream} */
+    let stream = null;
+    try {
+        if(!('getDisplayMedia' in navigator.mediaDevices))
+            throw new Error('Your browser does not support screen sharing');
+        stream = await navigator.mediaDevices.getDisplayMedia({
+            video: true,
+            audio: true,
+        });
+    } catch(e) {
+        console.error(e);
+        displayError(e);
+        return;
+    }
+
+    if(!safariScreenshareDone) {
+        if(isSafari())
+            displayWarning('Screen sharing under Safari is experimental.  ' +
+                           'Please use a different browser if possible.');
+        safariScreenshareDone = true;
+    }
+
+    let c = newUpStream();
+    c.label = 'screenshare';
+    setUpStream(c, stream);
+    await setMedia(c, true);
+    setButtonsVisibility();
+}
+
+/**
+ * @param {File} file
+ */
+async function addFileMedia(file) {
+    let url = URL.createObjectURL(file);
+    let video = document.createElement('video');
+    video.src = url;
+    video.controls = true;
+    let stream;
+    /** @ts-ignore */
+    if(video.captureStream)
+        /** @ts-ignore */
+        stream = video.captureStream();
+    /** @ts-ignore */
+    else if(video.mozCaptureStream)
+        /** @ts-ignore */
+        stream = video.mozCaptureStream();
+    else {
+        displayError("This browser doesn't support file playback");
+        return;
+    }
+
+    let c = newUpStream();
+    c.label = 'video';
+    c.userdata.onclose = function() {
+        let media = /** @type{HTMLVideoElement} */
+            (document.getElementById('media-' + this.localId));
+        if(media && media.src) {
+            URL.revokeObjectURL(media.src);
+            media.src = null;
+        }
+    };
+    await setUpStream(c, stream);
+
+    let presenting = !!findUpMedia('camera');
+    let muted = getSettings().localMute;
+    if(presenting && !muted) {
+        setLocalMute(true, true);
+        displayWarning('You have been muted');
+    }
+
+    await setMedia(c, true, false, video);
+    c.userdata.play = true;
+    setButtonsVisibility();
+}
+
+/**
+ * @param {MediaStream} s
+ */
+function stopStream(s) {
+    s.getTracks().forEach(t => {
+        try {
+            t.stop();
+        } catch(e) {
+            console.warn(e);
+        }
+    });
+}
+
+/**
+ * closeUpMedia closes all up connections with the given label.  If label
+ * is null, it closes all up connections.
+ *
+ * @param {string} [label]
+*/
+function closeUpMedia(label) {
+    for(let id in serverConnection.up) {
+        let c = serverConnection.up[id];
+        if(label && c.label !== label)
+            continue
+        c.close();
+    }
+}
+
+/**
+ * @param {string} label
+ * @returns {Stream}
+ */
+function findUpMedia(label) {
+    for(let id in serverConnection.up) {
+        let c = serverConnection.up[id]
+        if(c.label === label)
+            return c;
+    }
+    return null;
+}
+
+/**
+ * @param {boolean} mute
+ */
+function muteLocalTracks(mute) {
+    if(!serverConnection)
+        return;
+    for(let id in serverConnection.up) {
+        let c = serverConnection.up[id];
+        if(c.label === 'camera') {
+            let stream = c.stream;
+            stream.getTracks().forEach(t => {
+                if(t.kind === 'audio') {
+                    t.enabled = !mute;
+                }
+            });
+        }
+    }
+}
+
+/**
+ * @param {string} id
+ * @param {boolean} force
+ * @param {boolean} [value]
+ */
+function forceDownRate(id, force, value) {
+    let c = serverConnection.down[id];
+    if(!c)
+        throw new Error("Unknown down stream");
+    if('requested' in c.userdata) {
+        if(force)
+            c.userdata.requested.force = !!value;
+        else
+            delete(c.userdata.requested.force);
+    } else {
+        if(force)
+            c.userdata.requested = {force: value};
+    }
+    reconsiderDownRate(id);
+}
+
+/**
+ * Maps 'video' to 'video-low'.  Returns null if nothing changed.
+ *
+ * @param {string[]} requested
+ * @returns {string[]}
+ */
+function mapVideoToLow(requested) {
+    let result = [];
+    let found = false;
+    for(let i = 0; i < requested.length; i++) {
+        let r = requested[i];
+        if(r === 'video') {
+            r = 'video-low';
+            found = true;
+        }
+        result.push(r);
+    }
+    if(!found)
+        return null;
+    return result;
+}
+
+/**
+ * Reconsider the video track requested for a given down stream.
+ *
+ * @param {string} [id] - the id of the track to reconsider, all if null.
+ */
+function reconsiderDownRate(id) {
+    if(!serverConnection)
+        return;
+    if(!id) {
+        for(let id in serverConnection.down) {
+            reconsiderDownRate(id);
+        }
+        return;
+    }
+    let c = serverConnection.down[id];
+    if(!c)
+        throw new Error("Unknown down stream");
+    let normalrequest = mapRequestLabel(getSettings().request, c.label);
+
+    let requestlow = mapVideoToLow(normalrequest);
+    if(requestlow === null)
+        return;
+
+    let old = c.userdata.requested;
+    let low = false;
+    if(old && ('force' in old)) {
+        low = old.force;
+    } else {
+        let media = /** @type {HTMLVideoElement} */
+            (document.getElementById('media-' + c.localId));
+        if(!media)
+            throw new Error("No media for stream");
+        let w = media.scrollWidth;
+        let h = media.scrollHeight;
+        if(w && h && w * h <= 320 * 240) {
+            low = true;
+        }
+    }
+
+    if(low !== !!(old && old.low)) {
+        if('requested' in c.userdata)
+            c.userdata.requested.low = low;
+        else
+            c.userdata.requested = {low: low};
+        c.request(low ? requestlow : null);
+    }
+}
+
+let reconsiderDownRateTimer = null;
+
+/**
+ * Schedules reconsiderDownRate() to be run later.  The delay avoids too
+ * much recomputations when resizing the window.
+ */
+function scheduleReconsiderDownRate() {
+    if(reconsiderDownRateTimer)
+        return;
+    reconsiderDownRateTimer =
+        setTimeout(() => {
+            reconsiderDownRateTimer = null;
+            reconsiderDownRate();
+        }, 200);
+}
+
+/**
+ * setMedia adds a new media element corresponding to stream c.
+ *
+ * @param {Stream} c
+ * @param {boolean} isUp
+ *     - indicates whether the stream goes in the up direction
+ * @param {boolean} [mirror]
+ *     - whether to mirror the video
+ * @param {HTMLVideoElement} [video]
+ *     - the video element to add.  If null, a new element with custom
+ *       controls will be created.
+ */
+async function setMedia(c, isUp, mirror, video) {
+    let peersdiv = document.getElementById('peers');
+
+    let div = document.getElementById('peer-' + c.localId);
+    if(!div) {
+        div = document.createElement('div');
+        div.id = 'peer-' + c.localId;
+        div.classList.add('peer');
+        peersdiv.appendChild(div);
+    }
+
+    let media = /** @type {HTMLVideoElement} */
+        (document.getElementById('media-' + c.localId));
+    if(!media) {
+        if(video) {
+            media = video;
+        } else {
+            media = document.createElement('video');
+            if(isUp)
+                media.muted = true;
+        }
+
+        media.classList.add('media');
+        media.autoplay = true;
+        media.playsInline = true;
+        media.id = 'media-' + c.localId;
+        div.appendChild(media);
+        addCustomControls(media, div, c, !!video);
+    }
+
+    if(mirror)
+        media.classList.add('mirror');
+    else
+        media.classList.remove('mirror');
+
+    if(!video && media.srcObject !== c.stream)
+        media.srcObject = c.stream;
+
+    if(!isUp) {
+        media.onfullscreenchange = function(e) {
+            forceDownRate(c.id, document.fullscreenElement === media, false);
+        }
+    }
+
+    let label = document.getElementById('label-' + c.localId);
+    if(!label) {
+        label = document.createElement('div');
+        label.id = 'label-' + c.localId;
+        label.classList.add('label');
+        div.appendChild(label);
+    }
+
+    setLabel(c);
+    setMediaStatus(c);
+
+    showVideo();
+    resizePeers();
+
+    if(!isUp && isSafari() && !findUpMedia('camera')) {
+        // Safari doesn't allow autoplay unless the user has granted media access
+        try {
+            let stream = await navigator.mediaDevices.getUserMedia({audio: true});
+            stream.getTracks().forEach(t => t.stop());
+        } catch(e) {
+        }
+    }
+}
+
+/**
+ * resetMedia resets the source stream of the media element associated
+ * with c.  This has the side-effect of resetting any frozen frames.
+ *
+ * @param {Stream} c
+ */
+function resetMedia(c) {
+    let media = /** @type {HTMLVideoElement} */
+        (document.getElementById('media-' + c.localId));
+    if(!media) {
+        console.error("Resetting unknown media element")
+        return;
+    }
+    media.srcObject = media.srcObject;
+}
+
+/**
+ * @param {Element} elt
+ */
+function cloneHTMLElement(elt) {
+    if(!(elt instanceof HTMLElement))
+        throw new Error('Unexpected element type');
+    return /** @type{HTMLElement} */(elt.cloneNode(true));
+}
+
+/**
+ * @param {HTMLVideoElement} media
+ * @param {HTMLElement} container
+ * @param {Stream} c
+ */
+function addCustomControls(media, container, c, toponly) {
+    if(!toponly && !document.getElementById('controls-' + c.localId)) {
+        media.controls = false;
+
+        let template =
+            document.getElementById('videocontrols-template').firstElementChild;
+        let controls = cloneHTMLElement(template);
+        controls.id = 'controls-' + c.localId;
+
+        let volume = getVideoButton(controls, 'volume');
+
+        if(c.up && c.label === 'camera') {
+            volume.remove();
+        } else {
+            setVolumeButton(media.muted,
+                            getVideoButton(controls, "volume-mute"),
+                            getVideoButton(controls, "volume-slider"));
+        }
+        container.appendChild(controls);
+    }
+
+    if(c.up && !document.getElementById('topcontrols-' + c.localId)) {
+        let toptemplate =
+            document.getElementById('topvideocontrols-template').firstElementChild;
+        let topcontrols = cloneHTMLElement(toptemplate);
+        topcontrols.id = 'topcontrols-' + c.localId;
+        container.appendChild(topcontrols);
+    }
+    registerControlHandlers(c.localId, media, container);
+}
+
+/**
+ * @param {HTMLElement} container
+ * @param {string} name
+ */
+function getVideoButton(container, name) {
+    return /** @type {HTMLElement} */(container.getElementsByClassName(name)[0]);
+}
+
+/**
+ * @param {boolean} muted
+ * @param {HTMLElement} button
+ * @param {HTMLElement} slider
+ */
+function setVolumeButton(muted, button, slider) {
+    if(!muted) {
+        button.classList.remove("fa-volume-mute");
+        button.classList.add("fa-volume-up");
+    } else {
+        button.classList.remove("fa-volume-up");
+        button.classList.add("fa-volume-mute");
+    }
+
+    if(!(slider instanceof HTMLInputElement))
+        throw new Error("Couldn't find volume slider");
+    slider.disabled = muted;
+}
+
+/**
+ * @param {string} localId
+ * @param {HTMLVideoElement} media
+ * @param {HTMLElement} container
+ */
+function registerControlHandlers(localId, media, container) {
+    let play = getVideoButton(container, 'video-play');
+    if(play) {
+        play.onclick = function(event) {
+            event.preventDefault();
+            media.play();
+        };
+    }
+
+    let stop = getVideoButton(container, 'video-stop');
+    if(stop) {
+        stop.onclick = function(event) {
+            event.preventDefault();
+            try {
+                let c = serverConnection.findByLocalId(localId);
+                if(!c)
+                    throw new Error('Closing unknown stream');
+                c.close();
+            } catch(e) {
+                console.error(e);
+                displayError(e);
+            }
+        };
+    }
+
+    let volume = getVideoButton(container, 'volume');
+    if (volume) {
+        volume.onclick = function(event) {
+            let target = /** @type{HTMLElement} */(event.target);
+            if(!target.classList.contains('volume-mute'))
+                // if click on volume slider, do nothing
+                return;
+            event.preventDefault();
+            media.muted = !media.muted;
+            setVolumeButton(media.muted, target,
+                            getVideoButton(volume, "volume-slider"));
+        };
+        volume.oninput = function() {
+          let slider = /** @type{HTMLInputElement} */
+              (getVideoButton(volume, "volume-slider"));
+          media.volume = parseInt(slider.value, 10)/100;
+        };
+    }
+
+    let pip = getVideoButton(container, 'pip');
+    if(pip) {
+        if(HTMLVideoElement.prototype.requestPictureInPicture) {
+            pip.onclick = function(e) {
+                e.preventDefault();
+                if(media.requestPictureInPicture) {
+                    media.requestPictureInPicture();
+                } else {
+                    displayWarning('Picture in Picture not supported.');
+                }
+            };
+        } else {
+            pip.style.display = 'none';
+        }
+    }
+
+    let fs = getVideoButton(container, 'fullscreen');
+    if(fs) {
+        if(HTMLVideoElement.prototype.requestFullscreen ||
+           /** @ts-ignore */
+           HTMLVideoElement.prototype.webkitRequestFullscreen) {
+            fs.onclick = function(e) {
+                e.preventDefault();
+                if(media.requestFullscreen) {
+                    media.requestFullscreen();
+                /** @ts-ignore */
+                } else if(media.webkitRequestFullscreen) {
+                    /** @ts-ignore */
+                    media.webkitRequestFullscreen();
+                } else {
+                    displayWarning('Full screen not supported!');
+                }
+            };
+        } else {
+            fs.style.display = 'none';
+        }
+    }
+}
+
+/**
+ * @param {string} localId
+ */
+function delMedia(localId) {
+    let mediadiv = document.getElementById('peers');
+    let peer = document.getElementById('peer-' + localId);
+    if(!peer)
+        throw new Error('Removing unknown media');
+
+    let media = /** @type{HTMLVideoElement} */
+        (document.getElementById('media-' + localId));
+
+    media.srcObject = null;
+    mediadiv.removeChild(peer);
+
+    setButtonsVisibility();
+    resizePeers();
+    hideVideo();
+}
+
+/**
+ * @param {Stream} c
+ */
+function setMediaStatus(c) {
+    let state = c && c.pc && c.pc.iceConnectionState;
+    let good = state === 'connected' || state === 'completed';
+
+    let media = document.getElementById('media-' + c.localId);
+    if(!media) {
+        console.warn('Setting status of unknown media.');
+        return;
+    }
+    if(good) {
+        media.classList.remove('media-failed');
+        if(c.userdata.play) {
+            if(media instanceof HTMLMediaElement)
+                media.play().catch(e => {
+                    console.error(e);
+                    displayError(e);
+                });
+            delete(c.userdata.play);
+        }
+    } else {
+        media.classList.add('media-failed');
+    }
+}
+
+
+/**
+ * @param {Stream} c
+ * @param {string} [fallback]
+ */
+function setLabel(c, fallback) {
+    let label = document.getElementById('label-' + c.localId);
+    if(!label)
+        return;
+    let l = c.username;
+    if(l) {
+        label.textContent = l;
+        label.classList.remove('label-fallback');
+    } else if(fallback) {
+        label.textContent = fallback;
+        label.classList.add('label-fallback');
+    } else {
+        label.textContent = '';
+        label.classList.remove('label-fallback');
+    }
+}
+
+function resizePeers() {
+    // Window resize can call this method too early
+    if (!serverConnection)
+        return;
+    let count =
+        Object.keys(serverConnection.up).length +
+        Object.keys(serverConnection.down).length;
+    let peers = document.getElementById('peers');
+    let columns = Math.ceil(Math.sqrt(count));
+    if (!count)
+        // No video, nothing to resize.
+        return;
+    let container = document.getElementById("video-container");
+    // Peers div has total padding of 40px, we remove 40 on offsetHeight
+    // Grid has row-gap of 5px
+    let rows = Math.ceil(count / columns);
+    let margins = (rows - 1) * 5 + 40;
+
+    if (count <= 2 && container.offsetHeight > container.offsetWidth) {
+        peers.style['grid-template-columns'] = "repeat(1, 1fr)";
+        rows = count;
+    } else {
+        peers.style['grid-template-columns'] = `repeat(${columns}, 1fr)`;
+    }
+    if (count === 1)
+        return;
+    let max_video_height = (peers.offsetHeight - margins) / rows;
+    let media_list = peers.querySelectorAll(".media");
+    for(let i = 0; i < media_list.length; i++) {
+        let media = media_list[i];
+        if(!(media instanceof HTMLMediaElement)) {
+            console.warn('Unexpected media');
+            continue;
+        }
+        media.style['max-height'] = max_video_height + "px";
+    }
+}
+
+/**
+ * Lexicographic order, with case differences secondary.
+ * @param{string} a
+ * @param{string} b
+ */
+function stringCompare(a, b) {
+    let la = a.toLowerCase();
+    let lb = b.toLowerCase();
+    if(la < lb)
+        return -1;
+    else if(la > lb)
+        return +1;
+    else if(a < b)
+        return -1;
+    else if(a > b)
+        return +1;
+    return 0
+}
+
+/**
+ * @param {HTMLElement} elt
+ */
+function userMenu(elt) {
+    if(!elt.id.startsWith('user-'))
+        throw new Error('Unexpected id for user menu');
+    let id = elt.id.slice('user-'.length);
+    let user = serverConnection.users[id];
+    if(!user)
+        throw new Error("Couldn't find user")
+    let items = [];
+    if(id === serverConnection.id) {
+        let mydata = serverConnection.users[serverConnection.id].data;
+        if(mydata['raisehand'])
+            items.push({label: 'Unraise hand', onClick: () => {
+                serverConnection.userAction(
+                    'setdata', serverConnection.id, {'raisehand': null},
+                );
+            }});
+        else
+            items.push({label: 'Raise hand', onClick: () => {
+                serverConnection.userAction(
+                    'setdata', serverConnection.id, {'raisehand': true},
+                );
+            }});
+        if(serverConnection.permissions.indexOf('present')>= 0 && canFile())
+            items.push({label: 'Broadcast file', onClick: presentFile});
+        items.push({label: 'Restart media', onClick: renegotiateStreams});
+    } else {
+        items.push({label: 'Send file', onClick: () => {
+            sendFile(id);
+        }});
+        if(serverConnection.permissions.indexOf('op') >= 0) {
+            items.push({type: 'seperator'}); // sic
+            if(user.permissions.indexOf('present') >= 0)
+                items.push({label: 'Forbid presenting', onClick: () => {
+                    serverConnection.userAction('unpresent', id);
+                }});
+            else
+                items.push({label: 'Allow presenting', onClick: () => {
+                    serverConnection.userAction('present', id);
+                }});
+            items.push({label: 'Mute', onClick: () => {
+                serverConnection.userMessage('mute', id);
+            }});
+            items.push({label: 'Kick out', onClick: () => {
+                serverConnection.userAction('kick', id);
+            }});
+        }
+    }
+    /** @ts-ignore */
+    new Contextual({
+        items: items,
+    });
+}
+
+/**
+ * @param {string} id
+ * @param {user} userinfo
+ */
+function addUser(id, userinfo) {
+    let div = document.getElementById('users');
+    let user = document.createElement('div');
+    user.id = 'user-' + id;
+    user.classList.add("user-p");
+    setUserStatus(id, user, userinfo);
+    user.addEventListener('click', function(e) {
+        let elt = e.target;
+        if(!elt || !(elt instanceof HTMLElement))
+            throw new Error("Couldn't find user div");
+        userMenu(elt);
+    });
+
+    let us = div.children;
+
+    if(id === serverConnection.id) {
+        if(us.length === 0)
+            div.appendChild(user);
+        else
+            div.insertBefore(user, us[0]);
+        return;
+    }
+
+    if(userinfo.username) {
+        for(let i = 0; i < us.length; i++) {
+            let child = us[i];
+            let childid = child.id.slice('user-'.length);
+            if(childid === serverConnection.id)
+                continue;
+            let childuser = serverConnection.users[childid] || null;
+            let childname = (childuser && childuser.username) || null;
+            if(!childname || stringCompare(childname, userinfo.username) > 0) {
+                div.insertBefore(user, child);
+                return;
+            }
+        }
+    }
+
+    div.appendChild(user);
+}
+
+ /**
+  * @param {string} id
+  * @param {user} userinfo
+  */
+function changeUser(id, userinfo) {
+    let elt = document.getElementById('user-' + id);
+    if(!elt) {
+        console.warn('Unknown user ' + id);
+        return;
+    }
+    setUserStatus(id, elt, userinfo);
+}
+
+/**
+ * @param {string} id
+ * @param {HTMLElement} elt
+ * @param {user} userinfo
+ */
+function setUserStatus(id, elt, userinfo) {
+    elt.textContent = userinfo.username ? userinfo.username : '(anon)';
+    if(userinfo.data.raisehand)
+        elt.classList.add('user-status-raisehand');
+    else
+        elt.classList.remove('user-status-raisehand');
+}
+
+/**
+ * @param {string} id
+ */
+function delUser(id) {
+    let div = document.getElementById('users');
+    let user = document.getElementById('user-' + id);
+    div.removeChild(user);
+}
+
+/**
+ * @param {string} id
+ * @param {string} kind
+ */
+function gotUser(id, kind) {
+    switch(kind) {
+    case 'add':
+        addUser(id, serverConnection.users[id]);
+        if(Object.keys(serverConnection.users).length == 3)
+            reconsiderSendParameters();
+        break;
+    case 'delete':
+        delUser(id);
+        if(Object.keys(serverConnection.users).length < 3)
+            scheduleReconsiderParameters();
+        break;
+    case 'change':
+        changeUser(id, serverConnection.users[id]);
+        break;
+    default:
+        console.warn('Unknown user kind', kind);
+        break;
+    }
+}
+
+function displayUsername() {
+    document.getElementById('userspan').textContent = serverConnection.username;
+    let op = serverConnection.permissions.indexOf('op') >= 0;
+    let present = serverConnection.permissions.indexOf('present') >= 0;
+    let text = '';
+    if(op && present)
+        text = '(op, presenter)';
+    else if(op)
+        text = 'operator';
+    else if(present)
+        text = 'presenter';
+    document.getElementById('permspan').textContent = text;
+}
+
+let presentRequested = null;
+
+/**
+ * @param {string} s
+ */
+function capitalise(s) {
+    if(s.length <= 0)
+        return s;
+    return s.charAt(0).toUpperCase() + s.slice(1);
+}
+
+/**
+ * @param {string} title
+ */
+function setTitle(title) {
+    function set(title) {
+        document.title = title;
+        document.getElementById('title').textContent = title;
+    }
+    if(title)
+        set(title);
+    else
+        set('Galène');
+}
+
+
+/**
+ * @this {ServerConnection}
+ * @param {string} group
+ * @param {Array<string>} perms
+ * @param {Object<string,any>} status
+ * @param {Object<string,any>} data
+ * @param {string} message
+ */
+async function gotJoined(kind, group, perms, status, data, message) {
+    let present = presentRequested;
+    presentRequested = null;
+
+    switch(kind) {
+    case 'fail':
+        displayError('The server said: ' + message);
+        this.close();
+        setButtonsVisibility();
+        return;
+    case 'redirect':
+        this.close();
+        document.location.href = message;
+        return;
+    case 'leave':
+        this.close();
+        setButtonsVisibility();
+        return;
+    case 'join':
+    case 'change':
+        groupStatus = status;
+        setTitle((status && status.displayName) || capitalise(group));
+        displayUsername();
+        setButtonsVisibility();
+        if(kind === 'change')
+            return;
+        break;
+    default:
+        displayError('Unknown join message');
+        this.close();
+        return;
+    }
+
+    let input = /** @type{HTMLTextAreaElement} */
+        (document.getElementById('input'));
+    input.placeholder = 'Type /help for help';
+    setTimeout(() => {input.placeholder = '';}, 8000);
+
+    if(status.locked)
+        displayWarning('This group is locked');
+
+    if(typeof RTCPeerConnection === 'undefined')
+        displayWarning("This browser doesn't support WebRTC");
+    else
+        this.request(mapRequest(getSettings().request));
+
+    if(serverConnection.permissions.indexOf('present') >= 0 &&
+       !findUpMedia('camera')) {
+        if(present) {
+            if(present === 'mike')
+                updateSettings({video: ''});
+            else if(present === 'both')
+                delSetting('video');
+            reflectSettings();
+
+            let button = getButtonElement('presentbutton');
+            button.disabled = true;
+            try {
+                await addLocalMedia();
+            } finally {
+                button.disabled = false;
+            }
+        } else {
+            displayMessage(
+                "Press Enable to enable your camera or microphone"
+            );
+        }
+    }
+}
+
+/** @type {Object<string,TransferredFile>} */
+let transferredFiles = {};
+
+/**
+ * A file in the process of being transferred.
+ *
+ * @constructor
+ */
+function TransferredFile(id, userid, up, username, name, type, size) {
+    /** @type {string} */
+    this.id = id;
+    /** @type {string} */
+    this.userid = userid;
+    /** @type {boolean} */
+    this.up = up;
+    /** @type {string} */
+    this.username = username;
+    /** @type {string} */
+    this.name = name;
+    /** @type {string} */
+    this.type = type;
+    /** @type {number} */
+    this.size = size;
+    /** @type {File} */
+    this.file = null;
+    /** @type {RTCPeerConnection} */
+    this.pc = null;
+    /** @type {RTCDataChannel} */
+    this.dc = null;
+    /** @type {Array<RTCIceCandidateInit>} */
+    this.candidates = [];
+    /** @type {Array<Blob|ArrayBuffer>} */
+    this.data = [];
+    /** @type {number} */
+    this.datalen = 0;
+}
+
+TransferredFile.prototype.fullid = function() {
+    return this.userid + (this.up ? '+' : '-') + this.id;
+};
+
+/**
+ * @param {boolean} up
+ * @param {string} userid
+ * @param {string} fileid
+ * @returns {TransferredFile}
+ */
+TransferredFile.get = function(up, userid, fileid) {
+    return transferredFiles[userid + (up ? '+' : '-') + fileid];
+};
+
+TransferredFile.prototype.close = function() {
+    if(this.dc) {
+        this.dc.onclose = null;
+        this.dc.onerror = null;
+        this.dc.onmessage = null;
+    }
+    if(this.pc)
+        this.pc.close();
+    this.dc = null;
+    this.pc = null;
+    this.data = [];
+    this.datalen = 0;
+    delete(transferredFiles[this.fullid()]);
+}
+
+TransferredFile.prototype.pushData = function(data) {
+    if(data instanceof Blob) {
+        this.datalen += data.size;
+    } else if(data instanceof ArrayBuffer) {
+        this.datalen += data.byteLength;
+    } else {
+        throw new Error('unexpected type for received data');
+    }
+    this.data.push(data);
+}
+
+TransferredFile.prototype.getData = function() {
+    let blob = new Blob(this.data, {type: this.type});
+    if(blob.size != this.datalen)
+        throw new Error('Inconsistent data size');
+    this.data = [];
+    this.datalen = 0;
+    return blob;
+}
+
+/**
+ * @param {TransferredFile} f
+ */
+function fileTransferBox(f) {
+    let p = document.createElement('p');
+    if(f.up)
+        p.textContent =
+        `We have offered to send a file called "${f.name}" ` +
+        `to user ${f.username}.`;
+    else
+        p.textContent =
+        `User ${f.username} offered to send us a file ` +
+        `called "${f.name}" of size ${f.size}.`
+    let bno = null, byes = null;
+    if(f.up) {
+        bno = document.createElement('button');
+        bno.textContent = 'Cancel';
+        bno.onclick = function(e) {
+            cancelFile(f);
+        };
+        bno.id = "bno-" + f.fullid();
+    } else {
+        byes = document.createElement('button');
+        byes.textContent = 'Accept';
+        byes.onclick = function(e) {
+            getFile(f);
+        };
+        byes.id = "byes-" + f.fullid();
+        bno = document.createElement('button');
+        bno.textContent = 'Decline';
+        bno.onclick = function(e) {
+            rejectFile(f);
+        };
+        bno.id = "bno-" + f.fullid();
+    }
+    let status = document.createElement('div');
+    status.id = 'status-' + f.fullid();
+    if(!f.up) {
+        status.textContent =
+            '(Choosing "Accept" will disclose your IP address.)';
+    }
+    let div = document.createElement('div');
+    div.id = 'file-' + f.fullid();
+    div.appendChild(p);
+    if(byes)
+        div.appendChild(byes);
+    if(bno)
+        div.appendChild(bno);
+    div.appendChild(status);
+    div.classList.add('message');
+    div.classList.add('message-private');
+    div.classList.add('message-row');
+    let box = document.getElementById('box');
+    box.appendChild(div);
+    return div;
+}
+
+/**
+ * @param {TransferredFile} f
+ * @param {string} status
+ * @param {boolean} [delyes]
+ * @param {boolean} [delno]
+ */
+function setFileStatus(f, status, delyes, delno) {
+    let statusdiv = document.getElementById('status-' + f.fullid());
+    if(!statusdiv)
+        throw new Error("Couldn't find statusdiv");
+    statusdiv.textContent = status;
+    if(delyes || delno) {
+        let div = document.getElementById('file-' + f.fullid());
+        if(!div)
+            throw new Error("Couldn't find file div");
+        if(delyes) {
+            let byes = document.getElementById('byes-' + f.fullid())
+            if(byes)
+                div.removeChild(byes);
+        }
+        if(delno) {
+            let bno = document.getElementById('bno-' + f.fullid())
+            if(bno)
+                div.removeChild(bno);
+        }
+    }
+}
+
+/**
+ * @param {TransferredFile} f
+ * @param {any} message
+ */
+function failFile(f, message) {
+    if(!f.dc)
+        return;
+    console.error('File transfer failed:', message);
+    setFileStatus(f, message ? `Failed: ${message}` : 'Failed.');
+    f.close();
+}
+
+/**
+ * @param {string} id
+ * @param {File} file
+ */
+function offerFile(id, file) {
+    let fileid = newRandomId();
+    let username = serverConnection.users[id].username;
+    let f = new TransferredFile(
+        fileid, id, true, username, file.name, file.type, file.size,
+    );
+    f.file = file;
+    transferredFiles[f.fullid()] = f;
+    try {
+        fileTransferBox(f);
+        serverConnection.userMessage('offerfile', id, {
+            id: fileid,
+            name: f.name,
+            size: f.size,
+            type: f.type,
+        });
+    } catch(e) {
+        displayError(e);
+        f.close();
+    }
+}
+
+/**
+ * @param {TransferredFile} f
+ */
+function cancelFile(f) {
+    serverConnection.userMessage('cancelfile', f.userid, {
+        id: f.id,
+    });
+    f.close();
+    setFileStatus(f, 'Cancelled.', true, true);
+}
+
+/**
+ * @param {TransferredFile} f
+ */
+async function getFile(f) {
+    if(f.pc)
+        throw new Error("Download already in progress");
+    setFileStatus(f, 'Connecting...', true);
+    let pc = new RTCPeerConnection(serverConnection.getRTCConfiguration());
+    if(!pc)
+        throw new Error("Couldn't create peer connection");
+    f.pc = pc;
+    f.candidates = [];
+    pc.onsignalingstatechange = function(e) {
+        if(pc.signalingState === 'stable') {
+            f.candidates.forEach(c => pc.addIceCandidate(c).catch(console.warn));
+            f.candidates = [];
+        }
+    };
+    pc.onicecandidate = function(e) {
+        serverConnection.userMessage('filedownice', f.userid, {
+            id: f.id,
+            candidate: e.candidate,
+        });
+    };
+    f.dc = pc.createDataChannel('file');
+    f.data = [];
+    f.datalen = 0;
+    f.dc.onclose = function(e) {
+        try {
+            closeReceiveFileData(f);
+        } catch(e) {
+            failFile(f, e);
+        }
+    };
+    f.dc.onmessage = function(e) {
+        try {
+            receiveFileData(f, e.data);
+        } catch(e) {
+            failFile(f, e);
+        }
+    };
+    f.dc.onerror = function(e) {
+        /** @ts-ignore */
+        let err = e.error;
+        failFile(f, err);
+    };
+    let offer = await pc.createOffer();
+    if(!offer)
+        throw new Error("Couldn't create offer");
+    await pc.setLocalDescription(offer);
+    serverConnection.userMessage('getfile', f.userid, {
+        id: f.id,
+        offer: pc.localDescription.sdp,
+    });
+    setFileStatus(f, 'Negotiating...', true);
+}
+
+/**
+ * @param {TransferredFile} f
+ */
+async function rejectFile(f) {
+    serverConnection.userMessage('rejectfile', f.userid, {
+        id: f.id,
+    });
+    setFileStatus(f, 'Rejected.', true, true);
+    f.close();
+}
+
+/**
+ * @param {TransferredFile} f
+ * @param {string} sdp
+ */
+async function sendOfferedFile(f, sdp) {
+    if(f.pc)
+        throw new Error('Transfer already in progress');
+
+    setFileStatus(f, 'Negotiating...', true);
+    let pc = new RTCPeerConnection(serverConnection.getRTCConfiguration());
+    if(!pc)
+        throw new Error("Couldn't create peer connection");
+    f.pc = pc;
+    f.candidates = [];
+    pc.onicecandidate = function(e) {
+        serverConnection.userMessage('fileupice', f.userid, {
+            id: f.id,
+            candidate: e.candidate,
+        });
+    };
+    pc.onsignalingstatechange = function(e) {
+        if(pc.signalingState === 'stable') {
+            f.candidates.forEach(c => pc.addIceCandidate(c).catch(console.warn));
+            f.candidates = [];
+        }
+    };
+    pc.ondatachannel = function(e) {
+        if(f.dc)
+            throw new Error('Duplicate datachannel');
+        f.dc = /** @type{RTCDataChannel} */(e.channel);
+        f.dc.onclose = function(e) {
+            try {
+                closeSendFileData(f);
+            } catch(e) {
+                failFile(f, e);
+            }
+        };
+        f.dc.onerror = function(e) {
+            /** @ts-ignore */
+            let err = e.error;
+            failFile(f, err);
+        }
+        f.dc.onmessage = function(e) {
+            try {
+                ackSendFileData(f, e.data);
+            } catch(e) {
+                failFile(f, e);
+            }
+        };
+        sendFileData(f).catch(e => failFile(f, e));
+    };
+
+    await pc.setRemoteDescription({
+        type: 'offer',
+        sdp: sdp,
+    });
+
+    let answer = await pc.createAnswer();
+    if(!answer)
+        throw new Error("Couldn't create answer");
+    await pc.setLocalDescription(answer);
+    serverConnection.userMessage('sendfile', f.userid, {
+        id: f.id,
+        answer: pc.localDescription.sdp,
+    });
+    setFileStatus(f, 'Uploading...', true);
+}
+
+/**
+ * @param {TransferredFile} f
+ * @param {string} sdp
+ */
+async function receiveFile(f, sdp) {
+    await f.pc.setRemoteDescription({
+        type: 'answer',
+        sdp: sdp,
+    });
+    setFileStatus(f, 'Downloading...', true);
+}
+
+/**
+ * @param {TransferredFile} f
+ */
+async function sendFileData(f) {
+    let r = f.file.stream().getReader();
+
+    f.dc.bufferedAmountLowThreshold = 65536;
+
+    async function write(a) {
+        while(f.dc.bufferedAmount > f.dc.bufferedAmountLowThreshold) {
+            await new Promise((resolve, reject) => {
+                if(!f.dc) {
+                    reject(new Error('File is closed.'));
+                    return;
+                }
+                f.dc.onbufferedamountlow = function(e) {
+                    if(!f.dc) {
+                        reject(new Error('File is closed.'));
+                        return;
+                    }
+                    f.dc.onbufferedamountlow = null;
+                    resolve();
+                }
+            });
+        }
+        f.dc.send(a);
+        f.datalen += a.length;
+        setFileStatus(f, `Uploading... ${f.datalen}/${f.size}`, true);
+    }
+
+    while(true) {
+        let v = await r.read();
+        if(v.done)
+            break;
+        if(!(v.value instanceof Uint8Array))
+            throw new Error('Unexpected type for chunk');
+        if(v.value.length <= 16384) {
+            await write(v.value);
+        } else {
+            for(let i = 0; i < v.value.length; i += 16384) {
+                let a = new Uint8Array(
+                    v.value.buffer, i, Math.min(16384, v.value.length - i),
+                );
+                await write(a);
+            }
+        }
+    }
+}
+
+/**
+ * @param {TransferredFile} f
+ */
+function ackSendFileData(f, data) {
+    if(data === 'done' && f.datalen == f.size)
+        setFileStatus(f, 'Done.', true, true);
+    else
+        setFileStatus(f, 'Failed.', true, true);
+    f.dc.onclose = null;
+    f.dc.onerror = null;
+    f.close();
+}
+
+/**
+ * @param {TransferredFile} f
+ */
+function closeSendFileData(f) {
+    setFileStatus(f, 'Failed.', true, true);
+    f.close();
+}
+
+/**
+ * @param {TransferredFile} f
+ * @param {Blob|ArrayBuffer} data
+ */
+function receiveFileData(f, data) {
+    f.pushData(data);
+    setFileStatus(f, `Downloading... ${f.datalen}/${f.size}`, true);
+
+    if(f.datalen < f.size)
+        return;
+
+    if(f.datalen != f.size) {
+        setFileStatus(f, 'Failed.', true, true);
+        f.close();
+        return;
+    }
+
+    f.dc.onmessage = null;
+    doneReceiveFileData(f);
+}
+
+/**
+ * @param {TransferredFile} f
+ */
+async function doneReceiveFileData(f) {
+    setFileStatus(f, 'Done.', true, true);
+    let blob = f.getData();
+
+    await new Promise((resolve, reject) => {
+        let timer = setTimeout(function(e) { resolve(); }, 2000);
+        f.dc.onclose = function(e) {
+            clearTimeout(timer);
+            resolve();
+        };
+        f.dc.onerror = function(e) {
+            clearTimeout(timer);
+            resolve();
+        };
+        f.dc.send('done');
+    });
+
+    f.dc.onclose = null;
+    f.dc.onerror = null;
+    f.close();
+
+    let url = URL.createObjectURL(blob);
+    let a = document.createElement('a');
+    a.href = url;
+    a.textContent = f.name;
+    a.download = f.name;
+    a.type = f.type;
+    a.click();
+    URL.revokeObjectURL(url);
+}
+
+/**
+ * @param {TransferredFile} f
+ */
+function closeReceiveFileData(f) {
+    if(f.datalen !== f.size) {
+        setFileStatus(f, 'Failed.', true, true)
+        f.close();
+    }
+}
+
+/**
+ * @param {string} id
+ * @param {string} dest
+ * @param {string} username
+ * @param {number} time
+ * @param {boolean} privileged
+ * @param {string} kind
+ * @param {any} message
+ */
+function gotUserMessage(id, dest, username, time, privileged, kind, message) {
+    switch(kind) {
+    case 'kicked':
+    case 'error':
+    case 'warning':
+    case 'info':
+        let from = id ? (username || 'Anonymous') : 'The Server';
+        if(privileged)
+            displayError(`${from} said: ${message}`, kind);
+        else
+            console.error(`Got unprivileged message of kind ${kind}`);
+        break;
+    case 'mute':
+        if(privileged) {
+            setLocalMute(true, true);
+            let by = username ? ' by ' + username : '';
+            displayWarning(`You have been muted${by}`);
+        } else {
+            console.error(`Got unprivileged message of kind ${kind}`);
+        }
+        break;
+    case 'clearchat':
+        if(privileged) {
+            clearChat();
+        } else {
+            console.error(`Got unprivileged message of kind ${kind}`);
+        }
+        break;
+    case 'offerfile': {
+        let f = new TransferredFile(
+            message.id, id, false, username,
+            message.name, message.type, message.size,
+        );
+        transferredFiles[f.fullid()] = f;
+        fileTransferBox(f);
+        break;
+    }
+    case 'cancelfile': {
+        let f = TransferredFile.get(false, id, message.id);
+        if(!f)
+            throw new Error('unexpected cancelfile');
+        setFileStatus(f, 'Cancelled.', true, true);
+        f.close();
+        break;
+    }
+    case 'getfile': {
+        let f = TransferredFile.get(true, id, message.id);
+        if(!f)
+            throw new Error('unexpected getfile');
+        sendOfferedFile(f, message.offer);
+        break;
+    }
+    case 'rejectfile': {
+        let f = TransferredFile.get(true, id, message.id);
+        if(!f)
+            throw new Error('unexpected rejectfile');
+        setFileStatus(f, 'Rejected.', true, true);
+        f.close();
+        break;
+    }
+    case 'sendfile': {
+        let f = TransferredFile.get(false, id, message.id);
+        if(!f)
+            throw new Error('unexpected sendfile');
+        receiveFile(f, message.answer);
+        break;
+    }
+    case 'filedownice': {
+        let f = TransferredFile.get(true, id, message.id);
+        if(!f.pc) {
+            console.warn('Unexpected filedownice');
+            return;
+        }
+        if(f.pc.signalingState === 'stable')
+            f.pc.addIceCandidate(message.candidate).catch(console.warn);
+        else
+            f.candidates.push(message.candidate);
+        break;
+    }
+    case 'fileupice': {
+        let f = TransferredFile.get(false, id, message.id);
+        if(!f.pc) {
+            console.warn('Unexpected fileupice');
+            return;
+        }
+        if(f.pc.signalingState === 'stable')
+            f.pc.addIceCandidate(message.candidate).catch(console.warn);
+        else
+            f.candidates.push(message.candidate);
+        break;
+
+    }
+    default:
+        console.warn(`Got unknown user message ${kind}`);
+        break;
+    }
+};
+
+
+const urlRegexp = /https?:\/\/[-a-zA-Z0-9@:%/._\\+~#&()=?]+[-a-zA-Z0-9@:%/_\\+~#&()=]/g;
+
+/**
+ * @param {string} line
+ * @returns {Array.<Text|HTMLElement>}
+ */
+function formatLine(line) {
+    let r = new RegExp(urlRegexp);
+    let result = [];
+    let pos = 0;
+    while(true) {
+        let m = r.exec(line);
+        if(!m)
+            break;
+        result.push(document.createTextNode(line.slice(pos, m.index)));
+        let a = document.createElement('a');
+        a.href = m[0];
+        a.textContent = m[0];
+        a.target = '_blank';
+        a.rel = 'noreferrer noopener';
+        result.push(a);
+        pos = m.index + m[0].length;
+    }
+    result.push(document.createTextNode(line.slice(pos)));
+    return result;
+}
+
+/**
+ * @param {string[]} lines
+ * @returns {HTMLElement}
+ */
+function formatLines(lines) {
+    let elts = [];
+    if(lines.length > 0)
+        elts = formatLine(lines[0]);
+    for(let i = 1; i < lines.length; i++) {
+        elts.push(document.createElement('br'));
+        elts = elts.concat(formatLine(lines[i]));
+    }
+    let elt = document.createElement('p');
+    elts.forEach(e => elt.appendChild(e));
+    return elt;
+}
+
+/**
+ * @param {number} time
+ * @returns {string}
+ */
+function formatTime(time) {
+    let delta = Date.now() - time;
+    let date = new Date(time);
+    let m = date.getMinutes();
+    if(delta > -30000)
+        return date.getHours() + ':' + ((m < 10) ? '0' : '') + m;
+    return date.toLocaleString();
+}
+
+/**
+ * @typedef {Object} lastMessage
+ * @property {string} [nick]
+ * @property {string} [peerId]
+ * @property {string} [dest]
+ * @property {number} [time]
+ */
+
+/** @type {lastMessage} */
+let lastMessage = {};
+
+/**
+ * @param {string} peerId
+ * @param {string} dest
+ * @param {string} nick
+ * @param {number} time
+ * @param {boolean} privileged
+ * @param {boolean} history
+ * @param {string} kind
+ * @param {unknown} message
+ */
+function addToChatbox(peerId, dest, nick, time, privileged, history, kind, message) {
+    let row = document.createElement('div');
+    row.classList.add('message-row');
+    let container = document.createElement('div');
+    container.classList.add('message');
+    row.appendChild(container);
+    let footer = document.createElement('p');
+    footer.classList.add('message-footer');
+    if(!peerId)
+        container.classList.add('message-system');
+    if(serverConnection && peerId === serverConnection.id)
+        container.classList.add('message-sender');
+    if(dest)
+        container.classList.add('message-private');
+
+    if(kind !== 'me') {
+        let p = formatLines(message.toString().split('\n'));
+        let doHeader = true;
+        if(!peerId && !dest && !nick) {
+            doHeader = false;
+        } else if(lastMessage.nick !== (nick || null) ||
+                  lastMessage.peerId !== peerId ||
+                  lastMessage.dest !== (dest || null) ||
+                  !time || !lastMessage.time) {
+            doHeader = true;
+        } else {
+            let delta = time - lastMessage.time;
+            doHeader = delta < 0 || delta > 60000;
+        }
+
+        if(doHeader) {
+            let header = document.createElement('p');
+            if(peerId || nick || dest) {
+                let user = document.createElement('span');
+                let u = serverConnection.users[dest];
+                let name = (u && u.username);
+                user.textContent = dest ?
+                    `${nick||'(anon)'} \u2192 ${name || '(anon)'}` :
+                    (nick || '(anon)');
+                user.classList.add('message-user');
+                header.appendChild(user);
+            }
+            header.classList.add('message-header');
+            container.appendChild(header);
+            if(time) {
+                let tm = document.createElement('span');
+                tm.textContent = formatTime(time);
+                tm.classList.add('message-time');
+                header.appendChild(tm);
+            }
+        }
+
+        p.classList.add('message-content');
+        container.appendChild(p);
+        lastMessage.nick = (nick || null);
+        lastMessage.peerId = peerId;
+        lastMessage.dest = (dest || null);
+        lastMessage.time = (time || null);
+    } else {
+        let asterisk = document.createElement('span');
+        asterisk.textContent = '*';
+        asterisk.classList.add('message-me-asterisk');
+        let user = document.createElement('span');
+        user.textContent = nick || '(anon)';
+        user.classList.add('message-me-user');
+        let content = document.createElement('span');
+        formatLine(message.toString()).forEach(elt => {
+            content.appendChild(elt);
+        });
+        content.classList.add('message-me-content');
+        container.appendChild(asterisk);
+        container.appendChild(user);
+        container.appendChild(content);
+        container.classList.add('message-me');
+        lastMessage = {};
+    }
+    container.appendChild(footer);
+
+    let box = document.getElementById('box');
+    box.appendChild(row);
+    if(box.scrollHeight > box.clientHeight) {
+        box.scrollTop = box.scrollHeight - box.clientHeight;
+    }
+
+    return message;
+}
+
+/**
+ * @param {string} message
+ */
+function localMessage(message) {
+    return addToChatbox(null, null, null, Date.now(), false, false, '', message);
+}
+
+function clearChat() {
+    lastMessage = {};
+    document.getElementById('box').textContent = '';
+}
+
+/**
+ * A command known to the command-line parser.
+ *
+ * @typedef {Object} command
+ * @property {string} [parameters]
+ *     - A user-readable list of parameters.
+ * @property {string} [description]
+ *     - A user-readable description, null if undocumented.
+ * @property {() => string} [predicate]
+ *     - Returns null if the command is available.
+ * @property {(c: string, r: string) => void} f
+ */
+
+/**
+ * The set of commands known to the command-line parser.
+ *
+ * @type {Object.<string,command>}
+ */
+let commands = {};
+
+function operatorPredicate() {
+    if(serverConnection && serverConnection.permissions &&
+       serverConnection.permissions.indexOf('op') >= 0)
+        return null;
+    return 'You are not an operator';
+}
+
+function recordingPredicate() {
+    if(serverConnection && serverConnection.permissions &&
+       serverConnection.permissions.indexOf('record') >= 0)
+        return null;
+    return 'You are not allowed to record';
+}
+
+commands.help = {
+    description: 'display this help',
+    f: (c, r) => {
+        /** @type {string[]} */
+        let cs = [];
+        for(let cmd in commands) {
+            let c = commands[cmd];
+            if(!c.description)
+                continue;
+            if(c.predicate && c.predicate())
+                continue;
+            cs.push(`/${cmd}${c.parameters?' ' + c.parameters:''}: ${c.description}`);
+        }
+        localMessage(cs.sort().join('\n'));
+    }
+};
+
+commands.me = {
+    f: (c, r) => {
+        // handled as a special case
+        throw new Error("this shouldn't happen");
+    }
+};
+
+commands.set = {
+    f: (c, r) => {
+        if(!r) {
+            let settings = getSettings();
+            let s = "";
+            for(let key in settings)
+                s = s + `${key}: ${JSON.stringify(settings[key])}\n`;
+            localMessage(s);
+            return;
+        }
+        let p = parseCommand(r);
+        let value;
+        if(p[1]) {
+            value = JSON.parse(p[1]);
+        } else {
+            value = true;
+        }
+        updateSetting(p[0], value);
+        reflectSettings();
+    }
+};
+
+commands.unset = {
+    f: (c, r) => {
+        delSetting(r.trim());
+        return;
+    }
+};
+
+commands.leave = {
+    description: "leave group",
+    f: (c, r) => {
+        if(!serverConnection)
+            throw new Error('Not connected');
+        serverConnection.close();
+    }
+};
+
+commands.clear = {
+    predicate: operatorPredicate,
+    description: 'clear the chat history',
+    f: (c, r) => {
+        serverConnection.groupAction('clearchat');
+    }
+};
+
+commands.lock = {
+    predicate: operatorPredicate,
+    description: 'lock this group',
+    parameters: '[message]',
+    f: (c, r) => {
+        serverConnection.groupAction('lock', r);
+    }
+};
+
+commands.unlock = {
+    predicate: operatorPredicate,
+    description: 'unlock this group, revert the effect of /lock',
+    f: (c, r) => {
+        serverConnection.groupAction('unlock');
+    }
+};
+
+commands.record = {
+    predicate: recordingPredicate,
+    description: 'start recording',
+    f: (c, r) => {
+        serverConnection.groupAction('record');
+    }
+};
+
+commands.unrecord = {
+    predicate: recordingPredicate,
+    description: 'stop recording',
+    f: (c, r) => {
+        serverConnection.groupAction('unrecord');
+    }
+};
+
+commands.subgroups = {
+    predicate: operatorPredicate,
+    description: 'list subgroups',
+    f: (c, r) => {
+        serverConnection.groupAction('subgroups');
+    }
+};
+
+function renegotiateStreams() {
+    for(let id in serverConnection.up)
+        serverConnection.up[id].restartIce();
+    for(let id in serverConnection.down)
+        serverConnection.down[id].restartIce();
+}
+
+commands.renegotiate = {
+    description: 'renegotiate media streams',
+    f: (c, r) => {
+        renegotiateStreams();
+    }
+};
+
+commands.replace = {
+    f: (c, r) => {
+        replaceUpStreams(null);
+    }
+};
+
+
+/**
+ * parseCommand splits a string into two space-separated parts.  The first
+ * part may be quoted and may include backslash escapes.
+ *
+ * @param {string} line
+ * @returns {string[]}
+ */
+function parseCommand(line) {
+    let i = 0;
+    while(i < line.length && line[i] === ' ')
+        i++;
+    let start = ' ';
+    if(i < line.length && line[i] === '"' || line[i] === "'") {
+        start = line[i];
+        i++;
+    }
+    let first = "";
+    while(i < line.length) {
+        if(line[i] === start) {
+            if(start !== ' ')
+                i++;
+            break;
+        }
+        if(line[i] === '\\' && i < line.length - 1)
+            i++;
+        first = first + line[i];
+        i++;
+    }
+
+    while(i < line.length && line[i] === ' ')
+        i++;
+    return [first, line.slice(i)];
+}
+
+/**
+ * @param {string} user
+ */
+function findUserId(user) {
+    if(user in serverConnection.users)
+        return user;
+
+    for(let id in serverConnection.users) {
+        let u = serverConnection.users[id];
+        if(u && u.username === user)
+            return id;
+    }
+    return null;
+}
+
+commands.msg = {
+    parameters: 'user message',
+    description: 'send a private message',
+    f: (c, r) => {
+        let p = parseCommand(r);
+        if(!p[0])
+            throw new Error('/msg requires parameters');
+        let id = findUserId(p[0]);
+        if(!id)
+            throw new Error(`Unknown user ${p[0]}`);
+        serverConnection.chat('', id, p[1]);
+        addToChatbox(serverConnection.id, id, serverConnection.username,
+                     Date.now(), false, false, '', p[1]);
+    }
+};
+
+/**
+   @param {string} c
+   @param {string} r
+*/
+function userCommand(c, r) {
+    let p = parseCommand(r);
+    if(!p[0])
+        throw new Error(`/${c} requires parameters`);
+    let id = findUserId(p[0]);
+    if(!id)
+        throw new Error(`Unknown user ${p[0]}`);
+    serverConnection.userAction(c, id, p[1]);
+}
+
+function userMessage(c, r) {
+    let p = parseCommand(r);
+    if(!p[0])
+        throw new Error(`/${c} requires parameters`);
+    let id = findUserId(p[0]);
+    if(!id)
+        throw new Error(`Unknown user ${p[0]}`);
+    serverConnection.userMessage(c, id, p[1]);
+}
+
+commands.kick = {
+    parameters: 'user [message]',
+    description: 'kick out a user',
+    predicate: operatorPredicate,
+    f: userCommand,
+};
+
+commands.op = {
+    parameters: 'user',
+    description: 'give operator status',
+    predicate: operatorPredicate,
+    f: userCommand,
+};
+
+commands.unop = {
+    parameters: 'user',
+    description: 'revoke operator status',
+    predicate: operatorPredicate,
+    f: userCommand,
+};
+
+commands.present = {
+    parameters: 'user',
+    description: 'give user the right to present',
+    predicate: operatorPredicate,
+    f: userCommand,
+};
+
+commands.unpresent = {
+    parameters: 'user',
+    description: 'revoke the right to present',
+    predicate: operatorPredicate,
+    f: userCommand,
+};
+
+commands.mute = {
+    parameters: 'user',
+    description: 'mute a remote user',
+    predicate: operatorPredicate,
+    f: userMessage,
+};
+
+commands.muteall = {
+    description: 'mute all remote users',
+    predicate: operatorPredicate,
+    f: (c, r) => {
+        serverConnection.userMessage('mute', null, null, true);
+    }
+}
+
+commands.warn = {
+    parameters: 'user message',
+    description: 'send a warning to a user',
+    predicate: operatorPredicate,
+    f: (c, r) => {
+        userMessage('warning', r);
+    },
+};
+
+commands.wall = {
+    parameters: 'message',
+    description: 'send a warning to all users',
+    predicate: operatorPredicate,
+    f: (c, r) => {
+        if(!r)
+            throw new Error('empty message');
+        serverConnection.userMessage('warning', '', r);
+    },
+};
+
+commands.raise = {
+    description: 'raise hand',
+    f: (c, r) => {
+        serverConnection.userAction(
+            "setdata", serverConnection.id, {"raisehand": true},
+        );
+    }
+}
+
+commands.unraise = {
+    description: 'unraise hand',
+    f: (c, r) => {
+        serverConnection.userAction(
+            "setdata", serverConnection.id, {"raisehand": null},
+        );
+    }
+}
+
+/** @returns {boolean} */
+function canFile() {
+    let v =
+        /** @ts-ignore */
+        !!HTMLVideoElement.prototype.captureStream ||
+        /** @ts-ignore */
+        !!HTMLVideoElement.prototype.mozCaptureStream;
+    return v;
+}
+
+function presentFile() {
+    let input = document.createElement('input');
+    input.type = 'file';
+    input.accept="audio/*,video/*";
+    input.onchange = function(e) {
+        if(!(this instanceof HTMLInputElement))
+            throw new Error('Unexpected type for this');
+        let files = this.files;
+        for(let i = 0; i < files.length; i++) {
+            addFileMedia(files[i]).catch(e => {
+                console.error(e);
+                displayError(e);
+            });
+        }
+    };
+    input.click();
+}
+
+commands.presentfile = {
+    description: 'broadcast a video or audio file',
+    f: (c, r) => {
+        presentFile();
+    },
+    predicate: () => {
+        if(!canFile())
+            return 'Your browser does not support presenting arbitrary files';
+        if(!serverConnection || !serverConnection.permissions ||
+           serverConnection.permissions.indexOf('present') < 0)
+            return 'You are not authorised to present.';
+        return null;
+    }
+};
+
+
+/**
+ * @param {string} id
+ */
+function sendFile(id) {
+    let input = document.createElement('input');
+    input.type = 'file';
+    input.onchange = function(e) {
+        if(!(this instanceof HTMLInputElement))
+            throw new Error('Unexpected type for this');
+        let files = this.files;
+        for(let i = 0; i < files.length; i++) {
+            try {
+                offerFile(id, files[i]);
+            } catch(e) {
+                console.error(e);
+                displayError(e);
+            }
+        }
+    };
+    input.click();
+}
+
+commands.sendfile = {
+    parameters: 'user',
+    description: 'send a file (this will disclose your IP address)',
+    f: (c, r) => {
+        let p = parseCommand(r);
+        if(!p[0])
+            throw new Error(`/${c} requires parameters`);
+        let id = findUserId(p[0]);
+        if(!id)
+            throw new Error(`Unknown user ${p[0]}`);
+        sendFile(id);
+    },
+};
+
+/**
+ * Test loopback through a TURN relay.
+ *
+ * @returns {Promise<number>}
+ */
+async function relayTest() {
+    if(!serverConnection)
+        throw new Error('not connected');
+    let conf = Object.assign({}, serverConnection.getRTCConfiguration());
+    conf.iceTransportPolicy = 'relay';
+    let pc1 = new RTCPeerConnection(conf);
+    let pc2 = new RTCPeerConnection(conf);
+    pc1.onicecandidate = e => {e.candidate && pc2.addIceCandidate(e.candidate);};
+    pc2.onicecandidate = e => {e.candidate && pc1.addIceCandidate(e.candidate);};
+    try {
+        return await new Promise(async (resolve, reject) => {
+            let d1 = pc1.createDataChannel('loopbackTest');
+            d1.onopen = e => {
+                d1.send(Date.now().toString());
+            };
+
+            let offer = await pc1.createOffer();
+            await pc1.setLocalDescription(offer);
+            await pc2.setRemoteDescription(pc1.localDescription);
+            let answer = await pc2.createAnswer();
+            await pc2.setLocalDescription(answer);
+            await pc1.setRemoteDescription(pc2.localDescription);
+
+            pc2.ondatachannel = e => {
+                let d2 = e.channel;
+                d2.onmessage = e => {
+                    let t = parseInt(e.data);
+                    if(isNaN(t))
+                        reject(new Error('corrupt data'));
+                    else
+                        resolve(Date.now() - t);
+                }
+            }
+
+            setTimeout(() => reject(new Error('timeout')), 5000);
+        })
+    } finally {
+        pc1.close();
+        pc2.close();
+    }
+}
+
+commands['relay-test'] = {
+    f: async (c, r) => {
+        localMessage('Relay test in progress...');
+        try {
+            let s = Date.now();
+            let rtt = await relayTest();
+            let e = Date.now();
+            localMessage(`Relay test successful in ${e-s}ms, RTT ${rtt}ms`);
+        } catch(e) {
+            localMessage(`Relay test failed: ${e}`);
+        }
+    }
+}
+
+function handleInput() {
+    let input = /** @type {HTMLTextAreaElement} */
+        (document.getElementById('input'));
+    let data = input.value;
+    input.value = '';
+
+    let message, me;
+
+    if(data === '')
+        return;
+
+    if(data[0] === '/') {
+        if(data.length > 1 && data[1] === '/') {
+            message = data.slice(1);
+            me = false;
+        } else {
+            let cmd, rest;
+            let space = data.indexOf(' ');
+            if(space < 0) {
+                cmd = data.slice(1);
+                rest = '';
+            } else {
+                cmd = data.slice(1, space);
+                rest = data.slice(space + 1);
+            }
+
+            if(cmd === 'me') {
+                message = rest;
+                me = true;
+            } else {
+                let c = commands[cmd];
+                if(!c) {
+                    displayError(`Uknown command /${cmd}, type /help for help`);
+                    return;
+                }
+                if(c.predicate) {
+                    let s = c.predicate();
+                    if(s) {
+                        displayError(s);
+                        return;
+                    }
+                }
+                try {
+                    c.f(cmd, rest);
+                } catch(e) {
+                    console.error(e);
+                    displayError(e);
+                }
+                return;
+            }
+        }
+    } else {
+        message = data;
+        me = false;
+    }
+
+    if(!serverConnection || !serverConnection.socket) {
+        displayError("Not connected.");
+        return;
+    }
+
+    try {
+        serverConnection.chat(me ? 'me' : '', '', message);
+    } catch(e) {
+        console.error(e);
+        displayError(e);
+    }
+}
+
+document.getElementById('inputform').onsubmit = function(e) {
+    e.preventDefault();
+    handleInput();
+};
+
+document.getElementById('input').onkeypress = function(e) {
+    if(e.key === 'Enter' && !e.ctrlKey && !e.shiftKey && !e.metaKey) {
+        e.preventDefault();
+        handleInput();
+    }
+};
+
+function chatResizer(e) {
+    e.preventDefault();
+    let full_width = document.getElementById("mainrow").offsetWidth;
+    let left = document.getElementById("left");
+    let right = document.getElementById("right");
+
+    let start_x = e.clientX;
+    let start_width = left.offsetWidth;
+
+    function start_drag(e) {
+        let left_width = (start_width + e.clientX - start_x) * 100 / full_width;
+        // set min chat width to 300px
+        let min_left_width = 300 * 100 / full_width;
+        if (left_width < min_left_width) {
+          return;
+        }
+        left.style.flex = left_width.toString();
+        right.style.flex = (100 - left_width).toString();
+    }
+    function stop_drag(e) {
+        document.documentElement.removeEventListener(
+            'mousemove', start_drag, false,
+        );
+        document.documentElement.removeEventListener(
+            'mouseup', stop_drag, false,
+        );
+    }
+
+    document.documentElement.addEventListener(
+        'mousemove', start_drag, false,
+    );
+    document.documentElement.addEventListener(
+        'mouseup', stop_drag, false,
+    );
+}
+
+document.getElementById('resizer').addEventListener('mousedown', chatResizer, false);
+
+/**
+ * @param {unknown} message
+ * @param {string} [level]
+ */
+function displayError(message, level) {
+    if(!level)
+        level = "error";
+    var background = 'linear-gradient(to right, #e20a0a, #df2d2d)';
+    var position = 'center';
+    var gravity = 'top';
+
+    switch(level) {
+    case "info":
+        background = 'linear-gradient(to right, #529518, #96c93d)';
+        position = 'right';
+        gravity = 'bottom';
+        break;
+    case "warning":
+        background = "linear-gradient(to right, #bdc511, #c2cf01)";
+        break;
+    case "kicked":
+        level = "error";
+        break;
+    }
+
+    /** @ts-ignore */
+    Toastify({
+        text: message,
+        duration: 4000,
+        close: true,
+        position: position,
+        gravity: gravity,
+        style: {
+            background: background,
+        },
+        className: level,
+    }).showToast();
+}
+
+/**
+ * @param {unknown} message
+ */
+function displayWarning(message) {
+    return displayError(message, "warning");
+}
+
+/**
+ * @param {unknown} message
+ */
+function displayMessage(message) {
+    return displayError(message, "info");
+}
+
+let connecting = false;
+
+document.getElementById('userform').onsubmit = async function(e) {
+    e.preventDefault();
+    if(connecting)
+        return;
+    connecting = true;
+    try {
+        await serverConnect();
+    } finally {
+        connecting = false;
+    }
+
+    if(getInputElement('presentboth').checked)
+        presentRequested = 'both';
+    else if(getInputElement('presentmike').checked)
+        presentRequested = 'mike';
+    else
+        presentRequested = null;
+
+    getInputElement('presentoff').checked = true;
+};
+
+document.getElementById('disconnectbutton').onclick = function(e) {
+    serverConnection.close();
+    closeNav();
+};
+
+function openNav() {
+    document.getElementById("sidebarnav").style.width = "250px";
+}
+
+function closeNav() {
+    document.getElementById("sidebarnav").style.width = "0";
+}
+
+document.getElementById('sidebarCollapse').onclick = function(e) {
+    document.getElementById("left-sidebar").classList.toggle("active");
+    document.getElementById("mainrow").classList.toggle("full-width-active");
+};
+
+document.getElementById('openside').onclick = function(e) {
+      e.preventDefault();
+      let sidewidth = document.getElementById("sidebarnav").style.width;
+      if (sidewidth !== "0px" && sidewidth !== "") {
+          closeNav();
+          return;
+      } else {
+          openNav();
+      }
+};
+
+
+document.getElementById('clodeside').onclick = function(e) {
+    e.preventDefault();
+    closeNav();
+};
+
+document.getElementById('collapse-video').onclick = function(e) {
+    e.preventDefault();
+    setVisibility('collapse-video', false);
+    setVisibility('show-video', true);
+    hideVideo(true);
+};
+
+document.getElementById('show-video').onclick = function(e) {
+    e.preventDefault();
+    setVisibility('video-container', true);
+    setVisibility('collapse-video', true);
+    setVisibility('show-video', false);
+};
+
+document.getElementById('close-chat').onclick = function(e) {
+    e.preventDefault();
+    setVisibility('left', false);
+    setVisibility('show-chat', true);
+    resizePeers();
+};
+
+document.getElementById('show-chat').onclick = function(e) {
+    e.preventDefault();
+    setVisibility('left', true);
+    setVisibility('show-chat', false);
+    resizePeers();
+};
+
+async function serverConnect() {
+    if(serverConnection && serverConnection.socket)
+        serverConnection.close();
+    serverConnection = new ServerConnection();
+    serverConnection.onconnected = gotConnected;
+    serverConnection.onpeerconnection = onPeerConnection;
+    serverConnection.onclose = gotClose;
+    serverConnection.ondownstream = gotDownStream;
+    serverConnection.onuser = gotUser;
+    serverConnection.onjoined = gotJoined;
+    serverConnection.onchat = addToChatbox;
+    serverConnection.onusermessage = gotUserMessage;
+
+    let url = `ws${location.protocol === 'https:' ? 's' : ''}://${location.host}/ws`;
+    try {
+        await serverConnection.connect(url);
+    } catch(e) {
+        console.error(e);
+        displayError(e.message ? e.message : "Couldn't connect to " + url);
+    }
+}
+
+async function start() {
+    group = decodeURIComponent(
+        location.pathname.replace(/^\/[a-z]*\//, '').replace(/\/$/, '')
+    );
+
+    /** @type {Object} */
+    try {
+        let r = await fetch(".status.json")
+        if(!r.ok)
+            throw new Error(`${r.status} ${r.statusText}`);
+        groupStatus = await r.json()
+    } catch(e) {
+        console.error(e);
+        return;
+    }
+
+    let parms = new URLSearchParams(window.location.search);
+    if(window.location.search)
+        window.history.replaceState(null, '', window.location.pathname);
+    setTitle(groupStatus.displayName || capitalise(group));
+
+    addFilters();
+    setMediaChoices(false).then(e => reflectSettings());
+
+    if(parms.has('token')) {
+        token = parms.get('token');
+        await serverConnect();
+    } else if(groupStatus.authPortal) {
+        window.location.href = groupStatus.authPortal;
+    } else {
+        let container = document.getElementById("login-container");
+        container.classList.remove('invisible');
+    }
+    setViewportHeight();
+}
+
+//start();
+
+window.onload = async function() {	
+
+// On resize and orientation change, we update viewport height
+	addEventListener('resize', setViewportHeight);
+	addEventListener('orientationchange', setViewportHeight);
+
+	getButtonElement('presentbutton').onclick = async function(e) {
+		e.preventDefault();
+		let button = this;
+		if(!(button instanceof HTMLButtonElement))
+			throw new Error('Unexpected type for this.');
+		// there's a potential race condition here: the user might click the
+		// button a second time before the stream is set up and the button hidden.
+		button.disabled = true;
+		try {
+			let id = findUpMedia('camera');
+			if(!id)
+				await addLocalMedia();
+		} finally {
+			button.disabled = false;
+		}
+	};
+
+	getButtonElement('unpresentbutton').onclick = function(e) {
+		e.preventDefault();
+		closeUpMedia('camera');
+		resizePeers();
+	};
+
+    if(serverConnection && serverConnection.socket)
+        serverConnection.close();
+    serverConnection = new ServerConnection();
+    serverConnection.onconnected = amConnected;
+    serverConnection.onpeerconnection = onPeerConnection;
+    serverConnection.onclose = gotClose;
+    serverConnection.ondownstream = gotDownStream;
+    serverConnection.onuser = gotUser;
+    serverConnection.onjoined = gotJoined;
+    serverConnection.onchat = addToChatbox;
+    serverConnection.onusermessage = gotUserMessage;
+
+	const username = urlParam("username");
+    const jid = username + "@localhost";
+    const password = "Welcome123";
+	
+	group = "public/" + urlParam("group");	
+	setTitle(capitalise(group));
+    addFilters();
+    setMediaChoices(false).then(e => reflectSettings());	
+
+    const connection = window.top?.connection;
+	console.debug("onload", connection);	
+	
+	if (connection) {
+		await serverConnection.connect(connection);
+		setViewportHeight();			
+	}
+}
+
+window.onunload = async function() {	
+	serverConnection.close();
+}
+
+function urlParam (name) {
+	var results = new RegExp('[\\?&]' + name + '=([^&#]*)').exec(window.location.href);
+	if (!results) { return undefined; }
+	return unescape(results[1] || undefined);
+}
+
+async function amConnected() {
+	console.debug("amConnected");		
+	const username = urlParam("username");
+	const pw = "";
+
+    try {
+        await serverConnection.join(group, username, pw);
+    } catch(e) {
+        console.error(e);
+        serverConnection.close();
+    }
+}
+
+function closeConnection() {
+	setTimeout(() => {
+		serverConnection.close();	
+		location.href = "about:blank";		
+	}, 1000);	
+}

+ 1351 - 0
packages/galene/galene.css

@@ -0,0 +1,1351 @@
+.nav-fixed .topnav {
+    z-index: 1039;
+}
+
+.fixed-top{
+    position: fixed;
+    top: 0;
+    right: 0;
+    left: 0;
+    z-index: 1030;
+}
+
+.topnav {
+    padding-left: 0;
+    height: 3.5rem;
+    z-index: 1039;
+}
+
+.navbar .form-control, .topnav {
+    font-size: 1rem;
+}
+
+.form-control {
+    display: block;
+    padding: .375rem .75rem;
+    font-size: 1rem;
+    line-height: 1.5;
+    color: #495057;
+    background-color: #fff;
+    background-clip: padding-box;
+    border: 1px solid #ced4da;
+    border-radius: .25rem;
+    transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
+}
+
+.form-control-inline {
+    display: inline-block;
+}
+
+.shadow {
+    box-shadow: 0 .15rem 1.75rem 0 rgba(31,45,65,.15);
+}
+.bg-white {
+    background-color: #fff;
+}
+
+.bg-gray {
+    background-color: #eee;
+}
+
+.profile {
+    width: 230px;
+}
+
+.profile-logo {
+    float: left;
+    width: 50px;
+    height: 50px;
+    background: #b681c3;
+    border-radius: 25px;
+    text-align: center;
+    vertical-align: middle;
+    font-size: 1.4em;
+    padding: 7px;
+    color: #f9f9f9;
+}
+
+.profile-info {
+    float: left;
+    margin-left: 10px;
+    margin-top: 8px;
+    color: #616263;
+    width: 120px;
+}
+
+.user-logout {
+    float: right;
+    text-align: center;
+}
+
+.logout-icon {
+    display: block;
+    font-size: 1.5em;
+}
+
+.logout-text {
+    font-size: .7em;
+}
+
+.profile-info span {
+    display: block;
+    line-height: 1.2;
+    text-transform: capitalize;
+}
+
+#permspan {
+    font-size: .9em;
+    color: #108e07;
+    font-style: italic;
+}
+
+.sidenav .user-logout a {
+    font-size: 1em;
+    padding: 7px 0 0;
+    color: #e4157e;
+    cursor: pointer;
+    line-height: .7;
+}
+
+.sidenav .user-logout a:hover {
+    color: #ab0659;
+}
+
+.navbar, .navbar .container, .navbar .container-fluid, .navbar .container-lg, .navbar .container-md, .navbar .container-sm, .navbar .container-xl {
+    display: -webkit-box;
+    display: flex;
+    flex-wrap: wrap;
+    -webkit-box-align: center;
+    align-items: center;
+    -webkit-box-pack: justify;
+    justify-content: space-between;
+    background: #610a86;
+}
+.navbar {
+    position: relative;
+    padding: .1rem;
+}
+
+.topnav .navbar-brand {
+    width: 15rem;
+    padding-left: 1rem;
+    padding-right: 1rem;
+    margin: 0;
+    font-size: 1rem;
+    font-weight: 700;
+}
+
+.btn {
+    display: inline-block;
+    font-weight: 400;
+    font-size: 1em;
+    text-align: center;
+    white-space: nowrap;
+    vertical-align: middle;
+    -webkit-user-select: none;
+    -moz-user-select: none;
+    -ms-user-select: none;
+    user-select: none;
+    border: 1px solid transparent;
+    padding: 0.255rem .75rem;
+    line-height: 1.5;
+    border-radius: .25rem;
+    transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;
+}
+
+.btn {
+    transition-duration: 0.4s;
+}
+
+.btn-default:hover {
+    color: #fff;
+    background-color: #545b62;
+    border-color: #4e555b;
+}
+
+.btn-default {
+    color: #fff;
+    background-color: #6c757d;
+    border-color: #6c757d;
+}
+
+.btn:not(:disabled):not(.disabled) {
+    cursor: pointer;
+}
+
+.btn-success {
+    color: #fff;
+    background-color: #28a745;
+    border-color: #28a745;
+}
+
+.btn-success:hover {
+    color: #fff;
+    background-color: #218838;
+    border-color: #1e7e34;
+}
+
+.btn-cancel {
+    color: #fff;
+    background-color: #dc3545;
+    border-color: #dc3545;
+}
+
+.btn-cancel:hover {
+    color: #fff;
+    background-color: #c82333;
+    border-color: #bd2130;
+}
+
+.btn-blue {
+    color: #fff;
+    background-color: #007bff;
+    border-color: #007bff;
+}
+
+.btn-blue:hover {
+    color: #fff;
+    background-color: #0069d9;
+    border-color: #0062cc;
+}
+
+.btn-warn {
+    color: #ffc107;
+    background-color: transparent;
+    background-image: none;
+    border-color: #ffc107;
+}
+
+.btn-warn:hover {
+    color: #212529;
+    background-color: #ffc107;
+    border-color: #ffc107;
+}
+
+.btn-large {
+    font-size: 110%;
+}
+
+.app {
+    background-color: #f4f4f4;
+    overflow: hidden;
+    margin: 0;
+    padding: 0;
+    box-shadow: 0 1px 1px 0 rgba(0, 0, 0, .06), 0 2px 5px 0 rgba(0, 0, 0, .2);
+}
+
+.coln-left {
+    flex: 30%;
+    padding: 0;
+    margin: 0;
+}
+
+.coln-left-hide {
+  flex: 0;
+}
+
+.coln-right {
+    flex: 70%;
+    position: relative;
+}
+
+/* Clear floats after the columns */
+.row {
+    display: flex;
+}
+
+.full-height {
+    height: calc(var(--vh, 1vh) * 100);
+}
+
+.full-width {
+    width: calc(100vw - 200px);
+    height: calc(var(--vh, 1vh) * 100 - 56px);
+}
+
+.full-width-active {
+    width: 100vw;
+}
+
+.container {
+    width: 100%;
+}
+
+.users-header {
+    height: 3.5rem;
+    padding: 10px;
+    background: #610a86;
+    font-size: .95rem;
+    font-weight: 500;
+}
+
+.users-header:after, .profile-user:after, .users-header:before {
+    display: table;
+    content: " ";
+}
+
+.users-header:after, .profile-user:after {
+    clear: both;
+}
+
+.reply {
+    height: 53px;
+    width: 100%;
+    background-color: #eae7e5;
+    padding: 10px 5px 10px 5px;
+    margin: 0;
+    z-index: 1000;
+}
+
+.reply textarea {
+    width: 100%;
+    resize: none;
+    overflow: hidden;
+    padding: 5px;
+    outline: none;
+    border: none;
+    text-indent: 5px;
+    box-shadow: none;
+    height: 100%;
+}
+
+textarea.form-reply {
+    height: 2.1em;
+    margin-right: .5em;
+}
+
+.form-reply {
+    display: block;
+    width: 100%;
+    height: 34px;
+    padding: 6px 12px;
+    font-size: 1rem;
+    color: #555;
+    background-color: #fff;
+    background-image: none;
+    border: 1px solid #ccc;
+    border-radius: 4px;
+    box-shadow: inset 0 1px 1px rgba(0,0,0,.075);
+    transition: border-color ease-in-out .15s,box-shadow ease-in-out .15s;
+}
+
+.form-reply::placeholder {
+  opacity: .7;
+}
+
+.select {
+    display: block;
+    width: 100%;
+    padding: .275rem .75rem;
+    font-size: 1rem;
+    line-height: 1.5;
+    color: #495057;
+    background-color: #fff;
+    background-clip: padding-box;
+    border: 1px solid #ced4da;
+    border-radius: .25rem;
+    transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
+}
+
+.select-inline {
+    display: inline-block;
+}
+
+.message {
+    width: auto !important;
+    padding: 4px 10px 7px !important;
+    background: #daf1c6;
+    font-size: 12px;
+    box-shadow: 0 1px 1px rgba(43, 43, 43, 0.16);
+    border-radius: 5px;
+    word-wrap: break-word;
+    display: inline-block;
+    margin: 1em 0 0;
+    max-width: 90%;
+    text-align: left;
+}
+
+.message-sender {
+    background: #e6e6e6;
+}
+
+.message-private {
+    background: white;
+}
+
+.message-private .message-header:after {
+    content: "(private)";
+    margin-left: 1em;
+}
+
+.message-system {
+    font-size: 10px;
+    background: #ececec;
+}
+
+.message-row:after, .message-row:before {
+    display: table;
+    content: " ";
+}
+
+.message-row:after {
+    clear: both;
+}
+
+.message-content {
+    white-space: pre-wrap;
+    margin: 0;
+    padding: 0;
+    padding-left: 5px;
+    word-wrap: break-word;
+    word-break: break-word;
+    font-weight: 400;
+    font-size: 14px;
+    color: #202035;
+}
+
+.message-header {
+    margin: 0;
+    font-style: italic;
+    text-shadow: none;
+}
+
+.message-footer {
+    margin: 0;
+    padding: 0;
+    margin-bottom: -5px;
+    line-height: .9;
+    text-align: right;
+}
+
+.message-time {
+    margin-left: 1em;
+}
+
+.message-me-asterisk, .message-me-user {
+    margin-right: 0.33em;
+}
+
+.video-container {
+    height: calc(var(--vh, 1vh) * 100 - 56px);
+    position: relative;
+    background: rgba(0, 0, 0, 0.91);
+    /* Display only when showing video */
+    display: block;
+}
+
+.chat-btn {
+    display: block;
+    /*on top of video peers*/
+    z-index: 1002;
+    font-size: 1.8em;
+    position: absolute;
+    top: 10px;
+    left: 10px;
+    cursor: pointer;
+}
+
+.chat-btn .icon-chat {
+    color: #cac7c7;
+    height: 50px;
+    padding: 10px;
+    text-shadow: 0px 0px 1px #b3adad;
+}
+
+.collapse-video {
+    left: inherit;
+    right: 30px;
+}
+
+.video-controls, .top-video-controls {
+    position: absolute;
+    width: 100%;
+    left: 0;
+    bottom: 25px;
+    text-align: center;
+    color: #eaeaea;
+    font-size: 1.1em;
+    opacity: 0;
+    height: 32px;
+}
+
+.video-controls:after, .top-video-controls:after {
+    clear: both;
+    display: table;
+    content: " ";
+}
+
+.top-video-controls {
+    text-align: right;
+    bottom: inherit;
+    top: 0;
+    line-height: 1.1;
+    font-size: 1.3em;
+    text-shadow: 1px 1px 2px rgb(90 86 86);
+}
+
+.controls-button {
+    padding: 3px 10px;
+    vertical-align: middle;
+    height: 100%;
+}
+
+.controls-left {
+    float: left;
+    text-align: left;
+}
+
+.controls-right {
+    float: right;
+    text-align: right;
+}
+
+.vc-overlay {
+    background: linear-gradient(180deg, rgb(0 0 0 / 20%) 0%, rgb(0 0 0 / 50%) 0%, rgb(0 0 0 / 70%) 100%);
+}
+
+.peer:hover > .video-controls, .peer:hover > .top-video-controls {
+    opacity: 1;
+}
+
+.video-controls span, .top-video-controls span {
+    margin-right: 20px;
+    transition: opacity .7s ease-out;
+    opacity: 1;
+    cursor: pointer;
+}
+
+.video-controls span:last-child, .top-video-controls span:last-child {
+    margin-right: 0;
+}
+
+.video-controls span:hover, .top-video-controls span:hover {
+    opacity: .8;
+    transition: opacity .5s ease-out;
+}
+
+.top-video-controls .video-stop{
+    display: flex;
+    width: 1.5em;
+    height: 1.5em;
+    background: rgba(0,0,0,0.5);
+    border-radius: 50%;
+    justify-content: center;
+    align-items: center;
+    color: #eaeaea;
+}
+
+.video-controls .volume {
+    display: inline-block;
+    text-align: center;
+}
+
+.video-controls .video-play {
+    font-size: 0.85em;
+}
+
+.video-controls .video-stop {
+    color: #d03e3e;
+}
+
+.video-controls span.disabled, .video-controls span.disabled:hover, .top-video-controls span.disabled:hover{
+    opacity: .2;
+    color: #c8c8c8
+}
+
+.volume-mute {
+    vertical-align: middle;
+    width: 25px;
+    display: var(--dv, inline);
+}
+
+.volume-slider {
+    height: 4px;
+    width: 60px;
+    cursor: pointer;
+    margin: 5px 5px;
+    vertical-align: middle;
+    opacity: var(--ov, 0);
+    transition: opacity .5s ease-out;
+}
+
+.video-controls .volume:hover {
+    --ov: 1;
+    --dv: inline;
+}
+
+.mobile-container {
+    display: block !important;
+}
+
+.login-container {
+    height: calc(var(--vh, 1vh) * 100 - 56px);
+    position: relative;
+    display: flex;
+    justify-content: center;
+    overflow: scroll;
+}
+
+.login-box {
+    width: 20em;
+    padding: 1em;
+    margin: 5em auto;
+    height: 23em;
+    background: #fcfcfc;
+}
+
+.login-box .connect {
+    width: 100%;
+    text-align: center;
+}
+
+.login-box h2 {
+    text-align: center;
+    margin-bottom: 40px;
+}
+
+.label-fallback {
+    opacity: 0.5;
+}
+
+.label {
+    left: 0;
+    position: absolute;
+    bottom: 0;
+    width: 100%;
+    z-index: 1;
+    text-align: center;
+    line-height: 24px;
+    color: #ffffff;
+}
+
+.nav-link {
+    padding: 0;
+    color: #dbd9d9;
+    min-width: 30px;
+    display: block;
+    text-align: center;
+    margin: 0 10px;
+    position: relative;
+    line-height: 1.1;
+}
+
+.nav-link span {
+    display: block;
+}
+
+.nav-link label {
+    display: block;
+    cursor: pointer;
+    color: #fff;
+    font-size: 55%;
+}
+
+.nav-link:hover {
+    color: #c2a4e0;
+}
+.nav-link label:hover {
+    color: #c2a4e0;
+}
+
+.nav-cancel, .muted, .nav-cancel label, .muted label {
+    color: #d03e3e
+}
+
+.nav-cancel:hover, .muted:hover, .nav-cancel label:hover, .muted label:hover {
+    color: #d03e3e
+}
+
+.nav-button {
+    cursor: pointer;
+    font-size: 25px;
+}
+
+.nav-more {
+    padding-top: 5px;
+    margin-left: 0;
+}
+
+.header-title {
+    float: left;
+    margin: 0;
+    font-size: 1rem;
+    font-weight: 700;
+    color: #ebebeb;
+    line-height: 2em;
+}
+
+#title {
+    text-align: center;
+}
+
+h1 {
+    white-space: nowrap;
+}
+
+#statdiv {
+    white-space: nowrap;
+    margin-bottom: 16px;
+}
+
+#errspan {
+    margin-left: 1em;
+}
+
+.connected {
+    color: green;
+}
+
+.disconnected {
+    color: red;
+    font-weight: bold;
+}
+
+.userform {
+    display: inline
+}
+
+.userform label {
+    min-width: 3em;
+    display: inline-block;
+    padding-top: 10px;
+}
+
+.userform input[type="text"], .userform input[type="password"] {
+    width: 100%;
+}
+
+.switch-radio {
+    margin: 0;
+}
+
+.invisible {
+    display: none;
+}
+
+.error {
+    color: red;
+    font-weight: bold;
+}
+
+.noerror {
+    display: none;
+}
+
+.clear {
+    clear: both;
+    content: "";
+}
+
+#optionsdiv {
+    margin-bottom: 4px;
+}
+
+#optionsdiv input[type="checkbox"] {
+  vertical-align: middle;
+}
+
+#presentbutton, #unpresentbutton {
+    white-space: nowrap;
+    margin-right: 0.4em;
+    margin-top: .1em;
+    font-size: 1.1em;
+    text-align: left;
+    width: 5.8em;
+}
+
+#videoselect {
+    text-align-last: center;
+    margin-right: 0.4em;
+}
+
+#audioselect {
+    text-align-last: center;
+}
+
+#sharebutton, #unsharebutton {
+    white-space: nowrap;
+}
+
+#unsharebutton {
+    margin-right: 0.4em;
+}
+
+#filterselect {
+    width: 8em;
+    text-align-last: center;
+    margin-right: 0.4em;
+}
+
+#sendselect {
+    width: 8em;
+    text-align-last: center;
+    margin-right: 0.4em;
+}
+
+#simulcastselect {
+    width: 8em;
+    text-align-last: center;
+    margin-right: 0.4em;
+}
+
+#requestselect {
+    width: 8em;
+    text-align-last: center;
+}
+
+#chatbox {
+    height: 100%;
+    position: relative;
+}
+
+#chat {
+    padding: 0;
+    margin: 0;
+    background-color: #f8f8f8;
+    background-size: cover;
+    overflow-y: scroll;
+    border: none;
+    border-right: 4px solid #e6e6e6;
+    /* force to fill height */
+    height: 100% !important;
+    width: 100%;
+    min-width: 300px;
+    overflow: hidden;
+}
+
+#inputform {
+    display: flex;
+}
+
+#box {
+    overflow: auto;
+    height: calc(100% - 53px);
+    padding: 10px;
+}
+
+.close-chat {
+    position: absolute;
+    top: 2px;
+    right: 14px;
+    width: 25px;
+    font-size: 1em;
+    text-align: center;
+    font-weight: 700;
+    color: #8f8f8f;
+    cursor: pointer;
+    border: 1px solid transparent;
+}
+
+.close-chat:hover, .close-chat:active {
+    border: 1px solid #dfdfdf;
+    border-radius: 4px;
+}
+
+#connectbutton {
+    margin-top: 1em;
+    padding: 0.37rem 1.5rem;
+}
+
+#input {
+    width: 100%;
+    border: none;
+    resize: none;
+    overflow-y: hidden;
+}
+
+#input:focus {
+    outline: none;
+}
+
+#inputbutton:focus {
+    outline: none;
+}
+
+#resizer {
+    width: 4px;
+    margin-left: -4px;
+    z-index: 1000;
+}
+
+#resizer:hover {
+    cursor: ew-resize;
+}
+
+#peers {
+    padding: 10px;
+    display: grid;
+    grid-template-columns: repeat(1, 1fr);
+    grid-template-rows: repeat(1, auto);
+    row-gap: 5px;
+    column-gap: 10px;
+    position: absolute;
+    top: 0;
+    right: 0; 
+    bottom: 0;
+    min-width: 100%; 
+    min-height: 100%;
+    width: auto; 
+    height: auto; 
+    z-index: 1000;
+    background-size: cover;
+    overflow: hidden;
+    vertical-align: top!important;
+}
+
+.peer {
+    margin-top: auto;
+    margin-bottom: auto;
+    position: relative;
+    border: 2px solid rgba(0,0,0,0);
+    background: #80808014;
+}
+
+.peer-active {
+    border: 2px solid #610a86;
+}
+
+.media {
+    width: 100%;
+    max-height: calc(var(--vh, 1vh) * 100 - 76px);
+    padding-bottom: 20px;
+    object-fit: contain;
+}
+
+.media-failed {
+    filter: grayscale(0.5) contrast(0.5);
+}
+
+.mirror {
+    transform: scaleX(-1);
+}
+
+#inputform {
+    width: 100%;
+}
+
+.sidenav {
+    background-color: #4d076b;
+    box-shadow: 0 0 24px 0 rgba(71,77,86,.1), 0 1px 0 0 rgba(71,77,86,.08);
+    display: block;
+    position: fixed;
+    -webkit-transition: all .2s ease-out;
+    transition: all .2s ease-out;
+    width: 0px;
+    /* on top of everything */
+    z-index: 2999;
+    top: 0;
+    right: 0;
+    height: calc(var(--vh, 1vh) * 100);
+    overflow-x: hidden;
+    overflow-y: hidden;
+}
+
+.sidenav a {
+    padding: 10px 20px;
+    text-decoration: none;
+    font-size: 30px;
+    color: #dbd9d9;
+    display: block;
+    transition: 0.3s;
+    line-height: 1.0;
+}
+
+.sidenav a:hover {
+    color: #c2a4e0;
+}
+
+.sidenav .closebtn {
+    cursor: pointer;
+    position: absolute;
+    top: 0;
+    right: 0;
+    height: 56px;
+}
+
+.sidenav-label {
+    display: block;
+    margin-top: 15px;
+}
+
+.sidenav-label-first {
+    display: block;
+    margin-top: 0;
+}
+
+.sidenav form{
+    margin-top: 15px;
+}
+
+.sidenav-header {
+    height: 56px;
+}
+
+.sidenav-header h2{
+    color: #fff;
+    padding: 10px;
+    margin: 0;
+    max-width: 70%;
+    line-height: 36px;
+}
+
+.sidenav-content {
+    padding: 10px;
+    background: #fff;
+    height: calc(100% - 56px);
+    overflow-y: auto;
+    overflow-x: hidden;
+}
+
+.sidenav-content h2 {
+    margin: 0;
+}
+
+fieldset {
+    margin: 0;
+    margin-top: 20px;
+    border: 1px solid #e9e8e8;
+    padding: 8px;
+    border-radius: 4px;
+}
+legend {
+    padding: 2px;
+    color: #4d4f51;
+}
+
+.nav-menu {
+    margin: 0;
+    padding: 0;
+}
+
+.nav-menu li {
+    float: left;
+    max-height: 70px;
+    list-style: none;
+}
+
+.show-video {
+    position: absolute;
+    right: 30px;
+    bottom: 120px;
+    color: white;
+    width: 50px;
+    height: 50px;
+    text-align: center;
+    line-height: 50px;
+    font-size: 150%;
+    border-radius: 30px;
+    background: #600aa0;
+    box-shadow: 4px 4px 7px 1px rgba(0,0,0,0.16);
+}
+
+.blink {
+    -ms-animation: blink 1.0s linear infinite;
+    -o-animation: blink 1.0s linear infinite;
+    animation: blink 1.0s linear infinite;
+}
+
+@keyframes blink {
+    0% { box-shadow: 0 0 15px #600aa0; }
+    50% { box-shadow: none; }
+    100% { box-shadow: 0 0 15px #600aa0; }
+}
+
+@-webkit-keyframes blink {
+    0% { box-shadow: 0 0 15px #600aa0; }
+    50% { box-shadow: 0 0 0; }
+    100% { box-shadow: 0 0 15px #600aa0; }
+}
+
+/*   Dropdown Menu */
+.dropbtn {
+    cursor: pointer;
+}
+
+.dropdown {
+    position: relative;
+    display: inline-block;
+}
+
+.dropdown-content {
+    display: none;
+    position: absolute;
+    background-color: #fff;
+    max-width: 300px;
+    min-width: 200px;
+    margin-top: 7px;
+    overflow: auto;
+    right: 7px;
+    box-shadow: 0 8px 16px 0 rgba(0,0,0,0.2);
+    z-index: 1;
+    padding: 15px;
+}
+
+.dropdown-content a {
+    color: black;
+    padding: 12px 16px;
+    text-decoration: none;
+    display: block;
+}
+
+.dropdown a:hover {background-color: #ddd;}
+
+.show {display: block;}
+
+.dropdown-content label{
+    display: block;
+    margin-top: 15px;
+}
+
+/*  END Dropdown Menu */
+
+/*  Sidebar left */
+
+#left-sidebar {
+    min-width: 200px;
+    max-width: 200px;
+    transition: all 0.3s;
+    background: #ffffff;
+    border-right: 1px solid #dcdcdc;
+}
+
+#left-sidebar .galene-header {
+    display: inline-block;
+}
+
+header .collapse {
+    float: left;
+    text-align: center;
+    cursor: pointer;
+    font-size: 20px;
+    color: #dbd9d9;
+    margin-right: 20px;
+    margin-left: 5px;
+}
+
+header .collapse:hover {
+    color: #c2a4e0;
+}
+
+.galene-header {
+    font-size: 1.3rem;
+    font-weight: 900;
+    color: #dbd9d9;
+    line-height: 34px;
+}
+
+.header-sep {
+    height: 20px;
+}
+
+/* Shrinking the sidebar from 200px to 0px */
+#left-sidebar.active {
+    min-width: 0;
+    max-width: 0;
+}
+
+#left-sidebar .sidebar-header strong {
+    display: none;
+}
+#left-sidebar.active .sidebar-header h3 {
+    display: none;
+}
+#left-sidebar.active .sidebar-header strong {
+    display: block;
+}
+
+#users {
+    padding: 0;
+    margin: 0;
+    height: calc(100% - 84px);
+    width: 100%;
+    position: relative;
+    display: block;
+    background-color: #fff;
+    overflow-y: auto;
+    border: 1px solid #f7f7f7;
+}
+
+#users .user-p {
+    position: relative;
+    padding: 10px !important;
+    border-bottom: 1px solid #f0f0f0;
+    height: 40px;
+    line-height: 18px;
+    margin: 0 !important;
+    cursor: pointer;
+    overflow: hidden;
+    white-space: pre;
+}
+
+#left-sidebar.active #users > div {
+    padding: 10px 5px !important;
+}
+
+#users > div:hover {
+    background-color: #f2f2f2;
+}
+
+#users > div::before {
+    content: "\f111";
+    font-family: 'Font Awesome 6 Free';
+    color: #20b91e;
+    margin-right: 5px;
+    font-weight: 900;
+}
+
+#users > div.user-status-raisehand::before {
+    content: "\f256";
+}
+
+.close-icon {
+    font: normal 1em/1 Arial, sans-serif;
+    display: inline-block;
+}
+
+.close-icon:before{ content: "\2715"; }
+
+/* END Sidebar Left */
+
+@media only screen and (min-width: 1025px) {
+    .coln-right .collapse-video, .coln-right .show-video {
+        display: none;
+    }
+}
+
+@media only screen and (max-width: 1024px) {
+    #presentbutton, #unpresentbutton {
+        width: auto;
+    }
+    .nav-link {
+        margin: 0 4px;
+        line-height: 1.5;
+    }
+
+    .nav-link label {
+        display: none;
+    }
+
+    .nav-text {
+        display: none;
+    }
+
+    .nav-more {
+        padding-top: 0;
+        margin-left: inherit;
+    }
+
+    .full-width {
+        height: calc(var(--vh, 1vh) * 100 - 56px);
+    }
+
+    .close-chat, .show-chat {
+        display: none;
+    }
+
+    .video-container {
+        position: fixed;
+        height: calc(var(--vh, 1vh) * 100 - 56px);
+        top: 56px;
+        right: 0;
+        left: 0;
+        margin-bottom: 60px;
+    }
+
+    .top-video-controls {
+        opacity: 1;
+    }
+
+    .login-container {
+        position: fixed;
+        height: calc(var(--vh, 1vh) * 100 - 56px);
+        top: 56px;
+        right: 0;
+        left: 0;
+        background: #eff3f9;
+    }
+
+    .login-box {
+        background: transparent;
+    }
+
+    .coln-left {
+        flex: 100%;
+        width: 100vw;
+        /* chat is always visible here */
+        display: block !important;
+    }
+
+    .coln-right {
+        flex: none;
+        position: relative;
+    }
+
+    .full-width {
+        width: 100vw;
+    }
+
+    #left-sidebar.active {
+        min-width: 200px;
+        max-width: 200px;
+    }
+
+    #left-sidebar {
+        min-width: 0;
+        max-width: 0;
+    }
+
+    /* Reappearing the sidebar on toggle button click */
+    #left-sidebar {
+        margin-left: 0; 
+    }
+
+    #left-sidebar .sidebar-header strong {
+        display: none;
+    }
+
+    #left-sidebar.active .sidebar-header h3 {
+        display: none;
+    }
+
+    #left-sidebar.active .sidebar-header strong {
+        display: block;
+    }
+
+    .sidenav a {padding: 10px 10px;}
+
+    .sidenav-header h2 {
+        line-height: 36px;
+    }
+
+    #peers {
+        padding: 3px;
+    }
+
+    #resizer {
+        display: none;
+    }
+
+    #chat {
+        border-right: none;
+    }
+
+    .dropdown-content {
+        margin-top: 10px;
+    }
+
+}
+
+:root{
+    --contextualMenuBg: #eee;
+    --contextualMenuShadow: 1px 1px 1px #444; */
+    --contextualMenuRadius: 0px; */
+    --contextualMenuText: black;
+
+    --contextualSubMenuBg: #eee;
+
+    --contextualHover: #ddd;
+
+    --contextualOverflowIcon: #999;
+    --contextualSeperator: #999;
+}

+ 374 - 0
packages/galene/galene.js

@@ -0,0 +1,374 @@
+(function (root, factory) {
+    if (typeof define === 'function' && define.amd) {
+        define(["converse"], factory);
+    } else {
+        factory(converse);
+    }
+}(this, function (converse) {
+
+    var Strophe, $iq, $msg, $pres, $build, b64_sha1, _ , dayjs, _converse, html, _, __, Model, BootstrapModal, galene_confirm, galene_invitation, galene_tab_invitation;
+
+    converse.plugins.add("galene", {
+        dependencies: [],
+
+        initialize: function () {
+            _converse = this._converse;
+
+            Strophe = converse.env.Strophe;
+            $iq = converse.env.$iq;
+            $msg = converse.env.$msg;
+            $pres = converse.env.$pres;
+            $build = converse.env.$build;
+            b64_sha1 = converse.env.b64_sha1;
+            dayjs = converse.env.dayjs;
+            html = converse.env.html;
+            Model = converse.env.Model;
+            BootstrapModal = converse.env.BootstrapModal;
+            _ = converse.env._;
+            __ = _converse.__;
+
+            _converse.api.settings.update({
+                galene_head_display_toggle: false,
+                galene_signature: 'GALENE',
+            });
+
+            galene_confirm  = __('Galene Meeting?');
+            galene_invitation = __('Please join meeting in room at');
+            galene_tab_invitation = __('Or open in new tab at');
+
+            _converse.api.listen.on('messageNotification', function (data)
+            {
+                console.debug("messageNotification", data);
+
+                var chatbox = data.chatbox;
+                var bodyElement = data.stanza.querySelector('body');
+
+                if (bodyElement)
+                {
+                    var body = bodyElement.innerHTML;
+                    var url = _converse.api.settings.get("galene_signature");
+                    var pos = body.indexOf(url + "/");
+
+                    if (pos > -1)
+                    {
+                        var room = body.substring(pos + url.length + 1);
+                        var label = pos > 0 ? body.substring(0, pos) : galene_invitation;
+                        var from = chatbox.getDisplayName().trim();
+                        var avatar = _converse.api.settings.get("notification_icon");
+
+                        if (data.chatbox.vcard.attributes.image) avatar = data.chatbox.vcard.attributes.image;
+
+                        var prompt = new Notification(from,
+                        {
+                            'body': label + " " + room,
+                            'lang': _converse.locale,
+                            'icon': avatar,
+                            'requireInteraction': true
+                        });
+
+                        prompt.onclick = function(event)
+                        {
+                            event.preventDefault();
+
+                            var box_jid = Strophe.getBareJidFromJid(chatbox.get("contact_jid") || chatbox.get("jid") || chatbox.get("from"));
+                            var view = _converse.chatboxviews.get(box_jid);
+
+                            if (view)
+                            {
+                                doLocalVideo(view, room, label);
+                            }
+                        }
+                    }
+                }
+            });
+
+            _converse.api.listen.on('getToolbarButtons', async function(toolbar_el, buttons)
+            {
+                console.debug("getToolbarButtons", toolbar_el.model.get("jid"));
+                let color = "fill:var(--chat-toolbar-btn-color);";
+                if (toolbar_el.model.get("type") === "chatroom") color = "fill:var(--muc-toolbar-btn-color);";
+				
+				const features = await _converse.api.disco.getFeatures(_converse.connection.domain);
+				
+				features.each(feature => {
+					const fieldname = feature.get('var');
+					
+					if (fieldname == "urn:xmpp:sfu:galene:0") {
+						console.debug("SFU found");
+						
+						buttons.push(html`
+							<button class="plugin-galene" title="${__('Galene Meeting')}" @click=${performVideo}/>
+								<svg style="width:20px; height:20px; ${color}" viewBox="0 0 452.388 452.388"  xml:space="preserve"> <g> 	<g id="Layer_8_38_"> 		<path d="M441.677,43.643H10.687C4.785,43.643,0,48.427,0,54.329v297.425c0,5.898,4.785,10.676,10.687,10.676h162.069v25.631 			c0,0.38,0.074,0.722,0.112,1.089h-23.257c-5.407,0-9.796,4.389-9.796,9.795c0,5.408,4.389,9.801,9.796,9.801h158.506 			c5.406,0,9.795-4.389,9.795-9.801c0-5.406-4.389-9.795-9.795-9.795h-23.256c0.032-0.355,0.115-0.709,0.115-1.089V362.43H441.7 			c5.898,0,10.688-4.782,10.688-10.676V54.329C452.37,48.427,447.589,43.643,441.677,43.643z M422.089,305.133 			c0,5.903-4.784,10.687-10.683,10.687H40.96c-5.898,0-10.684-4.783-10.684-10.687V79.615c0-5.898,4.786-10.684,10.684-10.684 			h370.446c5.898,0,10.683,4.785,10.683,10.684V305.133z M303.942,290.648H154.025c0-29.872,17.472-55.661,42.753-67.706 			c-15.987-10.501-26.546-28.571-26.546-49.13c0-32.449,26.306-58.755,58.755-58.755c32.448,0,58.753,26.307,58.753,58.755 			c0,20.553-10.562,38.629-26.545,49.13C286.475,234.987,303.942,260.781,303.942,290.648z"/> </g></g> </svg>
+							</button>
+						`);						
+					}
+				});					
+
+                return buttons;
+            });
+
+            _converse.api.listen.on('connected', function()
+            {
+                window.connection = _converse.connection;
+            });			
+
+            _converse.api.listen.on('afterMessageBodyTransformed', function(text)
+            {
+                if (text.indexOf(_converse.api.settings.get("galene_signature")) > -1)
+                {
+                    console.debug("afterMessageBodyTransformed", text);
+
+                    const url = text.substring(0);
+                    const link_room = url.substring(url.lastIndexOf("/") + 1);
+                    const link_label = galene_invitation;
+                    const tab_label = galene_tab_invitation;
+
+                    text.references = [];
+                    text.addTemplateResult(0, text.length, html`<a @click=${clickVideo} data-room="${link_room}" data-url="${url}" href="#">${link_label} ${link_room}</a>`);
+                }
+            });
+
+            console.debug("galene plugin is ready");
+        }
+    });
+
+    function __confirm(msg, callback) {
+      if (confirm(galene_confirm)) {
+          callback();
+      }
+    }
+
+    function __displayError(error) {
+      alert(error);
+    }
+
+    function getChatViewFromElement($el) {
+        return $el.closest('converse-chat.chatbox') || $el.closest('converse-muc.chatbox');
+    }
+
+    function performVideo(ev)
+    {
+        ev.stopPropagation();
+        ev.preventDefault();
+
+        const chatView = getChatViewFromElement(ev.currentTarget);
+        __confirm(galene_confirm, function() {
+            doVideo(chatView);
+        });
+    }
+
+    function clickVideo(ev)
+    {
+        ev.stopPropagation();
+        ev.preventDefault();
+
+        var url = ev.target.getAttribute("data-url");
+        var room = ev.target.getAttribute("data-room");
+
+        if (ev.currentTarget) {
+          const chatView = getChatViewFromElement(ev.currentTarget);
+          doLocalVideo(chatView, room, url, galene_invitation);
+        }
+    }
+
+    var doVideo = function doVideo(view)
+    {
+        const room = Strophe.getNodeFromJid(view.model.attributes.jid).toLowerCase().replace(/[\\]/g, '') + "-" + Math.random().toString(36).substr(2,9);
+        const url = _converse.api.settings.get("galene_signature") + '/' + room;
+
+        console.debug("doVideo", room, url, view);
+
+        view.model.sendMessage({'body': url});	
+        doLocalVideo(view, room, galene_invitation);
+
+    }
+
+    var doLocalVideo = function doLocalVideo(view, room, label)
+    {
+        const chatModel = view.model;
+        console.debug("doLocalVideo", view, room, label);
+
+		const isOverlayedDisplay = _converse.api.settings.get("view_mode") === "overlayed";
+		const headDisplayToggle = isOverlayedDisplay || _converse.api.settings.get("galene_head_display_toggle") === true;
+		const div = view.querySelector(headDisplayToggle ? ".chat-body" : ".box-flyout");
+
+		if (div) {
+				div.innerHTML = '';
+                const jid = view.getAttribute("jid");
+                if(Array.from(document.querySelectorAll("iframe.galene")).filter(f => f.__jid === jid).length > 0) {
+                  __displayError(__('A meet is already running into room'));
+                  return;
+                }
+                const toggleHandler = function() {
+                  galeneFrame.toggleHideShow();
+                };
+                const dynamicDisplayManager = new function() {
+                  let __resizeHandler;
+                  let __resizeWatchImpl;
+                  this.start = function() {
+                    const $chatBox = document.querySelector('.converse-chatboxes');
+                    const $anchor = document.querySelector('#conversejs.conversejs');
+                    __resizeHandler = function() {
+                      const currentView = _converse.chatboxviews.get(jid)
+                      if (currentView && headDisplayToggle) {
+                        const $head = currentView.querySelector(".chat-head");
+                        $head.removeEventListener('dblclick', toggleHandler);
+                        $head.addEventListener('dblclick', toggleHandler);
+                      }
+                      const currentDiv = currentView && currentView.querySelector(headDisplayToggle ? ".chat-body" : ".box-flyout");
+                      let top = currentDiv ? currentDiv.offsetTop : 0;
+                      let left = currentDiv ? currentDiv.offsetLeft : 0;
+                      let width = currentDiv ? currentDiv.offsetWidth : 0;
+                      let height = currentDiv ? currentDiv.offsetHeight : 0;
+                      let current = currentDiv && currentDiv.offsetParent;
+                      while (current && current !== $anchor) {
+                        top += current.offsetTop;
+                        left += current.offsetLeft;
+                        current = current.offsetParent;
+                      }
+                      galeneFrame.style.top = top + "px";
+                      galeneFrame.style.left = left + "px";
+                      galeneFrame.style.width = width + "px";
+                      galeneFrame.style.height = height + "px";
+                    };
+                    __resizeWatchImpl = new function() {
+                      let __resizeObserver;
+                      if (isOverlayedDisplay && typeof ResizeObserver === 'function') {
+                        __resizeObserver = new ResizeObserver(function(entries) {
+                          if (entries.length > 0) {
+                            __resizeHandler();
+                          }
+                        });
+                      }
+                      const __resizeWatchEvents = ['controlBoxOpened', 'controlBoxClosed', 'chatBoxBlurred',
+                        'chatBoxFocused', 'chatBoxMinimized', 'chatBoxMaximized',
+                        'chatBoxViewInitialized', 'chatRoomViewInitialized'];
+                      const __startResize = function() {
+                        galeneFrame.style.pointerEvents = 'none';
+                        document.addEventListener('mousemove', __deferredResize);
+                      };
+                      const __endResize = function() {
+                        galeneFrame.style.pointerEvents = '';
+                        document.removeEventListener('mousemove', __deferredResize);
+                      };
+                      let timeoutId;
+                      const __deferredResize = function() {
+                        clearTimeout(timeoutId);
+                        timeoutId = setTimeout(__resizeHandler, 0);
+                      };
+                      this.start = function() {
+                        _converse.api.listen.on('startDiagonalResize', __startResize);
+                        _converse.api.listen.on('startHorizontalResize', __startResize);
+                        _converse.api.listen.on('startVerticalResize', __startResize);
+                        document.addEventListener('mouseup', __endResize);
+                        window.addEventListener('resize', __resizeHandler);
+                        __resizeWatchEvents.forEach(c => _converse.api.listen.on(c, __deferredResize));
+                        if (__resizeObserver) {
+                          __resizeObserver.observe(div);
+                          __resizeObserver.observe($anchor);
+                          __resizeObserver.observe($chatBox);
+                        }
+                      };
+                      this.close = function() {
+                        _converse.api.listen.not('startDiagonalResize', __startResize);
+                        _converse.api.listen.not('startHorizontalResize', __startResize);
+                        _converse.api.listen.not('startVerticalResize', __startResize);
+                        document.removeEventListener('mouseup', __endResize);
+                        window.removeEventListener('resize', __resizeHandler);
+                        __resizeWatchEvents.forEach(c => _converse.api.listen.not(c, __deferredResize));
+                        if (__resizeObserver) {
+                          __resizeObserver.disconnect();
+                        }
+                      };
+                    };
+                    galeneFrame.style.position = "absolute";
+                    $anchor.appendChild(galeneFrame);
+                    __resizeWatchImpl.start();
+                    _converse.api.listen.on('chatBoxClosed', closeGalene);
+                    this.triggerChange();
+                  };
+                  this.triggerChange = function() {
+                    __resizeHandler();
+                  };
+                  this.close = function() {
+                    __resizeWatchImpl.close();
+                    _converse.api.listen.not('chatBoxClosed', closeGalene);
+                  };
+                };
+                let galeneFrame = document.createElement('iframe');
+                let firstTime = true;
+
+				let openChatbox = function (view)
+				{
+					let jid = view.model.get("jid");
+					let type = view.model.get("type");
+
+					console.debug("openChatbox", jid, type);
+
+					if (jid)
+					{
+						if (type == "chatbox") _converse.api.chats.open(jid, {'bring_to_foreground': true}, true);
+						else
+						if (type == "chatroom") _converse.api.rooms.open(jid, {'bring_to_foreground': true}, true);
+					}
+				}	
+				
+                let closeGalene = function(currentModel) {
+                  console.debug("doLocalVideo - closeGalene", this);
+				  
+                  dynamicDisplayManager.triggerChange();
+                  if (currentModel && currentModel.cid !== chatModel.cid) {
+                    return;
+                  }
+                  dynamicDisplayManager.close();
+                  galeneFrame.remove();
+				  view.close();
+				  setTimeout(function() { openChatbox(view) });				  
+                }
+				
+                let galeneIframeCloseHandler = function ()
+                {
+                  console.debug("doLocalVideo - galeneIframeCloseHandler");
+                  if (!firstTime) // meeting closed and root url is loaded
+                  {
+                    closeGalene();
+                  }
+                  if (firstTime) firstTime = false;   // ignore when galene-meet room url is loaded
+                };
+                galeneFrame.toggleHideShow = function() {
+                  if (galeneFrame.style.display === 'none') {
+                    galeneFrame.show();
+                  } else {
+                    galeneFrame.hide();
+                  }
+                };
+                galeneFrame.show = function() {
+                  galeneFrame.style.display = '';
+                };
+                galeneFrame.hide = function() {
+                  galeneFrame.style.display = 'none';
+                };
+                galeneFrame.__jid = jid;
+                galeneFrame.addEventListener("load", galeneIframeCloseHandler);
+                galeneFrame.setAttribute("src", "./packages/galene/index.html?username=" + Strophe.getNodeFromJid(_converse.connection.jid) + "&password=&group=" + room);
+                galeneFrame.setAttribute("class", "galene");
+                galeneFrame.setAttribute("allow", "microphone; camera;");
+                galeneFrame.setAttribute("frameborder", "0");
+                galeneFrame.setAttribute("seamless", "seamless");
+                galeneFrame.setAttribute("allowfullscreen", "true");
+                galeneFrame.setAttribute("scrolling", "no");
+                galeneFrame.setAttribute("style", "z-index:1049;width:100%;height:100%;");
+                dynamicDisplayManager.start();
+				
+                galeneFrame.contentWindow.addEventListener("message", function (event) {
+                  if (typeof event.data === 'string') {
+                    let data = JSON.parse(event.data);
+                    let galeneEvent = data['galene_event'];
+                    if ('close' === galeneEvent) {
+                      closeGalene();
+                    }
+                  }
+                }, false);
+		}
+    }		
+}));

BIN
packages/galene/galene.png


+ 290 - 0
packages/galene/index.html

@@ -0,0 +1,290 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <title>Galène</title>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta http-equiv="ScreenOrientation" content="autoRotate:disabled">
+    <link rel="stylesheet" type="text/css" href="./common.css"/>
+    <link rel="stylesheet" type="text/css" href="./galene.css"/>
+    <link rel="stylesheet" type="text/css" href="./external/fontawesome/css/fontawesome.min.css"/>
+    <link rel="stylesheet" type="text/css" href="./external/fontawesome/css/solid.min.css"/>
+    <link rel="stylesheet" type="text/css" href="./external/fontawesome/css/regular.min.css"/>
+    <link rel="stylesheet" type="text/css" href="./external/toastify/toastify.css"/>
+    <link rel="stylesheet" type="text/css" href="./external/contextual/contextual.css"/>
+  </head>
+
+  <body>
+    <div id="main" class="app">
+      <div class="row full-height">
+        <nav id="left-sidebar">
+          <div class="users-header">
+            <div class="galene-header">Galène</div>
+          </div>
+          <div class="header-sep"></div>
+          <div id="users"></div>
+        </nav>
+        <div class="container">
+          <header>
+            <nav class="topnav navbar navbar-expand navbar-light fixed-top">
+              <div id="header">
+                <div class="collapse" title="Collapse left panel" id="sidebarCollapse">
+                  <i class="fas fa-align-left" aria-hidden="true"></i>
+                </div>
+                <h1 id="title" class="header-title">Galène</h1>
+              </div>
+
+              <ul class="nav-menu">
+                <li>
+                  <button id="presentbutton" class="invisible btn btn-success">
+                    <i class="fas fa-play" aria-hidden="true"></i><span class="nav-text"> Enable</span>
+                  </button>
+                </li>
+                <li>
+                  <button id="unpresentbutton" class="invisible btn btn-cancel">
+                    <i class="fas fa-stop" aria-hidden="true"></i><span class="nav-text"> Disable</span>
+                  </button>
+                </li>
+                <li>
+                  <div id="mutebutton" class="nav-link nav-button">
+                    <span><i class="fas fa-microphone-slash" aria-hidden="true"></i></span>
+                    <label>Mute</label>
+                  </div>
+                </li>
+                <li>
+                  <div id="sharebutton" class="invisible nav-link nav-button">
+                    <span><i class="fas fa-share-square" aria-hidden="true"></i></span>
+                    <label>Share Screen</label>
+                  </div>
+                </li>
+                <li>
+                  <div id="closebutton" class="nav-link nav-button">
+                    <span><i class="fa fa-sign-out" aria-hidden="true"></i></span>
+                    <label>Close</label>
+                  </div>
+                </li>				
+                <li>
+                  <div class="nav-button nav-link nav-more" id="openside">
+                    <span><i class="fas fa-ellipsis-v" aria-hidden="true"></i></span>
+                  </div>
+                </li>
+              </ul>
+            </nav>
+          </header>
+          <div class="row full-width" id="mainrow">
+            <div class="coln-left" id="left">
+              <div id="chat">
+                <div id="chatbox">
+                  <div class="close-chat" id="close-chat"  title="Hide chat">
+                    <span class="close-icon"></span>
+                  </div>
+                  <div id="box"></div>
+                  <div class="reply">
+                    <form id="inputform">
+                      <textarea id="input" class="form-reply"></textarea>
+                      <input id="inputbutton" type="submit" value="&#10148;" class="btn btn-default"/>
+                    </form>
+                  </div>
+                </div>
+              </div>
+            </div>
+            <div id="resizer"></div>
+            <div class="coln-right" id="right">
+              <span class="show-video blink invisible" id="show-video">
+                <i class="fas fa-exchange-alt" aria-hidden="true"></i>
+              </span>
+              <div class="chat-btn show-chat invisible" id="show-chat">
+                <i class="far fa-comment-alt icon-chat" title="Show chat"></i>
+              </div>
+              <div class="chat-btn collapse-video invisible" id="collapse-video">
+                <i class="far fa-comment-alt icon-chat" title="Hide video and show chat"></i>
+              </div>
+              <div class="video-container invisible" id="video-container">
+                <div id="expand-video" class="expand-video">
+                  <div id="peers"></div>
+                </div>
+              </div>
+              <div class="login-container invisible" id="login-container">
+                <div class="login-box">
+                  <form id="userform" class="userform">
+                    <label for="username">Username</label>
+                    <input id="username" type="text" name="username"
+                           autocomplete="username" class="form-control"/>
+                    <label for="password">Password</label>
+                    <input id="password" type="password" name="password"
+                           autocomplete="current-password" class="form-control"/>
+                    <label>Enable at start:</label>
+                    <div class="present-switch">
+                      <p class="switch-radio">
+                        <input id="presentoff" type="radio" name="presentradio" value="" checked/>
+                        <label for="presentoff">Nothing</label>
+                      </p>
+                      <p class="switch-radio">
+                        <input id="presentmike" type="radio" name="presentradio" value="mike"/>
+                        <label for="presentmike">Microphone</label>
+                      </p>
+                      <p class="switch-radio">
+                        <input id="presentboth" type="radio" name="presentradio" value="both"/>
+                        <label for="presentboth">Camera and microphone</label>
+                      </p>
+                    </div>
+                    <div class="clear"></div>
+                    <div class="connect">
+                      <input id="connectbutton" type="submit" class="btn btn-blue" value="Connect"/>
+                    </div>
+                  </form>
+                  <div class="clear"></div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div id="sidebarnav" class="sidenav">
+      <div class="sidenav-header">
+        <h2>Settings</h2>
+        <a class="closebtn" id="clodeside"><i class="fas fa-times" aria-hidden="true"></i></a>
+      </div>
+      <div class="sidenav-content" id="optionsdiv">
+        <div id="profile" class="profile invisible">
+          <div class="profile-user">
+            <div class="profile-logo">
+              <span><i class="fas fa-user" aria-hidden="true"></i></span>
+            </div>
+            <div class="profile-info">
+              <span id="userspan"></span>
+              <span id="permspan"></span>
+            </div>
+            <div class="user-logout">
+              <a id="disconnectbutton">
+                <span class="logout-icon"><i class="fas fa-sign-out-alt"></i></span>
+                <span class="logout-text">Logout</span>
+              </a>
+            </div>
+          </div>
+        </div>
+        <div id="mediaoptions" class="invisible">
+          <fieldset>
+            <legend>Media Options</legend>
+            <label for="videoselect" class="sidenav-label-first">Camera:</label>
+            <select id="videoselect" class="select select-inline">
+              <option value="">off</option>
+            </select>
+  
+            <label for="audioselect" class="sidenav-label">Microphone:</label>
+            <select id="audioselect" class="select select-inline">
+              <option value="">off</option>
+            </select>
+
+            <form>
+              <input id="mirrorbox" type="checkbox" checked/>
+              <label for="mirrorbox">Mirror view</label>
+            </form>
+
+            <form>
+              <input id="blackboardbox" type="checkbox"/>
+              <label for="blackboardbox">Blackboard mode</label>
+            </form>
+  
+            <form>
+              <input id="preprocessingbox" type="checkbox"/ checked>
+              <label for="preprocessingbox">Noise suppression</label>
+            </form>
+
+            <form>
+              <input id="hqaudiobox" type="checkbox"/>
+              <label for="hqaudiobox">High-quality audio</label>
+            </form>
+
+          </fieldset>
+        </div>
+
+        <fieldset>
+          <legend>Other Settings</legend>
+
+          <form id="filterform">
+            <label for="filterselect" class="sidenav-label-first">Filter:</label>
+            <select id="filterselect" class="select select-inline">
+              <option value="" selected>none</option>
+            </select>
+          </form>
+
+          <form id="sendform">
+            <label for="sendselect" class="sidenav-label-first">Send:</label>
+            <select id="sendselect" class="select select-inline">
+              <option value="lowest">lowest</option>
+              <option value="low">low</option>
+              <option value="normal" selected>normal</option>
+              <option value="unlimited">unlimited</option>
+            </select>
+          </form>
+
+          <form id="simulcastform">
+            <label for="simulcastselect" class="sidenav-label-first">Simulcast:</label>
+            <select id="simulcastselect" class="select select-inline">
+              <option value="off">off</option>
+              <option value="auto" selected>auto</option>
+              <option value="on">on</option>
+            </select>
+          </form>
+
+          <form id="requestform">
+            <label for="requestselect" class="sidenav-label">Receive:</label>
+            <select id="requestselect" class="select select-inline">
+              <option value="">nothing</option>
+              <option value="audio">audio only</option>
+              <option value="screenshare-low">screen share (low)</option>
+              <option value="screenshare">screen share</option>
+              <option value="everything-low">everything (low)</option>
+              <option value="everything" selected>everything</option>
+            </select>
+          </form>
+  
+          <form>
+            <input id="activitybox" type="checkbox"/>
+            <label for="activitybox">Activity detection</label>
+          </form>
+
+        </fieldset>
+      </div>
+    </div>
+
+    <div id="videocontrols-template" class="invisible">
+      <div class="video-controls vc-overlay">
+        <div class="controls-button controls-left">
+          <span class="video-play" title="Play video">
+            <i class="fas fa-play"></i>
+          </span>
+          <span class="volume" title="Volume">
+            <i class="fas fa-volume-up volume-mute" aria-hidden="true"></i>
+            <input class="volume-slider" type="range" max="100" value="100" min="0" step="5" >
+          </span>
+        </div>
+        <div class="controls-button controls-right">
+          <span class="pip" title="Picture In Picture">
+            <i class="far fa-clone" aria-hidden="true"></i>
+          </span>
+          <span class="fullscreen" title="Fullscreen">
+            <i class="fas fa-expand" aria-hidden="true"></i>
+          </span>
+        </div>
+      </div>
+    </div>
+    <div id="topvideocontrols-template" class="invisible">
+      <div class="top-video-controls">
+        <div class="controls-button controls-right">
+          <span class="close-icon video-stop" title="Stop video">
+          </span>
+        </div>
+      </div>
+    </div>
+
+    <script src="./protocol.js"></script>
+    <script src="./stophe.min.js"></script>	
+    <script src="./galene-socket.js"></script>		
+    <script src="./external/toastify/toastify.js"></script>
+    <script src="./external/contextual/contextual.js"></script>
+    <script src="./galene-ui.js"></script>
+</html>

+ 17 - 0
packages/galene/package.json

@@ -0,0 +1,17 @@
+{
+  "name": "@converse-plugins/galene",
+  "author": "Dele Olajide",
+  "license": "Apache 2.0",
+  "version": "0.0.1",
+  "keywords": [
+    "converse.js",
+    "plugin"
+  ],
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/conversejs/community-plugins.git"
+  }
+}

+ 1421 - 0
packages/galene/protocol.js

@@ -0,0 +1,1421 @@
+// Copyright (c) 2020 by Juliusz Chroboczek.
+
+// 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.
+
+'use strict';
+
+/**
+ * toHex formats an array as a hexadecimal string.
+ *
+ * @param {number[]|Uint8Array} array - the array to format
+ * @returns {string} - the hexadecimal representation of array
+ */
+function toHex(array) {
+    let a = new Uint8Array(array);
+    function hex(x) {
+        let h = x.toString(16);
+        if(h.length < 2)
+            h = '0' + h;
+        return h;
+    }
+    return a.reduce((x, y) => x + hex(y), '');
+}
+
+/**
+ * newRandomId returns a random string of 32 hex digits (16 bytes).
+ *
+ * @returns {string}
+ */
+function newRandomId() {
+    let a = new Uint8Array(16);
+    crypto.getRandomValues(a);
+    return toHex(a);
+}
+
+let localIdCounter = 0;
+
+/**
+ * newLocalId returns a string that is unique in this session.
+ *
+ * @returns {string}
+ */
+function newLocalId() {
+    let id = `${localIdCounter}`
+    localIdCounter++;
+    return id;
+}
+
+/**
+ * @typedef {Object} user
+ * @property {string} username
+ * @property {Array<string>} permissions
+ * @property {Object<string,any>} data
+ * @property {Object<string,Object<string,boolean>>} streams
+ */
+
+/**
+ * ServerConnection encapsulates a websocket connection to the server and
+ * all the associated streams.
+ * @constructor
+ */
+function ServerConnection() {
+    /**
+     * The id of this connection.
+     *
+     * @type {string}
+     * @const
+     */
+    this.id = newRandomId();
+    /**
+     * The group that we have joined, or null if we haven't joined yet.
+     *
+     * @type {string}
+     */
+    this.group = null;
+    /**
+     * The username we joined as.
+     *
+     * @type {string}
+     */
+    this.username = null;
+    /**
+     * The set of users in this group, including ourself.
+     *
+     * @type {Object<string,user>}
+     */
+    this.users = {};
+    /**
+     * The underlying websocket.
+     *
+     * @type {WebSocket}
+     */
+    this.socket = null;
+    /**
+     * The set of all up streams, indexed by their id.
+     *
+     * @type {Object<string,Stream>}
+     */
+    this.up = {};
+    /**
+     * The set of all down streams, indexed by their id.
+     *
+     * @type {Object<string,Stream>}
+     */
+    this.down = {};
+    /**
+     * The ICE configuration used by all associated streams.
+     *
+     * @type {RTCConfiguration}
+     */
+    this.rtcConfiguration = null;
+    /**
+     * The permissions granted to this connection.
+     *
+     * @type {Array<string>}
+     */
+    this.permissions = [];
+    /**
+     * userdata is a convenient place to attach data to a ServerConnection.
+     * It is not used by the library.
+     *
+     * @type{Object<unknown,unknown>}
+     */
+    this.userdata = {};
+
+    /* Callbacks */
+
+    /**
+     * onconnected is called when the connection has been established
+     *
+     * @type{(this: ServerConnection) => void}
+     */
+    this.onconnected = null;
+    /**
+     * onclose is called when the connection is closed
+     *
+     * @type{(this: ServerConnection, code: number, reason: string) => void}
+     */
+    this.onclose = null;
+    /**
+     * onpeerconnection is called before we establish a new peer connection.
+     * It may either return null, or a new RTCConfiguration that overrides
+     * the value obtained from the server.
+     *
+     * @type{(this: ServerConnection) => RTCConfiguration}
+     */
+    this.onpeerconnection = null;
+    /**
+     * onuser is called whenever a user in the group changes.  The users
+     * array has already been updated.
+     *
+     * @type{(this: ServerConnection, id: string, kind: string) => void}
+     */
+    this.onuser = null;
+    /**
+     * onjoined is called whenever we join or leave a group or whenever the
+     * permissions we have in a group change.
+     *
+     * kind is one of 'join', 'fail', 'change' or 'leave'.
+     *
+     * @type{(this: ServerConnection, kind: string, group: string, permissions: Array<string>, status: Object<string,any>, data: Object<string,any>, message: string) => void}
+     */
+    this.onjoined = null;
+    /**
+     * ondownstream is called whenever a new down stream is added.  It
+     * should set up the stream's callbacks; actually setting up the UI
+     * should be done in the stream's ondowntrack callback.
+     *
+     * @type{(this: ServerConnection, stream: Stream) => void}
+     */
+    this.ondownstream = null;
+    /**
+     * onchat is called whenever a new chat message is received.
+     *
+     * @type {(this: ServerConnection, id: string, dest: string, username: string, time: number, privileged: boolean, history: boolean, kind: string, message: unknown) => void}
+     */
+    this.onchat = null;
+    /**
+     * onusermessage is called when an application-specific message is
+     * received.  Id is null when the message originated at the server,
+     * a user-id otherwise.
+     *
+     * 'kind' is typically one of 'error', 'warning', 'info' or 'mute'.  If
+     * 'id' is non-null, 'privileged' indicates whether the message was
+     * sent by an operator.
+     *
+     * @type {(this: ServerConnection, id: string, dest: string, username: string, time: number, privileged: boolean, kind: string, message: unknown) => void}
+     */
+    this.onusermessage = null;
+}
+
+/**
+  * @typedef {Object} message
+  * @property {string} type
+  * @property {string} [kind]
+  * @property {string} [id]
+  * @property {string} [replace]
+  * @property {string} [source]
+  * @property {string} [dest]
+  * @property {string} [username]
+  * @property {string} [password]
+  * @property {string} [token]
+  * @property {boolean} [privileged]
+  * @property {Array<string>} [permissions]
+  * @property {Object<string,any>} [status]
+  * @property {Object<string,any>} [data]
+  * @property {string} [group]
+  * @property {unknown} [value]
+  * @property {boolean} [noecho]
+  * @property {string} [sdp]
+  * @property {RTCIceCandidate} [candidate]
+  * @property {string} [label]
+  * @property {Object<string,Array<string>>|Array<string>} [request]
+  * @property {Object<string,any>} [rtcConfiguration]
+  */
+
+/**
+ * close forcibly closes a server connection.  The onclose callback will
+ * be called when the connection is effectively closed.
+ */
+ServerConnection.prototype.close = function() {
+    this.socket && this.socket.close(1000, 'Close requested by client');
+    this.socket = null;
+};
+
+/**
+  * send sends a message to the server.
+  * @param {message} m - the message to send.
+  */
+ServerConnection.prototype.send = function(m) {
+    if(!this.socket || this.socket.readyState !== this.socket.OPEN) {
+        // send on a closed socket doesn't throw
+        throw(new Error('Connection is not open'));
+    }
+    return this.socket.send(JSON.stringify(m));
+};
+
+/**
+ * connect connects to the server.
+ *
+ * @param {string} url - The URL to connect to.
+ * @returns {Promise<ServerConnection>}
+ * @function
+ */
+ServerConnection.prototype.connect = async function(connection) {
+    let sc = this;
+    if(sc.socket) {
+        sc.socket.close(1000, 'Reconnecting');
+        sc.socket = null;
+    }
+
+    sc.socket = new GaleneSocket(connection);
+
+    return await new Promise((resolve, reject) => {
+        this.socket.onerror = function(e) {
+            reject(e);
+        };
+        this.socket.onopen = function(e) {
+            sc.send({
+                type: 'handshake',
+                id: sc.id,
+            });
+            if(sc.onconnected)
+                sc.onconnected.call(sc);
+            resolve(sc);
+        };
+        this.socket.onclose = function(e) {
+            sc.permissions = [];
+            for(let id in sc.up) {
+                let c = sc.up[id];
+                c.close();
+            }
+            for(let id in sc.down) {
+                let c = sc.down[id];
+                c.close();
+            }
+            for(let id in sc.users) {
+                delete(sc.users[id]);
+                if(sc.onuser)
+                    sc.onuser.call(sc, id, 'delete');
+            }
+            if(sc.group && sc.onjoined)
+                sc.onjoined.call(sc, 'leave', sc.group, [], {}, {}, '');
+            sc.group = null;
+            sc.username = null;
+            //if(sc.onclose)
+            //   sc.onclose.call(sc, e.code, e.reason);
+            reject(new Error('websocket close ' + e.code + ' ' + e.reason));
+        };
+        this.socket.onmessage = function(e) {
+            let m = JSON.parse(e.data);
+            switch(m.type) {
+            case 'handshake':
+                break;
+            case 'offer':
+                sc.gotOffer(m.id, m.label, m.source, m.username,
+                            m.sdp, m.replace);
+                break;
+            case 'answer':
+                sc.gotAnswer(m.id, m.sdp);
+                break;
+            case 'renegotiate':
+                sc.gotRenegotiate(m.id);
+                break;
+            case 'close':
+                sc.gotClose(m.id);
+                break;
+            case 'abort':
+                sc.gotAbort(m.id);
+                break;
+            case 'ice':
+                sc.gotRemoteIce(m.id, m.candidate);
+                break;
+            case 'joined':
+                if(sc.group) {
+                    if(m.group !== sc.group) {
+                        throw new Error('Joined multiple groups');
+                    }
+                } else {
+                    sc.group = m.group;
+                }
+                sc.username = m.username;
+                sc.permissions = m.permissions || [];
+                sc.rtcConfiguration = m.rtcConfiguration || null;
+                if(m.kind == 'leave') {
+                    for(let id in sc.users) {
+                        delete(sc.users[id]);
+                        if(sc.onuser)
+                            sc.onuser.call(sc, id, 'delete');
+                    }
+                }
+                if(sc.onjoined)
+                    sc.onjoined.call(sc, m.kind, m.group,
+                                     m.permissions || [],
+                                     m.status, m.data,
+                                     m.value || null);
+                break;
+            case 'user':
+                switch(m.kind) {
+                case 'add':
+                    if(m.id in sc.users)
+                        console.warn(`Duplicate user ${m.id} ${m.username}`);
+                    sc.users[m.id] = {
+                        username: m.username,
+                        permissions: m.permissions || [],
+                        data: m.data || {},
+                        streams: {},
+                    };
+                    break;
+                case 'change':
+                    if(!(m.id in sc.users)) {
+                        console.warn(`Unknown user ${m.id} ${m.username}`);
+                        sc.users[m.id] = {
+                            username: m.username,
+                            permissions: m.permissions || [],
+                            data: m.data || {},
+                            streams: {},
+                        };
+                    } else {
+                        sc.users[m.id].username = m.username;
+                        sc.users[m.id].permissions = m.permissions || [];
+                        sc.users[m.id].data = m.data || {};
+                    }
+                    break;
+                case 'delete':
+                    if(!(m.id in sc.users))
+                        console.warn(`Unknown user ${m.id} ${m.username}`);
+                    delete(sc.users[m.id]);
+                    break;
+                default:
+                    console.warn(`Unknown user action ${m.kind}`);
+                    return;
+                }
+                if(sc.onuser)
+                    sc.onuser.call(sc, m.id, m.kind);
+                break;
+            case 'chat':
+            case 'chathistory':
+                if(sc.onchat)
+                    sc.onchat.call(
+                        sc, m.source, m.dest, m.username, m.time, m.privileged,
+                        m.type === 'chathistory', m.kind, m.value,
+                    );
+                break;
+            case 'usermessage':
+                if(sc.onusermessage)
+                    sc.onusermessage.call(
+                        sc, m.source, m.dest, m.username, m.time,
+                        m.privileged, m.kind, m.value,
+                    );
+                break;
+            case 'ping':
+                sc.send({
+                    type: 'pong',
+                });
+                break;
+            case 'pong':
+                /* nothing */
+                break;
+            default:
+                console.warn('Unexpected server message', m.type);
+                return;
+            }
+        };
+    });
+};
+
+/**
+ * join requests to join a group.  The onjoined callback will be called
+ * when we've effectively joined.
+ *
+ * @param {string} group - The name of the group to join.
+ * @param {string} username - the username to join as.
+ * @param {string|Object} credentials - password or authServer.
+ * @param {Object<string,any>} [data] - the initial associated data.
+ */
+ServerConnection.prototype.join = async function(group, username, credentials, data) {
+    let m = {
+        type: 'join',
+        kind: 'join',
+        group: group,
+        username: username,
+    };
+    if((typeof credentials) === 'string') {
+        m.password = credentials;
+    } else {
+        switch(credentials.type) {
+        case 'password':
+            m.password = credentials.password;
+            break;
+        case 'token':
+            m.token = credentials.token;
+            break;
+        case 'authServer':
+            let r = await fetch(credentials.authServer, {
+                method: "POST",
+                headers: {
+                    "Content-Type": "application/json",
+                },
+                body: JSON.stringify({
+                    location: credentials.location,
+                    username: username,
+                    password: credentials.password,
+                }),
+            });
+            if(!r.ok)
+                throw new Error(
+                    `The authorisation server said: ${r.status} ${r.statusText}`,
+                );
+            m.token = await r.text();
+            break;
+        default:
+            throw new Error(`Unknown credentials type ${credentials.type}`);
+        }
+    }
+
+    if(data)
+        m.data = data;
+
+    this.send(m);
+};
+
+/**
+ * leave leaves a group.  The onjoined callback will be called when we've
+ * effectively left.
+ *
+ * @param {string} group - The name of the group to join.
+ */
+ServerConnection.prototype.leave = function(group) {
+    this.send({
+        type: 'join',
+        kind: 'leave',
+        group: group,
+    });
+};
+
+/**
+ * request sets the list of requested tracks
+ *
+ * @param {Object<string,Array<string>>} what
+ *     - A dictionary that maps labels to a sequence of 'audio', 'video'
+ *       or 'video-low.  An entry with an empty label '' provides the default.
+ */
+ServerConnection.prototype.request = function(what) {
+    this.send({
+        type: 'request',
+        request: what,
+    });
+};
+
+/**
+ * findByLocalId finds an active connection with the given localId.
+ * It returns null if none was find.
+ *
+ * @param {string} localId
+ * @returns {Stream}
+ */
+ServerConnection.prototype.findByLocalId = function(localId) {
+    if(!localId)
+        return null;
+
+    let sc = this;
+
+    for(let id in sc.up) {
+        let s = sc.up[id];
+        if(s.localId === localId)
+            return s;
+    }
+    return null;
+}
+
+/**
+ * getRTCConfiguration returns the RTCConfiguration that should be used
+ * with this peer connection.  This usually comes from the server, but may
+ * be overridden by the onpeerconnection callback.
+ *
+ * @returns {RTCConfiguration}
+ */
+ServerConnection.prototype.getRTCConfiguration = function() {
+    if(this.onpeerconnection) {
+        let conf = this.onpeerconnection.call(this);
+        if(conf !== null)
+            return conf;
+    }
+    return this.rtcConfiguration;
+}
+
+/**
+ * newUpStream requests the creation of a new up stream.
+ *
+ * @param {string} [localId]
+ *   - The local id of the stream to create.  If a stream already exists with
+ *     the same local id, it is replaced with the new stream.
+ * @returns {Stream}
+ */
+ServerConnection.prototype.newUpStream = function(localId) {
+    let sc = this;
+    let id = newRandomId();
+    if(sc.up[id])
+        throw new Error('Eek!');
+
+    if(typeof RTCPeerConnection === 'undefined')
+        throw new Error("This browser doesn't support WebRTC");
+
+
+    let pc = new RTCPeerConnection(sc.getRTCConfiguration());
+    if(!pc)
+        throw new Error("Couldn't create peer connection");
+
+    let oldId = null;
+    if(localId) {
+        let old = sc.findByLocalId(localId);
+        oldId = old && old.id;
+        if(old)
+            old.close(true);
+    }
+
+    let c = new Stream(this, id, localId || newLocalId(), pc, true);
+    if(oldId)
+        c.replace = oldId;
+    sc.up[id] = c;
+
+    pc.onnegotiationneeded = async e => {
+            await c.negotiate();
+    };
+
+    pc.onicecandidate = e => {
+        if(!e.candidate)
+            return;
+        c.gotLocalIce(e.candidate);
+    };
+
+    pc.oniceconnectionstatechange = e => {
+        if(c.onstatus)
+            c.onstatus.call(c, pc.iceConnectionState);
+        if(pc.iceConnectionState === 'failed')
+            c.restartIce();
+    };
+
+    pc.ontrack = console.error;
+    return c;
+};
+
+/**
+ * chat sends a chat message to the server.  The server will normally echo
+ * the message back to the client.
+ *
+ * @param {string} kind
+ *     -  The kind of message, either '', 'me' or an application-specific type.
+ * @param {string} dest - The id to send the message to, empty for broadcast.
+ * @param {string} value - The text of the message.
+ */
+ServerConnection.prototype.chat = function(kind, dest, value) {
+    this.send({
+        type: 'chat',
+        source: this.id,
+        dest: dest,
+        username: this.username,
+        kind: kind,
+        value: value,
+    });
+};
+
+/**
+ * userAction sends a request to act on a user.
+ *
+ * @param {string} kind - One of "op", "unop", "kick", "present", "unpresent".
+ * @param {string} dest - The id of the user to act upon.
+ * @param {any} [value] - An action-dependent parameter.
+ */
+ServerConnection.prototype.userAction = function(kind, dest, value) {
+    this.send({
+        type: 'useraction',
+        source: this.id,
+        dest: dest,
+        username: this.username,
+        kind: kind,
+        value: value,
+    });
+};
+
+/**
+ * userMessage sends an application-specific message to a user.
+ * This is similar to a chat message, but is not saved in the chat history.
+ *
+ * @param {string} kind - The kind of application-specific message.
+ * @param {string} dest - The id to send the message to, empty for broadcast.
+ * @param {unknown} [value] - An optional parameter.
+ * @param {boolean} [noecho] - If set, don't echo back the message to the sender.
+ */
+ServerConnection.prototype.userMessage = function(kind, dest, value, noecho) {
+    this.send({
+        type: 'usermessage',
+        source: this.id,
+        dest: dest,
+        username: this.username,
+        kind: kind,
+        value: value,
+        noecho: noecho,
+    });
+};
+
+/**
+ * groupAction sends a request to act on the current group.
+ *
+ * @param {string} kind
+ *     - One of 'clearchat', 'lock', 'unlock', 'record' or 'unrecord'.
+ * @param {string} [message] - An optional user-readable message.
+ */
+ServerConnection.prototype.groupAction = function(kind, message) {
+    this.send({
+        type: 'groupaction',
+        source: this.id,
+        kind: kind,
+        username: this.username,
+        value: message,
+    });
+};
+
+/**
+ * gotOffer is called when we receive an offer from the server.  Don't call this.
+ *
+ * @param {string} id
+ * @param {string} label
+ * @param {string} source
+ * @param {string} username
+ * @param {string} sdp
+ * @param {string} replace
+ * @function
+ */
+ServerConnection.prototype.gotOffer = async function(id, label, source, username, sdp, replace) {
+    let sc = this;
+
+    if(sc.up[id]) {
+        console.error("Duplicate connection id");
+        sc.send({
+            type: 'abort',
+            id: id,
+        });
+        return;
+    }
+
+    let oldLocalId = null;
+
+    if(replace) {
+        let old = sc.down[replace];
+        if(old) {
+            oldLocalId = old.localId;
+            old.close(true);
+        } else
+            console.error("Replacing unknown stream");
+    }
+
+    let c = sc.down[id];
+    if(c && oldLocalId)
+        console.error("Replacing duplicate stream");
+
+    if(!c) {
+        let pc;
+        try {
+            pc = new RTCPeerConnection(sc.getRTCConfiguration());
+        } catch(e) {
+            console.error(e);
+            sc.send({
+                type: 'abort',
+                id: id,
+            });
+            return;
+        }
+        c = new Stream(this, id, oldLocalId || newLocalId(), pc, false);
+        c.label = label;
+        sc.down[id] = c;
+
+        c.pc.onicecandidate = function(e) {
+            if(!e.candidate)
+                return;
+            c.gotLocalIce(e.candidate);
+        };
+
+        pc.oniceconnectionstatechange = e => {
+            if(c.onstatus)
+                c.onstatus.call(c, pc.iceConnectionState);
+            if(pc.iceConnectionState === 'failed') {
+                sc.send({
+                    type: 'renegotiate',
+                    id: id,
+                });
+            }
+        };
+
+        c.pc.ontrack = function(e) {
+            if(e.streams.length < 1) {
+                console.error("Got track with no stream");
+                return;
+            }
+            c.stream = e.streams[0];
+            let changed = recomputeUserStreams(sc, source);
+            if(c.ondowntrack) {
+                c.ondowntrack.call(
+                    c, e.track, e.transceiver, e.streams[0],
+                );
+            }
+            if(changed && sc.onuser)
+                sc.onuser.call(sc, source, "change");
+        };
+    }
+
+    c.source = source;
+    c.username = username;
+
+    if(sc.ondownstream)
+        sc.ondownstream.call(sc, c);
+
+    try {
+        await c.pc.setRemoteDescription({
+            type: 'offer',
+            sdp: sdp,
+        });
+
+        await c.flushRemoteIceCandidates();
+
+        let answer = await c.pc.createAnswer();
+        if(!answer)
+            throw new Error("Didn't create answer");
+        await c.pc.setLocalDescription(answer);
+        this.send({
+            type: 'answer',
+            id: id,
+            sdp: c.pc.localDescription.sdp,
+        });
+    } catch(e) {
+        try {
+            if(c.onerror)
+                c.onerror.call(c, e);
+        } finally {
+            c.abort();
+        }
+        return;
+    }
+
+    c.localDescriptionSent = true;
+    c.flushLocalIceCandidates();
+    if(c.onnegotiationcompleted)
+        c.onnegotiationcompleted.call(c);
+};
+
+/**
+ * gotAnswer is called when we receive an answer from the server.  Don't
+ * call this.
+ *
+ * @param {string} id
+ * @param {string} sdp
+ * @function
+ */
+ServerConnection.prototype.gotAnswer = async function(id, sdp) {
+    let c = this.up[id];
+    if(!c)
+        throw new Error('unknown up stream');
+    try {
+        await c.pc.setRemoteDescription({
+            type: 'answer',
+            sdp: sdp,
+        });
+    } catch(e) {
+        try {
+            if(c.onerror)
+                c.onerror.call(c, e);
+        } finally {
+            c.close();
+        }
+        return;
+    }
+    await c.flushRemoteIceCandidates();
+    if(c.onnegotiationcompleted)
+        c.onnegotiationcompleted.call(c);
+};
+
+/**
+ * gotRenegotiate is called when we receive a renegotiation request from
+ * the server.  Don't call this.
+ *
+ * @param {string} id
+ * @function
+ */
+ServerConnection.prototype.gotRenegotiate = function(id) {
+    let c = this.up[id];
+    if(!c)
+        throw new Error('unknown up stream');
+    c.restartIce();
+};
+
+/**
+ * gotClose is called when we receive a close request from the server.
+ * Don't call this.
+ *
+ * @param {string} id
+ */
+ServerConnection.prototype.gotClose = function(id) {
+    let c = this.down[id];
+    if(!c) {
+        console.warn('unknown down stream', id);
+        return;
+    }
+    c.close();
+};
+
+/**
+ * gotAbort is called when we receive an abort message from the server.
+ * Don't call this.
+ *
+ * @param {string} id
+ */
+ServerConnection.prototype.gotAbort = function(id) {
+    let c = this.up[id];
+    if(!c)
+        throw new Error('unknown up stream');
+    c.close();
+};
+
+/**
+ * gotRemoteIce is called when we receive an ICE candidate from the server.
+ * Don't call this.
+ *
+ * @param {string} id
+ * @param {RTCIceCandidate} candidate
+ * @function
+ */
+ServerConnection.prototype.gotRemoteIce = async function(id, candidate) {
+    let c = this.up[id];
+    if(!c)
+        c = this.down[id];
+    if(!c)
+        throw new Error('unknown stream');
+    if(c.pc.remoteDescription)
+        await c.pc.addIceCandidate(candidate).catch(console.warn);
+    else
+        c.remoteIceCandidates.push(candidate);
+};
+
+/**
+ * Stream encapsulates a MediaStream, a set of tracks.
+ *
+ * A stream is said to go "up" if it is from the client to the server, and
+ * "down" otherwise.
+ *
+ * @param {ServerConnection} sc
+ * @param {string} id
+ * @param {string} localId
+ * @param {RTCPeerConnection} pc
+ *
+ * @constructor
+ */
+function Stream(sc, id, localId, pc, up) {
+    /**
+     * The associated ServerConnection.
+     *
+     * @type {ServerConnection}
+     * @const
+     */
+    this.sc = sc;
+    /**
+     * The id of this stream.
+     *
+     * @type {string}
+     * @const
+     */
+    this.id = id;
+    /**
+     * The local id of this stream.
+     *
+     * @type {string}
+     * @const
+     */
+    this.localId = localId;
+    /**
+     * Indicates whether the stream is in the client->server direction.
+     *
+     * @type {boolean}
+     * @const
+     */
+    this.up = up;
+    /**
+     * For down streams, the id of the client that created the stream.
+     *
+     * @type {string}
+     */
+    this.source = null;
+    /**
+     * For down streams, the username of the client who created the stream.
+     *
+     * @type {string}
+     */
+    this.username = null;
+    /**
+     * The associated RTCPeerConnection.  This is null before the stream
+     * is connected, and may change over time.
+     *
+     * @type {RTCPeerConnection}
+     */
+    this.pc = pc;
+    /**
+     * The associated MediaStream.  This is null before the stream is
+     * connected, and may change over time.
+     *
+     * @type {MediaStream}
+     */
+    this.stream = null;
+    /**
+     * The label assigned by the originator to this stream.
+     *
+     * @type {string}
+     */
+    this.label = null;
+    /**
+     * The id of the stream that we are currently replacing.
+     *
+     * @type {string}
+     */
+    this.replace = null;
+    /**
+     * Indicates whether we have already sent a local description.
+     *
+     * @type {boolean}
+     */
+    this.localDescriptionSent = false;
+    /**
+     * Buffered local ICE candidates.  This will be flushed by
+     * flushLocalIceCandidates after we send a local description.
+     *
+     * @type {RTCIceCandidate[]}
+     */
+    this.localIceCandidates = [];
+    /**
+     * Buffered remote ICE candidates.  This will be flushed by
+     * flushRemoteIceCandidates when we get a remote SDP description.
+     *
+     * @type {RTCIceCandidate[]}
+     */
+    this.remoteIceCandidates = [];
+    /**
+     * The statistics last computed by the stats handler.  This is
+     * a dictionary indexed by track id, with each value a dictionary of
+     * statistics.
+     *
+     * @type {Object<string,unknown>}
+     */
+    this.stats = {};
+    /**
+     * The id of the periodic handler that computes statistics, as
+     * returned by setInterval.
+     *
+     * @type {number}
+     */
+    this.statsHandler = null;
+    /**
+     * userdata is a convenient place to attach data to a Stream.
+     * It is not used by the library.
+     *
+     * @type{Object<unknown,unknown>}
+     */
+    this.userdata = {};
+
+    /* Callbacks */
+
+    /**
+     * onclose is called when the stream is closed.  Replace will be true
+     * if the stream is being replaced by another one with the same id.
+     *
+     * @type{(this: Stream, replace: boolean) => void}
+     */
+    this.onclose = null;
+    /**
+     * onerror is called whenever a fatal error occurs.  The stream will
+     * then be closed, and onclose called normally.
+     *
+     * @type{(this: Stream, error: unknown) => void}
+     */
+    this.onerror = null;
+    /**
+     * onnegotiationcompleted is called whenever negotiation or
+     * renegotiation has completed.
+     *
+     * @type{(this: Stream) => void}
+     */
+    this.onnegotiationcompleted = null;
+    /**
+     * ondowntrack is called whenever a new track is added to a stream.
+     * If the stream parameter differs from its previous value, then it
+     * indicates that the old stream has been discarded.
+     *
+     * @type{(this: Stream, track: MediaStreamTrack, transceiver: RTCRtpTransceiver, stream: MediaStream) => void}
+     */
+    this.ondowntrack = null;
+    /**
+     * onstatus is called whenever the status of the stream changes.
+     *
+     * @type{(this: Stream, status: string) => void}
+     */
+    this.onstatus = null;
+    /**
+     * onstats is called when we have new statistics about the connection
+     *
+     * @type{(this: Stream, stats: Object<unknown,unknown>) => void}
+     */
+    this.onstats = null;
+}
+
+/**
+ * setStream sets the stream of an upwards connection.
+ *
+ * @param {MediaStream} stream
+ */
+Stream.prototype.setStream = function(stream) {
+    let c = this;
+    c.stream = stream;
+    let changed = recomputeUserStreams(c.sc, c.sc.id);
+    if(changed && c.sc.onuser)
+        c.sc.onuser.call(c.sc, c.sc.id, "change");
+}
+
+/**
+ * close closes a stream.
+ *
+ * For streams in the up direction, this may be called at any time.  For
+ * streams in the down direction, this will be called automatically when
+ * the server signals that it is closing a stream.
+ *
+ * @param {boolean} [replace]
+ *    - true if the stream is being replaced by another one with the same id
+ */
+Stream.prototype.close = function(replace) {
+    let c = this;
+
+    if(!c.sc) {
+        console.warn('Closing closed stream');
+        return;
+    }
+
+    if(c.statsHandler) {
+        clearInterval(c.statsHandler);
+        c.statsHandler = null;
+    }
+
+    c.pc.close();
+
+    if(c.up && !replace && c.localDescriptionSent) {
+        try {
+            c.sc.send({
+                type: 'close',
+                id: c.id,
+            });
+        } catch(e) {
+        }
+    }
+
+    let userid;
+    if(c.up) {
+        userid = c.sc.id;
+        if(c.sc.up[c.id] === c)
+            delete(c.sc.up[c.id]);
+        else
+            console.warn('Closing unknown stream');
+    } else {
+        userid = c.source;
+        if(c.sc.down[c.id] === c)
+            delete(c.sc.down[c.id]);
+        else
+            console.warn('Closing unknown stream');
+    }
+    let changed = recomputeUserStreams(c.sc, userid);
+    if(changed && c.sc.onuser)
+        c.sc.onuser.call(c.sc, userid, "change");
+    c.sc = null;
+
+    if(c.onclose)
+        c.onclose.call(c, replace);
+};
+
+/**
+ * recomputeUserStreams recomputes the user.streams array for a given user.
+ * It returns true if anything changed.
+ *
+ * @param {ServerConnection} sc
+ * @param {string} id
+ * @returns {boolean}
+ */
+function recomputeUserStreams(sc, id) {
+    let user = sc.users[id];
+    if(!user) {
+        console.warn("recomputing streams for unknown user");
+        return false;
+    }
+
+    let streams = id === sc.id ? sc.up : sc.down;
+    let old = user.streams;
+    user.streams = {};
+    for(id in streams) {
+        let c = streams[id];
+        if(!c.stream)
+            continue;
+        if(!user.streams[c.label])
+            user.streams[c.label] = {};
+        c.stream.getTracks().forEach(t => {
+            user.streams[c.label][t.kind] = true;
+        });
+    }
+
+    return JSON.stringify(old) != JSON.stringify(user.streams);
+}
+
+/**
+ * abort requests that the server close a down stream.
+ */
+Stream.prototype.abort = function() {
+    let c = this;
+    if(c.up)
+        throw new Error("Abort called on an up stream");
+    c.sc.send({
+        type: 'abort',
+        id: c.id,
+    });
+};
+
+/**
+ * gotLocalIce is Called when we get a local ICE candidate.  Don't call this.
+ *
+ * @param {RTCIceCandidate} candidate
+ * @function
+ */
+Stream.prototype.gotLocalIce = function(candidate) {
+    let c = this;
+    if(c.localDescriptionSent)
+        c.sc.send({type: 'ice',
+                   id: c.id,
+                   candidate: candidate,
+                  });
+    else
+        c.localIceCandidates.push(candidate);
+};
+
+/**
+ * flushLocalIceCandidates flushes any buffered local ICE candidates.
+ * It is called when we send an offer.
+ *
+ * @function
+ */
+Stream.prototype.flushLocalIceCandidates = function () {
+    let c = this;
+    let candidates = c.localIceCandidates;
+    c.localIceCandidates = [];
+    candidates.forEach(candidate => {
+        try {
+            c.sc.send({type: 'ice',
+                       id: c.id,
+                       candidate: candidate,
+                      });
+        } catch(e) {
+            console.warn(e);
+        }
+    });
+    c.localIceCandidates = [];
+};
+
+/**
+ * flushRemoteIceCandidates flushes any buffered remote ICE candidates.  It is
+ * called automatically when we get a remote description.
+ *
+ * @function
+ */
+Stream.prototype.flushRemoteIceCandidates = async function () {
+    let c = this;
+    let candidates = c.remoteIceCandidates;
+    c.remoteIceCandidates = [];
+    /** @type {Array.<Promise<void>>} */
+    let promises = [];
+    candidates.forEach(candidate => {
+        promises.push(c.pc.addIceCandidate(candidate).catch(console.warn));
+    });
+    return await Promise.all(promises);
+};
+
+/**
+ * negotiate negotiates or renegotiates an up stream.  It is called
+ * automatically when required.  If the client requires renegotiation, it
+ * is probably better to call restartIce which will cause negotiate to be
+ * called asynchronously.
+ *
+ * @function
+ * @param {boolean} [restartIce] - Whether to restart ICE.
+ */
+Stream.prototype.negotiate = async function (restartIce) {
+    let c = this;
+    if(!c.up)
+        throw new Error('not an up stream');
+
+    let options = {};
+    if(restartIce)
+        options = {iceRestart: true};
+    let offer = await c.pc.createOffer(options);
+    if(!offer)
+        throw(new Error("Didn't create offer"));
+    await c.pc.setLocalDescription(offer);
+
+    c.sc.send({
+        type: 'offer',
+        source: c.sc.id,
+        username: c.sc.username,
+        kind: this.localDescriptionSent ? 'renegotiate' : '',
+        id: c.id,
+        replace: this.replace,
+        label: c.label,
+        sdp: c.pc.localDescription.sdp,
+    });
+    this.localDescriptionSent = true;
+    this.replace = null;
+    c.flushLocalIceCandidates();
+};
+
+/**
+ * restartIce causes an ICE restart on a stream.  For up streams, it is
+ * called automatically when ICE signals that the connection has failed,
+ * but may also be called by the application.  For down streams, it
+ * requests that the server perform an ICE restart.  In either case,
+ * it returns immediately, negotiation will happen asynchronously.
+ */
+
+Stream.prototype.restartIce = function () {
+    let c = this;
+    if(!c.up) {
+        c.sc.send({
+            type: 'renegotiate',
+            id: c.id,
+        });
+        return;
+    }
+
+    if('restartIce' in c.pc) {
+        try {
+            c.pc.restartIce();
+            return;
+        } catch(e) {
+            console.warn(e);
+        }
+    }
+
+    // negotiate is async, but this returns immediately.
+    c.negotiate(true);
+};
+
+/**
+ * request sets the list of tracks.  If this is not called, or called with
+ * a null argument, then the default is provided by ServerConnection.request.
+ *
+ * @param {Array<string>} what - a sequence of 'audio', 'video' or 'video-low'.
+ */
+Stream.prototype.request = function(what) {
+    let c = this;
+    c.sc.send({
+        type: 'requestStream',
+        id: c.id,
+        request: what,
+    });
+};
+
+/**
+ * updateStats is called periodically, if requested by setStatsInterval,
+ * in order to recompute stream statistics and invoke the onstats handler.
+ *
+ * @function
+ */
+Stream.prototype.updateStats = async function() {
+    let c = this;
+    let old = c.stats;
+    /** @type{Object<string,unknown>} */
+    let stats = {};
+
+    let transceivers = c.pc.getTransceivers();
+    for(let i = 0; i < transceivers.length; i++) {
+        let t = transceivers[i];
+        let stid = t.sender.track && t.sender.track.id;
+        let rtid = t.receiver.track && t.receiver.track.id;
+
+        let report = null;
+        if(stid) {
+            try {
+                report = await t.sender.getStats();
+            } catch(e) {
+            }
+        }
+
+        if(report) {
+            for(let r of report.values()) {
+                if(stid && r.type === 'outbound-rtp') {
+                    let id = stid;
+                    // Firefox doesn't implement rid, use ssrc
+                    // to discriminate simulcast tracks.
+                    id = id + '-' + r.ssrc;
+                    if(!('bytesSent' in r))
+                        continue;
+                    if(!stats[id])
+                        stats[id] = {};
+                    stats[id][r.type] = {};
+                    stats[id][r.type].timestamp = r.timestamp;
+                    stats[id][r.type].bytesSent = r.bytesSent;
+                    if(old[id] && old[id][r.type])
+                        stats[id][r.type].rate =
+                        ((r.bytesSent - old[id][r.type].bytesSent) * 1000 /
+                         (r.timestamp - old[id][r.type].timestamp)) * 8;
+                }
+            }
+        }
+
+        report = null;
+        if(rtid) {
+            try {
+                report = await t.receiver.getStats();
+            } catch(e) {
+                console.error(e);
+            }
+        }
+
+        if(report) {
+            for(let r of report.values()) {
+                if(rtid && r.type === 'track') {
+                    if(!('totalAudioEnergy' in r))
+                        continue;
+                    if(!stats[rtid])
+                        stats[rtid] = {};
+                    stats[rtid][r.type] = {};
+                    stats[rtid][r.type].timestamp = r.timestamp;
+                    stats[rtid][r.type].totalAudioEnergy = r.totalAudioEnergy;
+                    if(old[rtid] && old[rtid][r.type])
+                        stats[rtid][r.type].audioEnergy =
+                        (r.totalAudioEnergy - old[rtid][r.type].totalAudioEnergy) * 1000 /
+                        (r.timestamp - old[rtid][r.type].timestamp);
+                }
+            }
+        }
+    }
+
+    c.stats = stats;
+
+    if(c.onstats)
+        c.onstats.call(c, c.stats);
+};
+
+/**
+ * setStatsInterval sets the interval in milliseconds at which the onstats
+ * handler will be called.  This is only useful for up streams.
+ *
+ * @param {number} ms - The interval in milliseconds.
+ */
+Stream.prototype.setStatsInterval = function(ms) {
+    let c = this;
+    if(c.statsHandler) {
+        clearInterval(c.statsHandler);
+        c.statsHandler = null;
+    }
+
+    if(ms <= 0)
+        return;
+
+    c.statsHandler = setInterval(() => {
+        c.updateStats();
+    }, ms);
+};

+ 24 - 0
packages/galene/readme.md

@@ -0,0 +1,24 @@
+# Galene SFU plugin for converse.js
+
+<img src="https://github.com/conversejs/community-plugins/blob/master/packages/galene/galene.png?raw=true" />
+
+## Overview
+This plugin implements [XEP-XXXX: In-band SFU Sessions](https://igniterealtime.github.io/openfire-galene-plugin/xep/index.html) and [XEP-0327: Rayo](https://xmpp.org/extensions/xep-0327.html) to provide audio and video conferencing features for Converse.
+Your XMPP server MUST support the above XEPs otherwise, it won't work.
+## Install
+see https://m.conversejs.org/docs/html/plugin_development.html on how to install this plugin
+
+## Configure
+To configure, edit the converse settings and modify all the galene_  values. See index.html for an example
+
+```
+converse.initialize({
+    ....
+
+    ....
+});
+```
+
+## How to use
+Click on the video icon on the conversation toolbar to turn a chat or groupchat into an audio/video conference
+Click on the telephone icon on the conversation toolbar to initiate an audio call from a chat conversation.

ファイルの差分が大きいため隠しています
+ 0 - 0
packages/galene/stophe.min.js


この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません