Files
2026-07-09 11:15:35 -04:00

4259 lines
116 KiB
JavaScript

// 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.<number>} [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<string,Array<string>>}
*/
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<string>}
*/
function mapRequestLabel(what, label) {
let r = mapRequest(what);
if(label in r)
return r[label];
else
return r[''];
}
getSelectElement('requestselect').onchange = function(e) {
e.preventDefault();
if(!(this instanceof HTMLSelectElement))
throw new Error('Unexpected type for this');
updateSettings({request: this.value});
serverConnection.request(mapRequest(this.value));
reconsiderDownRate();
};
const activityDetectionInterval = 200;
const activityDetectionPeriod = 700;
const activityDetectionThreshold = 0.2;
getInputElement('activitybox').onchange = function(e) {
if(!(this instanceof HTMLInputElement))
throw new Error('Unexpected type for this');
updateSettings({activityDetection: this.checked});
for(let id in serverConnection.down) {
let c = serverConnection.down[id];
if(this.checked)
c.setStatsInterval(activityDetectionInterval);
else {
c.setStatsInterval(0);
setActive(c, false);
}
}
};
/**
* @this {Stream}
* @param {Object<string,any>} stats
*/
function gotUpStats(stats) {
let c = this;
let values = [];
for(let id in stats) {
if(stats[id] && stats[id]['outbound-rtp']) {
let rate = stats[id]['outbound-rtp'].rate;
if(typeof rate === 'number') {
values.push(rate);
}
}
}
if(values.length === 0) {
setLabel(c, '');
} else {
values.sort((x,y) => x - y);
setLabel(c, values
.map(x => Math.round(x / 1000).toString())
.reduce((x, y) => x + '+' + y));
}
}
/**
* @param {Stream} c
* @param {boolean} value
*/
function setActive(c, value) {
let peer = document.getElementById('peer-' + c.localId);
if(!peer)
return;
if(value)
peer.classList.add('peer-active');
else
peer.classList.remove('peer-active');
}
/**
* @this {Stream}
* @param {Object<string,any>} stats
*/
function gotDownStats(stats) {
if(!getInputElement('activitybox').checked)
return;
let c = this;
let maxEnergy = 0;
c.pc.getReceivers().forEach(r => {
let tid = r.track && r.track.id;
let s = tid && stats[tid];
let energy = s && s['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<Stream>}
*/
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<string>} perms
* @param {Object<string,any>} status
* @param {Object<string,any>} 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.<string, {queue: string[], active: boolean}>} */
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.<string,command>}
*/
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<string,number>}
*/
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<number>}
*/
async function relayTest() {
if(!serverConnection)
throw new Error('not connected');
let conf = Object.assign({}, serverConnection.getRTCConfiguration());
conf.iceTransportPolicy = 'relay';
let pc1 = new RTCPeerConnection(conf);
let pc2 = new RTCPeerConnection(conf);
pc1.onicecandidate = e => {e.candidate && pc2.addIceCandidate(e.candidate);};
pc2.onicecandidate = e => {e.candidate && pc1.addIceCandidate(e.candidate);};
try {
return await new Promise(async (resolve, reject) => {
let d1 = pc1.createDataChannel('loopbackTest');
d1.onopen = e => {
d1.send(Date.now().toString());
};
let offer = await pc1.createOffer();
await pc1.setLocalDescription(offer);
await pc2.setRemoteDescription(pc1.localDescription);
let answer = await pc2.createAnswer();
await pc2.setLocalDescription(answer);
await pc1.setRemoteDescription(pc2.localDescription);
pc2.ondatachannel = e => {
let d2 = e.channel;
d2.onmessage = e => {
let t = parseInt(e.data);
if(isNaN(t))
reject(new Error('corrupt data'));
else
resolve(Date.now() - t);
}
}
setTimeout(() => reject(new Error('timeout')), 5000);
})
} finally {
pc1.close();
pc2.close();
}
}
commands['relay-test'] = {
f: async (c, r) => {
localMessage('Relay test in progress...');
try {
let s = Date.now();
let rtt = await relayTest();
let e = Date.now();
localMessage(`Relay test successful in ${e-s}ms, RTT ${rtt}ms`);
} catch(e) {
localMessage(`Relay test failed: ${e}`);
}
}
}
function handleInput() {
let input = /** @type {HTMLTextAreaElement} */
(document.getElementById('input'));
let data = input.value;
input.value = '';
let message, me;
if(data === '')
return;
if(data[0] === '/') {
if(data.length > 1 && data[1] === '/') {
message = data.slice(1);
me = false;
} else {
let cmd, rest;
let space = data.indexOf(' ');
if(space < 0) {
cmd = data.slice(1);
rest = '';
} else {
cmd = data.slice(1, space);
rest = data.slice(space + 1);
}
if(cmd === 'me') {
message = rest;
me = true;
} else {
let c = commands[cmd];
if(!c) {
displayError(
`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();