// 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'; /** * The name of the hall that we join. * * @type {string} */ let hall; /** * The connection to the server. * * @type {ServerConnection} */ let serverConnection; /** * The hall status. This is set twice, once over HTTP in the start * function in order to obtain the WebSocket address, and a second time * after joining. * * @type {Object} */ let hallStatus = {}; /** * Whether this hall is currently being recorded. * * @type {boolean} */ let hallRecording = false; /** * True if we need to request a password. * * type {boolean} */ let pwAuth = false; /** * The token we use to login. This is erased as soon as possible. * * @type {string} */ let token = null; /** * The state of the login automaton. * * @type {"probing" | "need-username" | "success"} */ let probingState = null; /** * @typedef {Object} settings - the type of stored settings * @property {boolean} [localMute] * @property {string} [audio] * @property {string} [send] * @property {string} [request] * @property {boolean} [activityDetection] * @property {Array.} [resolution] * @property {boolean} [preprocessing] * @property {boolean} [hqaudio] * @property {boolean} [notificationSounds] * @property {boolean} [forceRelay] */ /** * fallbackSettings is used to store settings if session storage is not * available. * * @type{settings} */ let fallbackSettings = null; /** * Overwrite settings with the parameter. This uses session storage if * available, and the global variable fallbackSettings otherwise. * * @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; } } /** * Return the current value of stored settings. This always returns * a dictionary, even when there are no stored settings. * * @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 || {}; } /** * Update stored settings with the key/value pairs stored in the parameter. * * @param {settings} settings */ function updateSettings(settings) { let s = getSettings(); for(let key in settings) s[key] = settings[key]; storeSettings(s); } /** * Update a single key/value pair in the stored settings. * * @param {string} key * @param {any} value */ function updateSetting(key, value) { let s = {}; s[key] = value; updateSettings(s); } /** * Remove a single key/value pair from the stored settings. * * @param {string} key */ function delSetting(key) { let s = getSettings(); if(!(key in s)) return; delete(s[key]); storeSettings(s); } /** * getElementById, then assert that the result is an HTMLSelectElement. * * @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; } /** * getElementById, then assert that the result is an HTMLInputElement. * * @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; } /** * getElementById, then assert that the result is an HTMLButtonElement. * * @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; } /** * Ensure that the UI reflects the stored settings. */ function reflectSettings() { let settings = getSettings(); let store = false; setLocalMute(settings.localMute); 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('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('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(settings.hasOwnProperty('notificationSounds')) { getInputElement('notificationsoundsbox').checked = settings.notificationSounds; } else { settings.notificationSounds = getInputElement('notificationsoundsbox').checked; store = true; } if(store) storeSettings(settings); } /** @type {AudioContext} */ let notificationAudioContext = null; let userNotificationSoundsReady = false; let notificationSoundGainScale = 15; function notificationSoundsEnabled() { return !!getSettings().notificationSounds; } function getNotificationAudioContext() { if(!notificationSoundsEnabled()) return null; let AudioContextConstructor = window.AudioContext || window.webkitAudioContext; if(!AudioContextConstructor) return null; if(!notificationAudioContext) notificationAudioContext = new AudioContextConstructor(); if(notificationAudioContext.state === 'suspended') notificationAudioContext.resume().catch(e => { console.warn('Could not resume notification audio:', e); }); return notificationAudioContext; } /** * @param {number} start * @param {number} duration * @param {number} fromFrequency * @param {number} toFrequency * @param {number} gainValue * @param {OscillatorType} [type] */ function scheduleGlide(start, duration, fromFrequency, toFrequency, gainValue, type) { let context = getNotificationAudioContext(); if(!context) return; let oscillator = context.createOscillator(); let gain = context.createGain(); oscillator.type = type || 'sine'; oscillator.frequency.setValueAtTime(fromFrequency, start); oscillator.frequency.exponentialRampToValueAtTime(toFrequency, start + duration); gain.gain.setValueAtTime(0, start); gain.gain.linearRampToValueAtTime( gainValue * notificationSoundGainScale, start + 0.02, ); gain.gain.exponentialRampToValueAtTime(0.0001, start + duration); oscillator.connect(gain); gain.connect(context.destination); oscillator.start(start); oscillator.stop(start + duration + 0.02); } /** * @param {number} offset * @param {number} duration * @param {number} frequency * @param {number} gainValue * @param {OscillatorType} [type] */ function scheduleTone(offset, duration, frequency, gainValue, type) { let context = getNotificationAudioContext(); if(!context) return; let start = context.currentTime + offset; let oscillator = context.createOscillator(); let gain = context.createGain(); oscillator.type = type || 'sine'; oscillator.frequency.setValueAtTime(frequency, start); gain.gain.setValueAtTime(0, start); gain.gain.linearRampToValueAtTime( gainValue * notificationSoundGainScale, start + 0.015, ); gain.gain.exponentialRampToValueAtTime(0.0001, start + duration); oscillator.connect(gain); gain.connect(context.destination); oscillator.start(start); oscillator.stop(start + duration + 0.02); } /** * @param {string} name */ function playNotificationSound(name) { let context = getNotificationAudioContext(); if(!context) return; let now = context.currentTime; switch(name) { case 'hand-raised': scheduleGlide(now, 0.36, 420, 1320, 0.035, 'sine'); break; case 'hand-lowered': scheduleGlide(now, 0.34, 1180, 360, 0.03, 'sine'); break; case 'user-joined': scheduleTone(0, 0.18, 660, 0.035, 'sine'); scheduleTone(0.18, 0.26, 880, 0.032, 'sine'); break; case 'user-left': scheduleTone(0, 0.16, 660, 0.025, 'triangle'); scheduleTone(0.14, 0.22, 440, 0.022, 'triangle'); break; case 'recording-started': scheduleTone(0, 0.16, 523.25, 0.03, 'sine'); scheduleTone(0.09, 0.18, 659.25, 0.026, 'sine'); scheduleTone(0.18, 0.24, 783.99, 0.024, 'sine'); break; case 'recording-stopped': scheduleTone(0, 0.16, 783.99, 0.026, 'sine'); scheduleTone(0.12, 0.18, 659.25, 0.024, 'sine'); scheduleTone(0.24, 0.24, 392, 0.022, 'sine'); break; case 'chat-message': scheduleTone(0, 0.09, 880, 0.018, 'triangle'); scheduleTone(0.08, 0.11, 1174.66, 0.014, 'triangle'); break; default: break; } } function unlockNotificationAudio() { getNotificationAudioContext(); } document.addEventListener('pointerdown', unlockNotificationAudio); document.addEventListener('keydown', unlockNotificationAudio); /** * Returns true if we should use the mobile layout. This should be kept * in sync with the CSS. */ function isMobileLayout() { return !!window.matchMedia('only screen and (max-width: 1024px)').matches } /** * Returns true if we are running on Safari. */ function isSafari() { let ua = navigator.userAgent.toLowerCase(); return ua.indexOf('safari') >= 0 && ua.indexOf('chrome') < 0; } /** * Returns true if we are running on Firefox. */ function isFirefox() { let ua = navigator.userAgent.toLowerCase(); return ua.indexOf('firefox') >= 0; } /** * setConnected is called whenever we connect or disconnect to the server. * * @param{boolean} connected */ function setConnected(connected) { let userbox = document.getElementById('profile'); let connectionbox = document.getElementById('login-container'); let alreadyConnected = !userbox.classList.contains('invisible'); if(connected !== alreadyConnected) { for(let elt of document.querySelectorAll('.connected-only')) { if(connected) { elt.classList.remove('invisible'); setAccessibleHidden(elt, false); } else { elt.classList.add('invisible'); setAccessibleHidden(elt, true); } } } if(connected) { if(!alreadyConnected) clearChat(); userbox.classList.remove('invisible'); connectionbox.classList.add('invisible'); closeNav(); setLeftPanelExpanded(true); updateChatResizerValueFromLayout(); displayUsername(); if(!alreadyConnected) focusTitle(); window.onresize = function(e) { scheduleReconsiderDownRate(); } } else { closeNav(); userbox.classList.add('invisible'); connectionbox.classList.remove('invisible'); ; window.onresize = null; } } /** * Called when we connect to the server. * * @this {ServerConnection} */ async function gotConnected() { await join(); } /** * Sets the href field of the "change password" link. * * @param {string} username */ function setChangePassword(username) { let s = document.getElementById('chpwspan'); let a = s.children[0]; if(!(a instanceof HTMLAnchorElement)) throw new Error('Bad type for chpwspan'); if(username) { a.href = `/change-password.html?hall=${encodeURIComponent(hall)}&username=${encodeURIComponent(username)}`; a.target = '_blank'; s.classList.remove('invisible'); } else { a.href = null; s.classList.add('invisible'); } } /** * Join a hall. */ async function join() { let username = getInputElement('username').value.trim(); let credentials; if(token) { pwAuth = false; credentials = { type: 'token', token: token, }; switch(probingState) { case null: // when logging in with a token, we need to give the user // a chance to interact with the page in order to enable // autoplay. Probe the hall first in order to determine if // we need a username. We should really extend the protocol // to have a simpler protocol for probing. probingState = 'probing'; username = null; break; case 'need-username': case 'success': probingState = null; break default: console.warn(`Unexpected probing state ${probingState}`); probingState = null; break; } } else { if(probingState !== null) { console.warn(`Unexpected probing state ${probingState}`); probingState = null; } let pw = getInputElement('password').value; getInputElement('password').value = ''; if(!hallStatus.authServer) { pwAuth = true; credentials = pw; } else { pwAuth = false; credentials = { type: 'authServer', authServer: hallStatus.authServer, location: location.href, password: pw, }; } } try { await serverConnection.join(hall, 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(); closeSafariStream(); setConnected(false); resetChalkboard(); if(code != 1000) { console.warn('Socket close', code, reason); } let form = document.getElementById('loginform'); if(!(form instanceof HTMLFormElement)) throw new Error('Bad type for loginform'); } /** * @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.toString()); }; c.ondowntrack = function(track, transceiver, stream) { setMedia(c); }; c.onnegotiationcompleted = function() { resetMedia(c); } c.onstatus = function(status) { setMediaStatus(c); }; c.onstats = gotDownStats; if(getSettings().activityDetection) c.setStatsInterval(activityDetectionInterval); setMedia(c); } // Store current browser viewport height in css variable function setViewportHeight() { document.documentElement.style.setProperty( '--vh', `${window.innerHeight/100}px`, ); } // 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.'); button.disabled = true; try { let id = findUpMedia('audio'); if(!id) await addLocalMedia(); } finally { button.disabled = false; } }; /** * @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'); } /** * getVisibility tells whether specified element is visible. * * @param {string} id */ function getVisibility(id) { let elt = document.getElementById(id); return !elt.classList.contains('invisible'); } /** * Shows and hides various UI elements depending on the protocol state. */ function setButtonsVisibility() { let connected = serverConnection && serverConnection.socket; let permissions = serverConnection.permissions; let canWebrtc = !(typeof RTCPeerConnection === 'undefined'); let canPresent = canWebrtc && ('mediaDevices' in navigator) && ('getUserMedia' in navigator.mediaDevices) && permissions.indexOf('present') >= 0; let local = !!findUpMedia('audio'); // don't allow multiple presentations setVisibility('presentbutton', canPresent && !local); setVisibility('mutebutton', !connected || canPresent); setVisibility('mediaoptions', canPresent); } /** * Sets the local mute state. If reflect is true, updates the stored settings. * * @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'); button.setAttribute('aria-pressed', 'true'); button.setAttribute('aria-label', 'Unmute microphone'); } else { icon.classList.remove('fa-microphone-slash'); icon.classList.add('fa-microphone'); button.classList.remove('muted'); button.setAttribute('aria-pressed', 'false'); button.setAttribute('aria-label', 'Mute microphone'); } if(reflect) updateSettings({localMute: mute}); } getSelectElement('audioselect').onchange = function(e) { e.preventDefault(); if(!(this instanceof HTMLSelectElement)) throw new Error('Unexpected type for this'); updateSettings({audio: this.value}); }; getInputElement('preprocessingbox').onchange = function(e) { e.preventDefault(); if(!(this instanceof HTMLInputElement)) throw new Error('Unexpected type for this'); updateSettings({preprocessing: this.checked}); }; getInputElement('hqaudiobox').onchange = function(e) { e.preventDefault(); if(!(this instanceof HTMLInputElement)) throw new Error('Unexpected type for this'); updateSettings({hqaudio: this.checked}); }; getInputElement('notificationsoundsbox').onchange = function(e) { e.preventDefault(); if(!(this instanceof HTMLInputElement)) throw new Error('Unexpected type for this'); updateSettings({notificationSounds: this.checked}); if(this.checked) getNotificationAudioContext(); }; document.getElementById('mutebutton').onclick = function(e) { e.preventDefault(); let localMute = getSettings().localMute; if (localMute && !findUpMedia('audio')) { displayMessage('Please use Enable to enable your microphone.'); } else { localMute = !localMute; setLocalMute(localMute, true); } }; /** * @param {string} what * @returns {Object>} */ function mapRequest(what) { switch(what) { case "": return {"": []}; case "audio": default: return {"": ["audio"]}; } } /** * Like mapRequest, but for a single label. * * @param {string} what * @param {string} label * @returns {Array} */ 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} 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(!peer) return; if(value) peer.classList.add('peer-active'); else peer.classList.remove('peer-active'); } /** * @this {Stream} * @param {Object} 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['inbound-rtp'] && s['inbound-rtp'].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); } } /** * Add an option to an HTMLSelectElement. * * @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); } /** * Returns true if an HTMLSelectElement has an option with a given value. * * @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 ''; } /** * True if we already went through setMediaChoices twice. * * @type {boolean} */ let mediaChoicesDone = false; /** * Populate the media choices menu. * * Since media names might not be available before we call * getUserMedia, we call this function twice, the second time in order * to update the menu with user-readable labels. * * @param{boolean} done */ async function setMediaChoices(done) { if(mediaChoicesDone) return; let devices = []; try { if('mediaDevices' in navigator) devices = await navigator.mediaDevices.enumerateDevices(); } catch(e) { console.error(e); return; } let mn = 1; devices.forEach(d => { let label = d.label; if(d.kind === 'audioinput' && d.deviceId) { if(!label) label = `Microphone ${mn}`; addSelectOption(getSelectElement('audioselect'), label, d.deviceId); mn++; } }); mediaChoicesDone = done; } /** * @param {string} [localId] */ function newUpStream(localId) { if(!serverConnection) throw new Error("Not connected"); let c = serverConnection.newUpStream(localId); c.onstatus = function(status) { setMediaStatus(c); }; c.onerror = function(e) { console.error(e); displayError(e); }; return c; } async function reconsiderParameters(c) { return; } let reconsiderParametersTimer = null; /** * Sets the send parameters for all up streams. */ async function reconsiderSendParameters() { cancelReconsiderParameters(); return; } /** * 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; } } const hqAudioRate = 128000; /** * Sets up c to send the given stream. Some extra parameters are stored * in c.userdata. * * @param {Stream} c * @param {MediaStream} stream */ async function setUpStream(c, stream) { if(c.stream != null) throw new Error("Setting nonempty stream"); c.setStream(stream); // set up the handler early, before any sender setup can fail. c.onclose = async replace => { 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 === 'audio' && t.kind == 'audio') { if(settings.localMute) t.enabled = false; } t.onended = e => { stream.onaddtrack = null; stream.onremovetrack = null; c.close(); }; let encodings = []; if(settings.hqaudio) { encodings.push({ maxBitrate: hqAudioRate, }); } let tr = c.pc.addTransceiver(t, { direction: 'sendonly', streams: [stream], sendEncodings: encodings, }); // Firefox before 110 does not implement sendEncodings, and // requires this hack, which throws an exception on Chromium. try { let p = tr.sender.getParameters(); if(!p.encodings) { p.encodings = encodings; tr.sender.setParameters(p); } } catch(e) { } } 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} */ async function replaceUpStream(c) { let cn = newUpStream(c.localId); cn.label = c.label; if(c.userdata.onclose) cn.userdata.onclose = c.userdata.onclose; try { await setUpStream(cn, c.stream); } catch(e) { console.error(e); displayError(e); cn.close(); c.close(); return null; } await setMedia(cn); 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); } /** * @param {string} [localId] */ async function addLocalMedia(localId) { let settings = getSettings(); /** @type{boolean|MediaTrackConstraints} */ let audio = false; if(settings.audio === 'default') { audio = {}; } else if(settings.audio && settings.audio !== 'off') { audio = {deviceId: settings.audio}; } if(!audio) { displayMessage('Please choose a microphone before enabling audio.'); return; } if(audio !== true) { if(!settings.preprocessing) { audio.echoCancellation = false; audio.noiseSuppression = false; audio.autoGainControl = false; } } let old = serverConnection.findByLocalId(localId); if(old) { stopStream(old.stream); } let constraints = {audio: audio, video: false}; /** @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 = 'audio'; try { await setUpStream(c, stream); await setMedia(c); } catch(e) { console.error(e); displayError(e); c.close(); } 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) { if(!serverConnection) return null; 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 === 'audio') { let stream = c.stream; stream.getTracks().forEach(t => { if(t.kind === 'audio') { t.enabled = !mute; } }); } } } /** * Reconsider the down stream. * * @param {string} [id] - the id of the track to reconsider, all if null. */ function reconsiderDownRate(id) { // Audio-only: no send quality to reconsider return; } /** * Schedules reconsiderDownRate() to be run later. The delay avoids too * much recomputations when resizing the window. */ /** * setMedia sets up the media for stream c. * * @param {Stream} c */ async function setMedia(c) { let stream = c.stream; if(!stream) return; c.userdata.media = stream; let media = /** @type {HTMLAudioElement} */ (document.getElementById('media-' + c.localId)); if(!media) { media = document.createElement('audio'); media.id = 'media-' + c.localId; media.autoplay = true; media.muted = !!c.up; media.classList.add('media'); let container = document.getElementById('audio-streams'); if(!container) throw new Error("Couldn't find audio-streams"); container.appendChild(media); } if(media.srcObject !== stream) media.srcObject = stream; setMediaStatus(c); } /** * @param {Stream} c * @param {HTMLElement} elt */ function showHideMedia(c, elt) { // Audio-only: peers are always shown if present return; } /** * 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 {HTMLAudioElement} */ (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 {HTMLElement} container * @param {Stream} c */ /** * @param {HTMLElement} container * @param {string} name */ /** * @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 {HTMLElement} container */ /** * @param {string} localId */ function delMedia(localId) { let media = document.getElementById('media-' + localId); if(media) { if(media instanceof HTMLMediaElement) media.srcObject = null; media.remove(); } setButtonsVisibility(); } function resizePeers() { } /** * @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(media instanceof HTMLMediaElement) { if(!c.up && !c.userdata.playing) { c.userdata.playing = true; media.play().catch(e => { c.userdata.playing = false; console.error(e); displayError(e); }); } else if(c.userdata.play) { media.play().catch(e => { console.error(e); displayError(e); }); } delete(c.userdata.play); } } else { media.classList.add('media-failed'); } if(!c.up && state === 'failed') { let from = c.username ? `from user ${c.username}` : 'from anonymous user'; displayWarning(`Cannot receive media ${from}, still trying...`); } } /** * @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'); } } /** * 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 {string} v */ function dateFromInput(v) { let d = new Date(v); if(d.toString() === 'Invalid Date') throw new Error('Invalid date'); return d; } /** * @param {Date} d */ function dateToInput(d) { let dd = new Date(d); dd.setMinutes(dd.getMinutes() - dd.getTimezoneOffset()); return dd.toISOString().slice(0, -1); } function inviteMenu() { let d = /** @type {HTMLDialogElement} */ (document.getElementById('invite-dialog')); if(!('HTMLDialogElement' in window) || !d.showModal) { displayError("This browser doesn't support modal dialogs"); return; } d.returnValue = ''; let c = getButtonElement('invite-cancel'); c.onclick = function(e) { d.close('cancel'); }; let u = getInputElement('invite-username'); u.value = ''; let now = new Date(); now.setMilliseconds(0); now.setSeconds(0); let nb = getInputElement('invite-not-before'); nb.min = dateToInput(now); let ex = getInputElement('invite-expires'); let expires = new Date(now); expires.setDate(expires.getDate() + 2); ex.min = dateToInput(now); ex.value = dateToInput(expires); d.showModal(); } document.getElementById('invite-dialog').onclose = function(e) { if(!(this instanceof HTMLDialogElement)) throw new Error('Unexpected type for this'); let dialog = /** @type {HTMLDialogElement} */(this); if(dialog.returnValue !== 'invite') return; let u = getInputElement('invite-username'); let username = u.value.trim() || null; let nb = getInputElement('invite-not-before'); let notBefore = null; if(nb.value) { try { notBefore = dateFromInput(nb.value); } catch(e) { displayError(`Couldn't parse ${nb.value}: ${e}`); return; } } let ex = getInputElement('invite-expires'); let expires = null; if(ex.value) { try { expires = dateFromInput(ex.value); } catch(e) { displayError(`Couldn't parse ${ex.value}: ${e}`); return; } } let template = {} if(username) template.username = username; if(notBefore) template['not-before'] = notBefore; if(expires) template.expires = expires; makeToken(template); }; let currentActionMenu = null; let currentActionMenuTrigger = null; function closeActionMenu(restoreFocus) { document.removeEventListener('click', closeActionMenuOnOutside); if(currentActionMenu) currentActionMenu.remove(); if(currentActionMenuTrigger) currentActionMenuTrigger.setAttribute('aria-expanded', 'false'); if(restoreFocus && currentActionMenuTrigger) currentActionMenuTrigger.focus(); currentActionMenu = null; currentActionMenuTrigger = null; } function canModerateChat() { return !!(serverConnection && serverConnection.permissions && serverConnection.permissions.indexOf('op') >= 0); } function canRecordHall() { return !!(serverConnection && serverConnection.permissions && serverConnection.permissions.indexOf('record') >= 0); } function recordingsUrl() { return '/recordings/' + hall.split('/').map(part => encodeURIComponent(part)).join('/') + '/'; } /** * @param {{label?: string, type?: string, onClick?: function(): void}[]} items * @param {HTMLElement} trigger * @param {string} label */ function openActionMenu(items, trigger, label) { closeActionMenu(false); let menu = document.createElement('ul'); menu.classList.add('skald-action-menu'); menu.setAttribute('role', 'menu'); menu.setAttribute('aria-label', label); let buttons = []; items.forEach(item => { let li = document.createElement('li'); if(item.type === 'seperator') { li.classList.add('skald-action-menu-separator'); li.setAttribute('role', 'separator'); menu.appendChild(li); return; } let button = document.createElement('button'); button.type = 'button'; button.classList.add('skald-action-menu-item'); button.setAttribute('role', 'menuitem'); button.textContent = item.label || 'Action'; button.addEventListener('click', function(e) { e.stopPropagation(); closeActionMenu(true); if(item.onClick) item.onClick(); }); li.appendChild(button); menu.appendChild(li); buttons.push(button); }); if(buttons.length === 0) return; menu.addEventListener('click', function(e) { e.stopPropagation(); }); menu.addEventListener('keydown', function(e) { let index = buttons.indexOf(document.activeElement); if(e.key === 'Escape') { e.preventDefault(); closeActionMenu(true); } else if(e.key === 'ArrowDown') { e.preventDefault(); buttons[(index + 1 + buttons.length) % buttons.length].focus(); } else if(e.key === 'ArrowUp') { e.preventDefault(); buttons[(index - 1 + buttons.length) % buttons.length].focus(); } else if(e.key === 'Home') { e.preventDefault(); buttons[0].focus(); } else if(e.key === 'End') { e.preventDefault(); buttons[buttons.length - 1].focus(); } }); document.body.appendChild(menu); currentActionMenu = menu; currentActionMenuTrigger = trigger; trigger.setAttribute('aria-haspopup', 'menu'); trigger.setAttribute('aria-expanded', 'true'); let rect = trigger.getBoundingClientRect(); let left = rect.left; let top = rect.bottom; if(left + menu.offsetWidth > window.innerWidth) left = Math.max(0, window.innerWidth - menu.offsetWidth); if(top + menu.offsetHeight > window.innerHeight) top = Math.max(0, rect.top - menu.offsetHeight); menu.style.left = `${left}px`; menu.style.top = `${top}px`; window.setTimeout(() => { document.addEventListener('click', closeActionMenuOnOutside); }, 0); buttons[0].focus(); } function closeActionMenuOnOutside() { closeActionMenu(false); } /** * @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.version !== "1" && serverConnection.permissions.indexOf('token') >= 0) { items.push({label: 'Invite user', onClick: () => { inviteMenu(); }}); } if(canRecordHall()) { items.push({label: hallRecording ? 'Stop recording' : 'Start recording', onClick: () => { serverConnection.hallAction(hallRecording ? 'unrecord' : 'record'); }}); items.push({label: 'Open recordings (opens in new tab)', onClick: () => { window.open(recordingsUrl(), '_blank', 'noopener'); }}); } if(findUpMedia('audio')) items.push({label: 'Turn microphone off', onClick: () => { closeUpMedia('audio'); }}); items.push({label: 'Restart audio connection', 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); }}); items.push({label: 'Identify', onClick: () => { serverConnection.userAction('identify', id); }}); } if(serverConnection.permissions.indexOf('op') >= 0 || serverConnection.permissions.indexOf('admin') >= 0) { if(serverConnection.permissions.indexOf('op') < 0) items.push({type: 'seperator'}); // sic if(user.permissions.indexOf('chalkboard') >= 0) items.push({label: 'Forbid chalkboard editing', onClick: () => { serverConnection.userAction('unchalkboard', id); }}); else items.push({label: 'Allow chalkboard editing', onClick: () => { serverConnection.userAction('chalkboard', id); }}); } } openActionMenu(items, elt, `Actions for ${user.username || 'participant'}`); } /** * @param {string} id * @param {user} userinfo */ function addUser(id, userinfo) { let div = document.getElementById('users'); let user = document.createElement('button'); user.id = 'user-' + id; user.type = 'button'; user.classList.add("user-p"); user.setAttribute('aria-haspopup', 'menu'); user.setAttribute('aria-expanded', 'false'); let statusIcon = document.createElement('span'); statusIcon.classList.add('user-status-icon', 'fas'); statusIcon.setAttribute('aria-hidden', 'true'); user.appendChild(statusIcon); let name = document.createElement('span'); name.classList.add('user-name'); user.appendChild(name); let microphoneIcon = document.createElement('span'); microphoneIcon.classList.add('user-microphone-icon', 'fas'); microphoneIcon.setAttribute('aria-hidden', 'true'); microphoneIcon.hidden = true; user.appendChild(microphoneIcon); setUserStatus(id, user, userinfo, false); user.addEventListener('click', function(e) { e.stopPropagation(); let elt = e.currentTarget; if(!elt || !(elt instanceof HTMLElement)) throw new Error("Couldn't find user button"); 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, true); } /** * @param {string} id * @param {HTMLElement} elt * @param {user} userinfo * @param {boolean} notify */ function setUserStatus(id, elt, userinfo, notify) { let username = userinfo.username ? userinfo.username : '(anon)'; let name = elt.querySelector('.user-name'); if(!name) throw new Error("Couldn't find user name span"); if(name.textContent !== username) name.textContent = username; let wasRaised = elt.classList.contains('user-status-raisehand'); if(userinfo.data.raisehand) { elt.classList.add('user-status-raisehand'); if(notify && !wasRaised) { announceUrgent(`${username}: hand raised`); playNotificationSound('hand-raised'); } } else { elt.classList.remove('user-status-raisehand'); if(notify && wasRaised) { announceUrgent(`${username}: hand lowered`); playNotificationSound('hand-lowered'); } } let microphone=false; for(let label in userinfo.streams) { for(let kind in userinfo.streams[label]) { if(kind == 'audio') microphone = true; } } // Build descriptive aria-label with media status let statusParts = [username]; if(userinfo.data.raisehand) statusParts.push('hand raised'); if(microphone) statusParts.push('microphone on'); elt.classList.toggle('user-status-microphone', microphone); let microphoneIcon = elt.querySelector('.user-microphone-icon'); if(!microphoneIcon) throw new Error("Couldn't find user microphone icon span"); microphoneIcon.hidden = !microphone; elt.setAttribute('aria-label', statusParts.join(', ')); } /** * @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 * @param {user|null} userinfo */ function gotUser(id, kind, userinfo) { let username = userinfo && userinfo.username ? userinfo.username : '(anon)'; let isRemote = id !== serverConnection.id; switch(kind) { case 'add': addUser(id, serverConnection.users[id]); if(userNotificationSoundsReady && isRemote) { localMessage(`${username} joined the hall.`); playNotificationSound('user-joined'); } if(Object.keys(serverConnection.users).length == 3) reconsiderSendParameters(); break; case 'delete': if(userNotificationSoundsReady && isRemote) { localMessage(`${username} left the hall.`); playNotificationSound('user-left'); } 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 chalkboard = canEditChalkboard(); let text = ''; if(op && present) text = '(op, presenter)'; else if(op) text = 'operator'; else if(present) text = 'presenter'; else if(chalkboard) text = 'chalkboard editor'; document.getElementById('permspan').textContent = text; } let presentRequested = null; const skaldVersionLabel = 'Skald Version 2026.07.08'; /** * @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) { let displayTitle = skaldVersionLabel; if(title) { let status = hallStatus.locked ? 'locked' : 'unlocked'; if(hallRecording) status += ', recording'; displayTitle = `Hall: ${title} (${status}) - ${skaldVersionLabel}`; } document.title = displayTitle; document.getElementById('title').textContent = displayTitle; } function focusTitle() { let title = document.getElementById('title'); if(title) title.focus({preventScroll: true}); } /** * Under Safari, we request access to the microphone at startup in order to * enable autoplay. The microphone stream is stored in safariStream. * * @type {MediaStream} */ let safariStream = null; async function openSafariStream() { if(!isSafari()) return; if(!safariStream) safariStream = await navigator.mediaDevices.getUserMedia({audio: true}) } async function closeSafariStream() { if(!safariStream) return; stopStream(safariStream); safariStream = null; } /** * @this {ServerConnection} * @param {string} kind * @param {string} hall * @param {Array} perms * @param {Object} status * @param {Object} data * @param {string} error * @param {string} message */ async function gotJoined(kind, hall, perms, status, data, error, message) { let present = presentRequested; presentRequested = null; switch(kind) { case 'fail': if(probingState === 'probing' && error === 'need-username') { probingState = 'need-username'; setVisibility('passwordform', false); } else { token = null; displayError('The server said: ' + message); } closeSafariStream(); this.close(); setButtonsVisibility(); return; case 'redirect': closeSafariStream(); this.close(); token = null; document.location.href = message; return; case 'leave': hallRecording = false; closeSafariStream(); this.close(); setButtonsVisibility(); setChangePassword(null); return; case 'join': case 'change': if(probingState === 'probing') { probingState = 'success'; setVisibility('userform', false); setVisibility('passwordform', false); closeSafariStream(); this.close(); setButtonsVisibility(); return; } else { token = null; } // don't discard endPoint and friends hallStatus.locked = false; hallStatus.recording = false; for(let key in status) hallStatus[key] = status[key]; hallRecording = !!hallStatus.recording; setTitle(hallStatus.displayName || capitalise(hall)); setConnected(true); displayUsername(); updateChalkboardAccess(); setButtonsVisibility(); setChangePassword(pwAuth && !!hallStatus.canChangePassword && serverConnection.username ); openSafariStream(); window.setTimeout(() => { userNotificationSoundsReady = true; }, 1000); if(kind === 'change') return; break; default: token = null; displayError('Unknown join message'); closeSafariStream(); 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 hall is locked'); if(typeof RTCPeerConnection === 'undefined') displayWarning("This browser doesn't support WebRTC"); else this.request(mapRequest(getSettings().request)); if(('mediaDevices' in navigator) && ('getUserMedia' in navigator.mediaDevices) && serverConnection.permissions.indexOf('present') >= 0 && !findUpMedia('audio')) { if(present) { reflectSettings(); let button = getButtonElement('presentbutton'); button.disabled = true; try { await addLocalMedia(); } finally { button.disabled = false; } } else { displayMessage( "Press Enable to enable your microphone" ); } } } /** * @param {TransferredFile} f */ function gotFileTransfer(f) { f.onevent = gotFileTransferEvent; 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) { byes = document.createElement('button'); byes.textContent = 'Accept'; byes.onclick = function(e) { f.receive(); }; byes.id = "byes-" + f.fullid(); } bno = document.createElement('button'); bno.textContent = f.up ? 'Cancel' : 'Reject'; bno.onclick = function(e) { f.cancel(); }; bno.id = "bno-" + f.fullid(); let status = document.createElement('span'); status.id = 'status-' + f.fullid(); if(!f.up) { status.textContent = '(Choosing "Accept" will disclose your IP address.)'; } let statusp = document.createElement('p'); statusp.id = 'statusp-' + f.fullid(); statusp.appendChild(status); 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(statusp); div.classList.add('message'); div.classList.add('message-private'); div.classList.add('message-row'); let box = document.getElementById('box'); box.appendChild(div); if(!f.up) { announceChat( `File offer from ${f.username}: ${f.name}. ` + 'Choosing Accept will disclose your IP address.', ); } return div; } /** * @param {TransferredFile} f * @param {string} status * @param {number} [value] */ function setFileStatus(f, status, value) { let statuselt = document.getElementById('status-' + f.fullid()); if(!statuselt) throw new Error("Couldn't find statusp"); statuselt.textContent = status; if(value) { let progress = document.getElementById('progress-' + f.fullid()); if(!progress || !(progress instanceof HTMLProgressElement)) throw new Error("Couldn't find progress element"); progress.value = value; let label = document.getElementById('progresstext-' + f.fullid()); let percent = Math.round(100 * value / progress.max); label.textContent = `${percent}%`; } } /** * @param {TransferredFile} f * @param {number} [max] */ function createFileProgress(f, max) { let statusp = document.getElementById('statusp-' + f.fullid()); if(!statusp) throw new Error("Couldn't find status div"); /** @type HTMLProgressElement */ let progress = document.createElement('progress'); progress.id = 'progress-' + f.fullid(); progress.classList.add('file-progress'); progress.max = max; progress.value = 0; statusp.appendChild(progress); let progresstext = document.createElement('span'); progresstext.id = 'progresstext-' + f.fullid(); progresstext.textContent = '0%'; statusp.appendChild(progresstext); } /** * @param {TransferredFile} f * @param {boolean} delyes * @param {boolean} delno * @param {boolean} [delprogress] */ function delFileStatusButtons(f, delyes, delno, delprogress) { 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); } if(delprogress) { let statusp = document.getElementById('statusp-' + f.fullid()); let progress = document.getElementById('progress-' + f.fullid()); let progresstext = document.getElementById('progresstext-' + f.fullid()); if(progress) statusp.removeChild(progress); if(progresstext) statusp.removeChild(progresstext); } } /** * @this {TransferredFile} * @param {string} state * @param {any} [data] */ function gotFileTransferEvent(state, data) { let f = this; switch(state) { case 'inviting': break; case 'connecting': delFileStatusButtons(f, true, false); setFileStatus(f, 'Connecting...'); createFileProgress(f, f.size); break; case 'connected': setFileStatus(f, f.up ? 'Sending...' : 'Receiving...', f.datalen); break; case 'done': delFileStatusButtons(f, true, true, true); setFileStatus(f, 'Done.'); if(!f.up) { let url = URL.createObjectURL(data); let a = document.createElement('a'); a.href = url; a.textContent = f.name; a.download = f.name; a.type = f.mimetype; a.click(); URL.revokeObjectURL(url); } break; case 'cancelled': delFileStatusButtons(f, true, true, true); if(data) setFileStatus(f, `Cancelled: ${data.toString()}.`); else setFileStatus(f, 'Cancelled.'); break; case 'closed': break; default: console.error(`Unexpected state "${state}"`); f.cancel(`unexpected state "${state}" (this shouldn't happen)`); break; } } /** * @param {string} id * @param {string} dest * @param {string} username * @param {Date} time * @param {boolean} privileged * @param {string} kind * @param {string} error * @param {any} message */ function gotUserMessage(id, dest, username, time, privileged, kind, error, message) { switch(kind) { case 'kicked': case 'error': case 'warning': { if(!privileged) { console.error(`Got unprivileged message of kind ${kind}`); return; } let from = id ? (username || 'Anonymous') : 'The Server'; displayError(`${from} said: ${message}`, kind); break; } case 'info': { if(!privileged) { console.error(`Got unprivileged message of kind ${kind}`); return; } let text = '' + message; localMessage(text); displayMessage(text); break; } case 'recording': { if(!privileged) { console.error(`Got unprivileged message of kind ${kind}`); return; } let text = '' + message; if(text === 'Recording started') { hallRecording = true; setTitle(hallStatus.displayName || capitalise(hall)); announceUrgent(text); playNotificationSound('recording-started'); } else { if(text === 'Recording stopped') { hallRecording = false; setTitle(hallStatus.displayName || capitalise(hall)); } announceChat(text); if(text === 'Recording stopped') playNotificationSound('recording-stopped'); } displayMessage(text); break; } case 'mute': { if(!privileged) { console.error(`Got unprivileged message of kind ${kind}`); return; } setLocalMute(true, true); let by = username ? ' by ' + username : ''; displayWarning(`You have been muted${by}`); break; } case 'clearchat': { if(!privileged) { console.error(`Got unprivileged message of kind ${kind}`); return; } let id = message && message.id; let userId = message && message.userId; clearChat(id, userId); break; } case 'chalkboard': if(!privileged) { console.error(`Got unprivileged message of kind ${kind}`); return; } applyChalkboardUpdate(message); break; case 'token': if(!privileged) { console.error(`Got unprivileged message of kind ${kind}`); return; } if(error) { displayError(`Token operation failed: ${message}`) return } if(typeof message != 'object') { displayError('Unexpected type for token'); return; } let f = formatToken(message, false); localMessage(f[0] + ': ' + f[1]); if('share' in navigator) { try { navigator.share({ title: `Invitation to Skald hall ${message.hall}`, text: f[0], url: f[1], }); } catch(e) { console.warn("Share failed", e); } } break; case 'tokenlist': if(!privileged) { console.error(`Got unprivileged message of kind ${kind}`); return; } if(error) { displayError(`Token operation failed: ${message}`) return } let s = ''; for(let i = 0; i < message.length; i++) { let f = formatToken(message[i], true); s = s + f[0] + ': ' + f[1] + "\n"; } localMessage(s); break; case 'userinfo': if(!privileged) { console.error(`Got unprivileged message of kind ${kind}`); return; } let u = message.username ? 'username ' + message.username : 'unknown username'; let a = message.address ? 'address ' + message.address : 'unknown address'; localMessage(`User ${message.id} has ${u} and ${a}.`); break; default: console.warn(`Got unknown user message ${kind}`); break; } }; /** * @param {Object} token * @param {boolean} [details] */ function formatToken(token, details) { let url = new URL(window.location.href); let params = new URLSearchParams(); params.append('token', token.token); url.search = params.toString(); let foruser = '', by = '', tohall = ''; if(token.username) foruser = ` for user ${token.username}`; if(details) { if(token.issuedBy) by = ' issued by ' + token.issuedBy; if(token.issuedAt) { if(by === '') by = ' issued at ' + token.issuedAt; else by = by + ' at ' + (new Date(token.issuedAt)).toLocaleString(); } } else { if(token.hall) tohall = ' to hall ' + token.hall; } let since = ''; if(token["not-before"]) since = ` since ${(new Date(token['not-before'])).toLocaleString()}` /** @type{Date} */ let expires = null; let until = ''; if(token.expires) { expires = new Date(token.expires) until = ` until ${expires.toLocaleString()}`; } return [ (expires && (expires >= new Date())) ? `Invitation${foruser}${tohall}${by} valid${since}${until}` : `Expired invitation${foruser}${tohall}${by}`, url.toString(), ]; } const urlRegexp = /https?:\/\/[-a-zA-Z0-9@:%/._\\+~#&()=?]+[-a-zA-Z0-9@:%/_\\+~#&()=]/g; /** * @param {string} text * @returns {HTMLDivElement} */ function formatText(text) { let r = new RegExp(urlRegexp); let result = []; let pos = 0; while(true) { let m = r.exec(text); if(!m) break; result.push(document.createTextNode(text.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(text.slice(pos))); let div = document.createElement('div'); result.forEach(e => { div.appendChild(e); }); return div; } /** * @param {Date} time * @returns {string} */ function formatTime(time) { let delta = Date.now() - time.getTime(); let m = time.getMinutes(); if(delta > -30000) return time.getHours() + ':' + ((m < 10) ? '0' : '') + m; return time.toLocaleString(); } /** * @typedef {Object} lastMessage * @property {string} [nick] * @property {string} [peerId] * @property {string} [dest] * @property {Date} [time] */ /** @type {lastMessage} */ let lastMessage = {}; /** @type {Object.} */ let liveRegionStates = {}; let liveRegionDelay = 700; /** * @param {string} text * @param {string} elementId */ function announceLiveRegion(text, elementId) { let announcement = document.getElementById(elementId); if(!announcement) return; let state = liveRegionStates[elementId]; if(!state) { state = {queue: [], active: false}; liveRegionStates[elementId] = state; } state.queue.push(String(text)); if(!state.active) flushLiveRegion(elementId); } /** * @param {string} elementId */ function flushLiveRegion(elementId) { let state = liveRegionStates[elementId]; let announcement = document.getElementById(elementId); if(!state || !announcement) { if(state) state.active = false; return; } let text = state.queue.shift(); if(text === undefined) { announcement.replaceChildren(); state.active = false; return; } state.active = true; announcement.replaceChildren(); window.setTimeout(() => { let item = document.createElement('div'); item.textContent = text; announcement.appendChild(item); window.setTimeout(() => flushLiveRegion(elementId), liveRegionDelay); }, 20); } /** * @param {string} text */ function announceChat(text) { announceLiveRegion(text, 'chat-announcements'); } let chalkboardRevision = 0; let chalkboardSendTimer = null; let applyingChalkboardUpdate = false; let chalkboardAnnouncementTimer = null; let chalkboardHasPendingAnnouncement = false; let chalkboardPendingHasText = false; let chalkboardLocalTextPending = null; function clearChalkboardAnnouncementTimer() { if(chalkboardAnnouncementTimer) { clearTimeout(chalkboardAnnouncementTimer); chalkboardAnnouncementTimer = null; } } function canEditChalkboard() { if(!serverConnection || !serverConnection.permissions) return false; return serverConnection.permissions.indexOf('op') >= 0 || serverConnection.permissions.indexOf('admin') >= 0 || serverConnection.permissions.indexOf('chalkboard') >= 0; } function updateChalkboardAccess() { let chalkboard = /** @type {HTMLTextAreaElement} */ (document.getElementById('chalkboard')); if(!chalkboard) return; let canEdit = canEditChalkboard(); chalkboard.readOnly = !canEdit; chalkboard.setAttribute('aria-readonly', canEdit ? 'false' : 'true'); chalkboard.setAttribute( 'aria-label', canEdit ? 'Virtual chalkboard' : 'Virtual chalkboard, read only', ); updateChalkboardCopyButton(); } function updateChalkboardCopyButton() { let chalkboard = /** @type {HTMLTextAreaElement} */ (document.getElementById('chalkboard')); let copyButton = /** @type {HTMLButtonElement} */ (document.getElementById('chalkboard-copy')); if(!chalkboard || !copyButton) return; copyButton.disabled = chalkboard.value.length === 0; } function resetChalkboard() { chalkboardRevision = 0; chalkboardHasPendingAnnouncement = false; chalkboardPendingHasText = false; chalkboardLocalTextPending = null; clearChalkboardAnnouncementTimer(); if(chalkboardSendTimer) { clearTimeout(chalkboardSendTimer); chalkboardSendTimer = null; } let chalkboard = /** @type {HTMLTextAreaElement} */ (document.getElementById('chalkboard')); if(chalkboard) chalkboard.value = ''; updateChalkboardAccess(); } async function copyChalkboardToClipboard() { let chalkboard = /** @type {HTMLTextAreaElement} */ (document.getElementById('chalkboard')); if(!chalkboard || chalkboard.value.length === 0) return; try { if(navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(chalkboard.value); } else { chalkboard.focus(); chalkboard.select(); if(!document.execCommand('copy')) throw new Error('copy command failed'); } announceChat('Chalkboard copied to clipboard'); } catch(e) { console.error(e); displayError('Unable to copy chalkboard to clipboard'); } } function announcePendingChalkboardUpdate() { clearChalkboardAnnouncementTimer(); if(!chalkboardHasPendingAnnouncement) return; announceChat( chalkboardPendingHasText ? 'Chalkboard updated. Contents available in the chalkboard.' : 'Chalkboard cleared.', ); chalkboardHasPendingAnnouncement = false; } function scheduleChalkboardUpdateAnnouncement(hasText) { let chalkboard = /** @type {HTMLTextAreaElement} */ (document.getElementById('chalkboard')); if(!chalkboard) return; chalkboardHasPendingAnnouncement = true; chalkboardPendingHasText = hasText; if(document.activeElement === chalkboard) return; clearChalkboardAnnouncementTimer(); chalkboardAnnouncementTimer = setTimeout(() => { chalkboardAnnouncementTimer = null; announceChat(hasText ? 'Chalkboard updated.' : 'Chalkboard cleared.'); }, 2000); } /** * @param {any} message */ function applyChalkboardUpdate(message) { if(!message || typeof message !== 'object') { console.warn('Unexpected chalkboard message', message); return; } let text = typeof message.text === 'string' ? message.text : ''; let revision = Number(message.revision || 0); if(revision < chalkboardRevision) return; chalkboardRevision = revision; let chalkboard = /** @type {HTMLTextAreaElement} */ (document.getElementById('chalkboard')); if(!chalkboard) return; let isLocalEcho = chalkboardLocalTextPending !== null && text === chalkboardLocalTextPending; if(chalkboard.value !== text) { applyingChalkboardUpdate = true; chalkboard.value = text; applyingChalkboardUpdate = false; if(isLocalEcho) { chalkboardLocalTextPending = null; } else { scheduleChalkboardUpdateAnnouncement(text.length > 0); } } else if(isLocalEcho) { chalkboardLocalTextPending = null; } updateChalkboardAccess(); } function scheduleChalkboardSend() { if(!canEditChalkboard()) return; if(chalkboardSendTimer) clearTimeout(chalkboardSendTimer); chalkboardSendTimer = setTimeout(() => { chalkboardSendTimer = null; let chalkboard = /** @type {HTMLTextAreaElement} */ (document.getElementById('chalkboard')); if(!chalkboard || !serverConnection || !serverConnection.socket) return; chalkboardLocalTextPending = chalkboard.value; serverConnection.hallAction('chalkboard', { text: chalkboard.value, revision: chalkboardRevision, }); }, 350); } /** * @param {string} text */ function announceUrgent(text) { announceLiveRegion(text, 'urgent-announcements'); } /** * @param {string} id * @param {string} peerId * @param {string} dest * @param {string} nick * @param {Date} time * @param {boolean} privileged * @param {boolean} history * @param {string} kind * @param {string|HTMLElement} message */ function addToChatbox(id, 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(id) container.dataset.id = id; if(peerId) { container.dataset.peerId = peerId; container.dataset.username = nick; } /** @type{HTMLElement} */ let body; if(message instanceof HTMLElement) { body = message; } else if(typeof message === 'string') { body = formatText(message); } else { throw new Error('Cannot add element to chatbox'); } if(kind !== 'me') { let doHeader = true; if(lastMessage.nick !== (nick || null) || lastMessage.peerId !== (peerId || null) || lastMessage.dest !== (dest || null) || !time || !lastMessage.time) { doHeader = true; } else { let delta = time.getTime() - lastMessage.time.getTime(); doHeader = delta < 0 || delta > 60000; } if(doHeader) { let header = document.createElement('h3'); let user = document.createElement('span'); let u = dest && 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); } } let p = document.createElement('p'); p.appendChild(body); 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'); body.classList.add('message-me-content'); container.appendChild(asterisk); container.appendChild(user); container.appendChild(body); container.classList.add('message-me'); lastMessage = {}; } container.appendChild(footer); if(peerId && canModerateChat()) { let actions = document.createElement('button'); actions.type = 'button'; actions.classList.add('message-actions'); actions.setAttribute('aria-haspopup', 'menu'); actions.setAttribute('aria-expanded', 'false'); actions.setAttribute('aria-label', `Actions for message from ${nick || '(anon)'}`); actions.textContent = 'Actions'; actions.addEventListener('click', function(e) { e.stopPropagation(); chatMessageMenu(container, actions); }); footer.appendChild(actions); } let box = document.getElementById('box'); box.appendChild(row); if(box.scrollHeight > box.clientHeight) { box.scrollTop = box.scrollHeight - box.clientHeight; } // Announce new messages to screen readers (but not history) if(!history) { let messageText = typeof message === 'string' ? message : body.textContent; let speaker = nick; if(serverConnection && peerId === serverConnection.id) speaker = 'You'; announceChat(speaker ? `${speaker}: ${messageText}` : messageText); if(serverConnection && peerId && peerId !== serverConnection.id) playNotificationSound('chat-message'); } return; } /** * @param {HTMLElement} elt * @param {HTMLElement} trigger */ function chatMessageMenu(elt, trigger) { if(!canModerateChat()) return; let messageId = elt.dataset.id; let peerId = elt.dataset.peerId; if(!peerId) return; let username = elt.dataset.username; let u = username || 'user'; let items = []; if(messageId) items.push({label: 'Delete message', onClick: () => { serverConnection.hallAction('clearchat', { id: messageId, userId: peerId, }); }}); items.push({label: `Delete all from ${u}`, onClick: () => { serverConnection.hallAction('clearchat', { userId: peerId, }); }}); items.push({label: `Identify ${u}`, onClick: () => { serverConnection.userAction('identify', peerId); }}); items.push({label: `Kick out ${u}`, onClick: () => { serverConnection.userAction('kick', peerId); }}); openActionMenu(items, trigger, `Actions for message from ${u}`); } /** * @param {string|HTMLElement} message */ function localMessage(message) { return addToChatbox(null, null, null, 'server', new Date(), false, false, '', message); } /** * @param {string} [id] * @param {string} [userId] */ function clearChat(id, userId) { lastMessage = {}; let box = document.getElementById('box'); if(!id && !userId) { box.textContent = ''; return; } let elts = box.children; let i = 0; while(i < elts.length) { let row = elts.item(i); if(row instanceof HTMLDivElement) { let div = row.firstChild; if(div instanceof HTMLDivElement) if((!id || div.dataset.id === id) && div.dataset.peerId === userId) { box.removeChild(row); continue; } } i++; } } /** * 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.} */ let commands = {}; function operatorPredicate() { if(serverConnection && serverConnection.permissions && serverConnection.permissions.indexOf('op') >= 0) return null; return 'You are not an operator'; } function chalkboardGrantPredicate() { if(serverConnection && serverConnection.permissions && (serverConnection.permissions.indexOf('op') >= 0 || serverConnection.permissions.indexOf('admin') >= 0)) return null; return 'You are not allowed to grant chalkboard editing'; } function recordingPredicate() { if(canRecordHall()) 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}`); } let shortcuts = '\n\nKeyboard shortcuts:\n' + 'Control+Alt+H or Alt+Shift+H: Raise or lower hand\n' + 'Control+Alt+C or Alt+Shift+C: Expand or collapse chat\n' + 'Control+Alt+T or Alt+Shift+T: Mute or unmute microphone'; localMessage(cs.sort().join('\n') + shortcuts); } }; 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 hall", 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.hallAction('clearchat'); } }; commands.lock = { predicate: operatorPredicate, description: 'lock this hall', parameters: '[message]', f: (c, r) => { serverConnection.hallAction('lock', r); } }; commands.unlock = { predicate: operatorPredicate, description: 'unlock this hall, revert the effect of /lock', f: (c, r) => { serverConnection.hallAction('unlock'); } }; commands.raisehand = { description: 'raise your hand', f: (c, r) => { if(!serverConnection || !serverConnection.id) throw new Error('Not connected'); serverConnection.userAction('setdata', serverConnection.id, {'raisehand': true}); } }; commands.unraisehand = { description: 'lower your hand', f: (c, r) => { if(!serverConnection || !serverConnection.id) throw new Error('Not connected'); serverConnection.userAction('setdata', serverConnection.id, {'raisehand': null}); } }; commands.record = { predicate: recordingPredicate, description: 'start recording', f: (c, r) => { serverConnection.hallAction('record'); } }; commands.unrecord = { predicate: recordingPredicate, description: 'stop recording', f: (c, r) => { serverConnection.hallAction('unrecord'); } }; commands.subhalls = { predicate: operatorPredicate, description: 'list subhalls', f: (c, r) => { serverConnection.hallAction('subhalls'); } }; /** * @type {Object} */ const units = { s: 1000, min: 60 * 1000, h: 60 * 60 * 1000, d: 24 * 60 * 60 * 1000, mon: 31 * 24 * 60 * 60 * 1000, yr: 365 * 24 * 60 * 60 * 1000, }; /** * @param {string} s * @returns {Date|number} */ function parseExpiration(s) { if(!s) return null; let re = /^([0-9]+)(s|min|h|d|yr)$/ let e = re.exec(s) if(e) { let unit = units[e[2]]; if(!unit) throw new Error(`Couldn't find unit ${e[2]}`); return parseInt(e[1]) * unit; } let d = new Date(s); if(d.toString() === 'Invalid Date') throw new Error("Couldn't parse expiration date"); return d; } function makeTokenPredicate() { return (serverConnection.permissions.indexOf('token') < 0 ? "You don't have permission to create tokens" : null); } function editTokenPredicate() { return (serverConnection.permissions.indexOf('token') < 0 || serverConnection.permissions.indexOf('op') < 0 ? "You don't have permission to edit or list tokens" : null); } /** * @param {Object} [template] */ function makeToken(template) { if(!template) template = {}; let v = { hall: hall, } if('username' in template) v.username = template.username; if('expires' in template) v.expires = template.expires; else v.expires = units.d; if('not-before' in template) v["not-before"] = template["not-before"]; if('permissions' in template) v.permissions = template.permissions; else { v.permissions = []; if(serverConnection.permissions.indexOf('present') >= 0) v.permissions.push('present'); if(serverConnection.permissions.indexOf('message') >= 0) v.permissions.push('message'); } serverConnection.hallAction('maketoken', v); } commands.invite = { predicate: makeTokenPredicate, description: "create an invitation link", parameters: "[username] [expiration]", f: (c, r) => { let p = parseCommand(r); let template = {}; if(p[0]) template.username = p[0]; let expires = parseExpiration(p[1]); if(expires) template.expires = expires; makeToken(template); } } /** * @param {string} t */ function parseToken(t) { let m = /^https?:\/\/.*?token=([^?]+)/.exec(t); if(m) { return m[1]; } else if(!/^https?:\/\//.exec(t)) { return t } else { throw new Error("Couldn't parse link"); } } commands.reinvite = { predicate: editTokenPredicate, description: "extend an invitation link", parameters: "link [expiration]", f: (c, r) => { let p = parseCommand(r); let v = {} v.token = parseToken(p[0]); if(p[1]) v.expires = parseExpiration(p[1]); else v.expires = units.d; serverConnection.hallAction('edittoken', v); } } commands.revoke = { predicate: editTokenPredicate, description: "revoke an invitation link", parameters: "link", f: (c, r) => { let token = parseToken(r); serverConnection.hallAction('edittoken', { token: token, expires: -units.s, }); } } commands.listtokens = { predicate: editTokenPredicate, description: "list invitation links", f: (c, r) => { serverConnection.hallAction('listtokens'); } } 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: 'restart audio connections', 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, null, id, serverConnection.username, new Date(), 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.identify = { parameters: 'user [message]', description: 'identify 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.shutup = { parameters: 'user', description: 'revoke the right to send chat messages', predicate: operatorPredicate, f: userCommand, }; commands.unshutup = { parameters: 'user', description: 'give the right to send chat messages', predicate: operatorPredicate, f: userCommand, }; commands.chalkboard = { parameters: 'user', description: 'give the right to edit the virtual chalkboard', predicate: chalkboardGrantPredicate, f: userCommand, }; commands.unchalkboard = { parameters: 'user', description: 'revoke the right to edit the virtual chalkboard', predicate: chalkboardGrantPredicate, 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}, ); } } /** * @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 { serverConnection.sendFile(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} */ 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( `Unknown 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(); } }; document.getElementById('chalkboard').addEventListener('input', function(e) { if(applyingChalkboardUpdate) return; updateChalkboardCopyButton(); scheduleChalkboardSend(); }); document.getElementById('chalkboard').addEventListener('keydown', function(e) { if(e.key === 'Tab') return; }); document.getElementById('chalkboard').addEventListener('focus', function(e) { announcePendingChalkboardUpdate(); }); document.getElementById('chalkboard-copy').addEventListener('click', function(e) { e.preventDefault(); copyChalkboardToClipboard(); }); function updateChatResizerValue(leftPercent) { let resizer = document.getElementById('resizer'); if(!resizer) return; let rounded = Math.round(leftPercent); resizer.setAttribute('aria-valuenow', rounded.toString()); resizer.setAttribute('aria-valuetext', `Chat width ${rounded} percent`); } function updateChatResizerValueFromLayout() { let mainrow = document.getElementById("mainrow"); let left = document.getElementById("left"); if(!mainrow || !left || mainrow.offsetWidth === 0) return; updateChatResizerValue(left.offsetWidth * 100 / mainrow.offsetWidth); } function setChatWidth(leftPercent) { let full_width = document.getElementById("mainrow").offsetWidth; let left = document.getElementById("left"); let right = document.getElementById("right"); let min_left_width = 300 * 100 / full_width; if(leftPercent < min_left_width) return false; left.style.flex = leftPercent.toString(); right.style.flex = (100 - leftPercent).toString(); updateChatResizerValue(leftPercent); return true; } function chatResizer(e) { e.preventDefault(); let full_width = document.getElementById("mainrow").offsetWidth; let left = document.getElementById("left"); 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; setChatWidth(left_width); } 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); // Add keyboard support for chat resizer document.getElementById('resizer').addEventListener('keydown', function(e) { if(e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); let full_width = document.getElementById("mainrow").offsetWidth; let left = document.getElementById("left"); let current_width = left.offsetWidth; let step = 20; // pixels per keypress let new_width = e.key === 'ArrowLeft' ? current_width - step : current_width + step; let left_percent = new_width * 100 / full_width; setChatWidth(left_percent); } }, false); /** * @param {unknown} message * @param {string} [level] */ function displayError(message, level) { if(!level) level = "error"; let text = String(message); let position = 'center'; let gravity = 'top'; switch(level) { case "info": position = 'right'; gravity = 'bottom'; break; case "warning": break; case "kicked": level = "error"; break; } if(level === "info") announceChat(text); else announceUrgent(text); /** @ts-ignore */ Toastify({ text: text, duration: 8000, // Increased from 4000 for screen reader users close: true, position: position, gravity: gravity, className: level, }).showToast(); } /** * @param {unknown} message */ function displayWarning(message) { return displayError(message, "warning"); } /** * @param {unknown} message */ function displayMessage(message) { return displayError(message, "info"); } document.getElementById('loginform').onsubmit = async function(e) { e.preventDefault(); let form = this; if(!(form instanceof HTMLFormElement)) throw new Error('Bad type for loginform'); setVisibility('passwordform', true); presentRequested = 'mike'; // Connect to the server, gotConnected will join. serverConnect(); }; document.getElementById('disconnectbutton').onclick = function(e) { serverConnection.close(); closeNav(); }; /** * @param {HTMLElement} elt * @param {boolean} hidden */ function setAccessibleHidden(elt, hidden) { if(hidden) { elt.setAttribute('aria-hidden', 'true'); elt.inert = true; } else { elt.removeAttribute('aria-hidden'); elt.inert = false; } } function openNav() { let sidebarnav = document.getElementById("sidebarnav"); let openside = document.getElementById("openside"); let closeside = document.getElementById("closeside"); sidebarnav.style.width = "250px"; setAccessibleHidden(sidebarnav, false); openside.setAttribute("aria-expanded", "true"); if(closeside) closeside.focus(); } function closeNav() { let sidebarnav = document.getElementById("sidebarnav"); let openside = document.getElementById("openside"); sidebarnav.style.width = "0"; setAccessibleHidden(sidebarnav, true); openside.setAttribute("aria-expanded", "false"); if(sidebarnav.contains(document.activeElement)) openside.focus(); } function toggleNav() { let sidebarnav = document.getElementById("sidebarnav"); if(sidebarnav.getAttribute("aria-hidden") === "true") openNav(); else closeNav(); } function setLeftPanelExpanded(expanded) { let sidebar = document.getElementById("left-sidebar"); let mainrow = document.getElementById("mainrow"); let sidebarCollapse = document.getElementById("sidebarCollapse"); sidebar.classList.toggle("active", !expanded); mainrow.classList.toggle("full-width-active", !expanded); sidebarCollapse.setAttribute("aria-expanded", expanded ? "true" : "false"); setAccessibleHidden(sidebar, !expanded); if(!expanded && sidebar.contains(document.activeElement)) sidebarCollapse.focus(); } function toggleLeftPanel() { let sidebarCollapse = document.getElementById("sidebarCollapse"); setLeftPanelExpanded( sidebarCollapse.getAttribute("aria-expanded") !== "true", ); } function setChatPanelVisible(visible) { let chatPanel = document.getElementById('left'); setVisibility('left', visible); if(chatPanel) setAccessibleHidden(chatPanel, !visible); } document.getElementById('sidebarCollapse').onclick = function(e) { e.preventDefault(); toggleLeftPanel(); }; document.getElementById('openside').onclick = function(e) { e.preventDefault(); toggleNav(); }; document.getElementById('closeside').onclick = function(e) { e.preventDefault(); closeNav(); }; document.getElementById('close-chat').onclick = function(e) { e.preventDefault(); setChatPanelVisible(false); setVisibility('show-chat', true); resizePeers(); document.getElementById('show-chat').focus(); }; document.getElementById('show-chat').onclick = function(e) { e.preventDefault(); setChatPanelVisible(true); setVisibility('show-chat', false); resizePeers(); document.getElementById('close-chat').focus(); }; async function serverConnect() { if(serverConnection && serverConnection.socket) serverConnection.close(); serverConnection = new ServerConnection(); serverConnection.onconnected = gotConnected; serverConnection.onerror = function(e) { console.error(e); displayError(e.toString()); }; serverConnection.onpeerconnection = onPeerConnection; serverConnection.onclose = gotClose; serverConnection.ondownstream = gotDownStream; serverConnection.onuser = gotUser; serverConnection.onjoined = gotJoined; serverConnection.onchat = addToChatbox; serverConnection.onusermessage = gotUserMessage; serverConnection.onfiletransfer = gotFileTransfer; let url = hallStatus.endpoint; if(!url) { console.warn("no endpoint in status"); 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() { try { let r = await fetch(".status") if(!r.ok) throw new Error(`${r.status} ${r.statusText}`); hallStatus = await r.json() } catch(e) { console.error(e); displayWarning("Couldn't fetch status: " + e); hallStatus = {}; } if(hallStatus.name) { hall = hallStatus.name; } else { console.warn("no hall name in status"); hall = decodeURIComponent( location.pathname.replace(/^\/[a-z]*\//, '').replace(/\/$/, ''), ); } let parms = new URLSearchParams(window.location.search); if(window.location.search) window.history.replaceState(null, '', window.location.pathname); setTitle(hallStatus.displayName || capitalise(hall)); await setMediaChoices(false); reflectSettings(); if(parms.has('token')) token = parms.get('token'); if(token) { await serverConnect(); } else if(hallStatus.authPortal) { window.location.href = hallStatus.authPortal; } else { setVisibility('login-container', true); document.getElementById('username').focus() } setViewportHeight(); } function isGlobalShortcut(e, key) { return e.altKey && (e.ctrlKey || e.shiftKey) && e.key.toLowerCase() === key; } // Global keyboard shortcuts document.addEventListener('keydown', function(e) { // Control+Alt+H or Alt+Shift+H - Raise or lower hand if(isGlobalShortcut(e, 'h')) { e.preventDefault(); if(!serverConnection || !serverConnection.id) { return; } let mydata = serverConnection.users[serverConnection.id].data; let isRaised = mydata['raisehand']; // Toggle hand state serverConnection.userAction( 'setdata', serverConnection.id, {'raisehand': isRaised ? null : true} ); // Focus on user's name button in the user list (delayed to ensure announcement is read) setTimeout(function() { let userButton = document.getElementById('user-' + serverConnection.id); if(userButton) { userButton.focus(); } }, 100); } // Control+Alt+C or Alt+Shift+C - Expand or collapse chat if(isGlobalShortcut(e, 'c')) { e.preventDefault(); let leftPanel = document.getElementById('left'); let showChatButton = document.getElementById('show-chat'); let closeChatButton = document.getElementById('close-chat'); let isChatVisible = leftPanel && !leftPanel.classList.contains('invisible'); if(isChatVisible) { // Collapse chat setChatPanelVisible(false); setVisibility('show-chat', true); resizePeers(); announceChat('Chat collapsed'); // Focus the show-chat button (delayed to ensure announcement is read) setTimeout(function() { if(showChatButton) { showChatButton.focus(); } }, 100); } else { // Expand chat setChatPanelVisible(true); setVisibility('show-chat', false); resizePeers(); announceChat('Chat expanded'); // Focus the close-chat button (delayed to ensure announcement is read) setTimeout(function() { if(closeChatButton) { closeChatButton.focus(); } }, 100); } } // Control+Alt+T or Alt+Shift+T - Mute or unmute microphone if(isGlobalShortcut(e, 't')) { e.preventDefault(); let muteButton = document.getElementById('mutebutton'); if(!muteButton) { return; } let localMute = getSettings().localMute; if (localMute && !findUpMedia('audio')) { displayMessage('Please use Enable to enable your microphone.'); return; } // Toggle mute state localMute = !localMute; setLocalMute(localMute, true); announceChat(localMute ? 'Microphone muted' : 'Microphone unmuted'); // Focus the mute button (delayed to ensure announcement is read) setTimeout(function() { muteButton.focus(); }, 100); } }); resetChalkboard(); start();