First pass at adding a waiting room.

This commit is contained in:
Storm Dragon
2026-08-30 16:56:26 -04:00
parent 9125e76d43
commit ae55982a40
17 changed files with 1254 additions and 100 deletions
+29 -3
View File
@@ -201,6 +201,10 @@ function ServerConnection() {
* @type{(this: ServerConnection, kind: string, hall: string, permissions: Array<string>, status: Object<string,any>, data: Object<string,any>, error: string, message: string) => void}
*/
this.onjoined = null;
/** Called for authenticated waiting-room state changes. */
this.onwaiting = null;
/** Called for operator-only waiting-list changes. */
this.onwaitinglist = null;
/**
* ondownstream is called whenever a new down stream is added. It
* should set up the stream's callbacks; actually setting up the UI
@@ -342,7 +346,7 @@ ServerConnection.prototype.connect = function(url) {
try {
sc.send({
type: 'handshake',
version: ['2'],
version: ['3'],
id: sc.id,
});
} catch(e) {
@@ -391,8 +395,8 @@ ServerConnection.prototype.connect = function(url) {
sc.lastServerMessage = new Date().valueOf();
switch(m.type) {
case 'handshake': {
if((m.version instanceof Array) && m.version.includes('2')) {
sc.version = '2';
if((m.version instanceof Array) && m.version.includes('3')) {
sc.version = '3';
} else {
sc.version = null;
sc.error(new Error(`Unknown protocol version ${m.version}`));
@@ -449,6 +453,21 @@ ServerConnection.prototype.connect = function(url) {
m.status, m.data,
m.error || null, m.value || null);
break;
case 'waiting':
if(m.kind === 'enter' && m.value) {
sc.username = m.value.username;
sc.permissions = [];
} else if(m.kind === 'leave' || m.kind === 'cancel') {
sc.username = null;
sc.permissions = [];
}
if(sc.onwaiting)
sc.onwaiting.call(sc, m.kind, m.value || null);
break;
case 'waiting-list':
if(sc.onwaitinglist)
sc.onwaitinglist.call(sc, m.kind, m.value || null);
break;
case 'user':
let user = null;
switch(m.kind) {
@@ -651,6 +670,13 @@ ServerConnection.prototype.leave = function(hall) {
});
};
ServerConnection.prototype.waitingAction = function(kind, value) {
let message = {type: 'waiting', kind: kind};
if(typeof value !== 'undefined')
message.value = value;
this.send(message);
};
/**
* request sets the list of requested tracks
*
+26 -1
View File
@@ -982,6 +982,8 @@ legend {
#left-sidebar {
min-width: 200px;
max-width: 200px;
display: flex;
flex-direction: column;
transition: all 0.3s;
background: #ffffff;
border-right: 1px solid #dcdcdc;
@@ -1035,7 +1037,8 @@ header .collapse:hover {
#users {
padding: 0;
margin: 0;
height: calc(100% - 84px);
min-height: 0;
flex: 1 1 auto;
width: 100%;
position: relative;
display: block;
@@ -1044,6 +1047,28 @@ header .collapse:hover {
border: 1px solid #f7f7f7;
}
#waiting-list-panel {
padding: 0.75rem;
border-top: 1px solid #ddd;
background: #fff;
max-height: 40%;
overflow-y: auto;
flex: 0 1 auto;
}
#waiting-list-panel h2 {
font-size: 1rem;
margin: 0 0 0.5rem;
}
.waiting-user {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin: 0.4rem 0;
}
#users .user-p {
display: block;
position: relative;
+16
View File
@@ -23,6 +23,10 @@
</div>
<div class="header-sep"></div>
<div id="users"></div>
<section id="waiting-list-panel" class="invisible" aria-labelledby="waiting-list-heading">
<h2 id="waiting-list-heading">Waiting room (0)</h2>
<div id="waiting-list"></div>
</section>
</nav>
<div class="container">
<header class="connected-only invisible" aria-hidden="true">
@@ -105,6 +109,18 @@
<div class="clear"></div>
</div>
</div>
<section id="waiting-room" class="login-container invisible" aria-labelledby="waiting-heading">
<div class="login-box">
<h1 id="waiting-heading" tabindex="-1">Waiting room</h1>
<p id="waiting-status"></p>
<p>
<input id="waiting-auto-join" type="checkbox"/>
<label for="waiting-auto-join">Join hall automatically when it unlocks</label>
</p>
<p><button id="waiting-join" type="button" class="btn btn-blue" disabled>Join hall</button></p>
<p><button id="waiting-leave" type="button" class="btn btn-default">Leave waiting room</button></p>
</div>
</section>
</div>
</div>
</div>
+158 -1
View File
@@ -71,6 +71,10 @@ let token = null;
*/
let probingState = null;
let waitingState = null;
let waitingCountdownTimer = null;
let waitingUsers = new Map();
/**
* @typedef {Object} settings - the type of stored settings
* @property {boolean} [localMute]
@@ -516,12 +520,138 @@ function setConnected(connected) {
} else {
closeNav();
userbox.classList.add('invisible');
connectionbox.classList.remove('invisible');
if(!waitingState)
connectionbox.classList.remove('invisible');
;
window.onresize = null;
}
}
function waitingHallName(state = waitingState) {
return state && state.displayName ? state.displayName :
(hallStatus.displayName || capitalise(hall));
}
function updateWaitingStatus(announceSchedule = false) {
if(!waitingState)
return;
let name = waitingHallName();
let status;
if(!waitingState.locked) {
status = `The ${name} hall is open. You may join now.`;
} else if(waitingState.deadline) {
let remaining = Math.max(1, Math.ceil(
(new Date(waitingState.deadline).getTime() - Date.now()) / 60000,
));
status = `The ${name} hall is locked. It is scheduled to unlock in ${remaining} minute${remaining === 1 ? '' : 's'}.`;
} else {
status = `The ${name} hall is locked. An operator is present; no unlock time is set.`;
}
document.getElementById('waiting-status').textContent = status;
getButtonElement('waiting-join').disabled = !!waitingState.locked;
if(announceSchedule)
announceChat(status);
}
function showWaitingRoom(state) {
waitingState = state;
token = null;
getInputElement('waiting-auto-join').checked = false;
document.getElementById('login-container').classList.add('invisible');
document.getElementById('waiting-room').classList.remove('invisible');
document.getElementById('waiting-heading').textContent =
`Waiting for the ${waitingHallName(state)} hall`;
updateWaitingStatus(false);
if(waitingCountdownTimer)
clearInterval(waitingCountdownTimer);
waitingCountdownTimer = setInterval(() => updateWaitingStatus(false), 30000);
document.getElementById('waiting-heading').focus({preventScroll: true});
announceChat(`You are in the waiting room for the ${waitingHallName(state)} hall.`);
}
function hideWaitingRoom() {
if(waitingCountdownTimer) {
clearInterval(waitingCountdownTimer);
waitingCountdownTimer = null;
}
waitingState = null;
document.getElementById('waiting-room').classList.add('invisible');
}
function gotWaiting(kind, state) {
switch(kind) {
case 'enter':
showWaitingRoom(state);
break;
case 'update':
case 'open': {
let oldDeadline = waitingState && waitingState.deadline;
let wasLocked = waitingState && waitingState.locked;
waitingState = state;
updateWaitingStatus(oldDeadline !== state.deadline || wasLocked !== state.locked);
break;
}
case 'approved':
waitingState = state;
announceChat(`An operator admitted you to the ${waitingHallName(state)} hall.`);
break;
case 'cancel':
hideWaitingRoom();
announceUrgent(state || 'Waiting ended.');
this.close();
break;
case 'leave':
hideWaitingRoom();
this.close();
break;
default:
console.warn(`Unknown waiting-room event ${kind}`);
}
}
function renderWaitingList() {
let panel = document.getElementById('waiting-list-panel');
let list = document.getElementById('waiting-list');
let isOperator = serverConnection &&
serverConnection.permissions.indexOf('op') >= 0;
let visible = isOperator && !!hallStatus.locked;
panel.classList.toggle('invisible', !visible);
document.getElementById('waiting-list-heading').textContent =
`Waiting room (${waitingUsers.size})`;
list.replaceChildren();
if(!visible)
return;
for(let user of waitingUsers.values()) {
let row = document.createElement('div');
row.className = 'waiting-user';
let name = document.createElement('span');
name.textContent = user.username + (user.approved ? ' (admission approved)' : '');
let button = document.createElement('button');
button.type = 'button';
button.textContent = user.approved ? 'Approved' : 'Admit';
button.disabled = !!user.approved;
button.setAttribute('aria-label', `Admit ${user.username}`);
button.onclick = () => serverConnection.waitingAction('admit', user.username);
row.append(name, button);
list.appendChild(row);
}
}
function gotWaitingList(kind, value) {
if(kind === 'snapshot') {
waitingUsers.clear();
for(let user of value || [])
waitingUsers.set(user.username, user);
} else if(kind === 'add' && value) {
waitingUsers.set(value.username, value);
announceChat(`${value.username} entered the waiting room.`);
} else if(kind === 'remove' && value) {
waitingUsers.delete(value.username);
announceChat(`${value.username} left the waiting room.`);
}
renderWaitingList();
}
/**
* Called when we connect to the server.
*
@@ -635,6 +765,7 @@ function onPeerConnection() {
function gotClose(code, reason) {
closeUpMedia();
closeSafariStream();
hideWaitingRoom();
setConnected(false);
resetChalkboard();
if(code != 1000) {
@@ -2211,6 +2342,9 @@ async function gotJoined(kind, hall, perms, status, data, error, message) {
} else {
token = null;
}
if(kind === 'join' && waitingState)
announceChat(`Joining the ${waitingHallName()} hall.`);
hideWaitingRoom();
// don't discard endPoint and friends
hallStatus.locked = false;
hallStatus.recording = false;
@@ -2225,6 +2359,7 @@ async function gotJoined(kind, hall, perms, status, data, error, message) {
setChangePassword(pwAuth && !!hallStatus.canChangePassword &&
serverConnection.username
);
renderWaitingList();
openSafariStream();
window.setTimeout(() => {
userNotificationSoundsReady = true;
@@ -2273,6 +2408,14 @@ async function gotJoined(kind, hall, perms, status, data, error, message) {
);
}
}
let displayName = hallStatus.displayName || capitalise(hall);
let microphone = findUpMedia('audio');
if(!microphone) {
announceChat(`Joined the ${displayName} hall. Your microphone is not enabled.`);
} else {
let enabled = microphone.stream && microphone.stream.getAudioTracks().some(track => track.enabled);
announceChat(`Joined the ${displayName} hall. Your microphone is ${enabled ? 'unmuted' : 'muted'}.`);
}
}
/**
@@ -4069,6 +4212,18 @@ document.getElementById('disconnectbutton').onclick = function(e) {
closeNav();
};
getInputElement('waiting-auto-join').onchange = function() {
serverConnection.waitingAction('auto', this.checked);
};
getButtonElement('waiting-join').onclick = function() {
serverConnection.waitingAction('join');
};
getButtonElement('waiting-leave').onclick = function() {
serverConnection.waitingAction('leave');
};
/**
* @param {HTMLElement} elt
* @param {boolean} hidden
@@ -4186,6 +4341,8 @@ async function serverConnect() {
serverConnection.ondownstream = gotDownStream;
serverConnection.onuser = gotUser;
serverConnection.onjoined = gotJoined;
serverConnection.onwaiting = gotWaiting;
serverConnection.onwaitinglist = gotWaitingList;
serverConnection.onchat = addToChatbox;
serverConnection.onusermessage = gotUserMessage;
serverConnection.onfiletransfer = gotFileTransfer;