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
@@ -2,7 +2,7 @@
# shellcheck shell=bash disable=SC2034,SC2154
pkgname=skald-git
pkgver=2026.07.08.r2.g6a94f89
pkgver=2026.07.08.r3.g9125e76
pkgrel=1
pkgdesc='Audio-only hall-based conferencing server'
arch=('x86_64' 'aarch64')
+6
View File
@@ -133,6 +133,12 @@ type ClientCredentials struct {
Token string
}
// AuthenticatedClient is the credential-free result of authentication.
type AuthenticatedClient struct {
Username string
Permissions []string
}
type Client interface {
Hall() *Hall
Addr() net.Addr
+142 -83
View File
@@ -105,6 +105,7 @@ type Hall struct {
description *Description
locked *string
clients map[string]Client
reserved map[string]Client
history []ChatHistoryEntry
timestamp time.Time
data map[string]interface{}
@@ -232,6 +233,37 @@ func (g *Hall) ClientCount() int {
return g.clientCountUnlocked()
}
// ReserveUsernameWhileWaiting atomically checks whether waiting is available
// and, if so, reserves a username for an authenticated non-member.
func (g *Hall) ReserveUsernameWhileWaiting(username string, client Client) (bool, error) {
g.mu.Lock()
defer g.mu.Unlock()
if g.locked == nil || !g.hasOperatorUnlocked() {
return false, nil
}
for _, c := range g.clients {
if !isSystemClient(c) && c.Username() == username {
return true, UserError("username already in use")
}
}
if holder := g.reserved[username]; holder != nil && holder != client {
return true, UserError("username already in use")
}
if g.reserved == nil {
g.reserved = make(map[string]Client)
}
g.reserved[username] = client
return true, nil
}
func (g *Hall) ReleaseUsername(username string, client Client) {
g.mu.Lock()
defer g.mu.Unlock()
if g.reserved[username] == client {
delete(g.reserved, username)
}
}
func (g *Hall) clientCountUnlocked() int {
count := 0
for _, c := range g.clients {
@@ -252,6 +284,9 @@ func (g *Hall) mayExpire() bool {
if len(g.clients) > 0 {
return false
}
if len(g.reserved) > 0 {
return false
}
return time.Since(g.timestamp) > maxHistoryAge(g.description)
}
@@ -578,7 +613,7 @@ func Delete(name string) bool {
// Called with both halls.mu and g.mu taken.
func deleteUnlocked(g *Hall) bool {
if len(g.clients) != 0 {
if len(g.clients) != 0 || len(g.reserved) != 0 {
return false
}
@@ -599,112 +634,136 @@ func isSystemClient(c Client) bool {
return member("system", c.Permissions())
}
func AddClient(hall string, c Client, creds ClientCredentials) (*Hall, error) {
g, err := Add(hall, nil)
func AuthenticateClient(name string, creds ClientCredentials) (*Hall, AuthenticatedClient, error) {
g, err := Add(name, nil)
if err != nil {
return nil, err
return nil, AuthenticatedClient{}, err
}
g.mu.Lock()
defer g.mu.Unlock()
username, perms, err := g.getPermission(creds)
if err != nil {
return nil, AuthenticatedClient{}, err
}
return g, AuthenticatedClient{username, append([]string(nil), perms...)}, nil
}
// AddAuthenticatedClient admits a previously authenticated non-system client.
// A lock bypass does not bypass time restrictions or capacity.
func AddAuthenticatedClient(g *Hall, c Client, auth AuthenticatedClient, bypassLock bool) error {
if g == nil {
return os.ErrNotExist
}
g.mu.Lock()
defer g.mu.Unlock()
clients := g.getClientsUnlocked(nil)
systemClient := isSystemClient(c)
if !systemClient {
username, perms, err := g.getPermission(creds)
if err != nil {
return nil, err
if holder := g.reserved[auth.Username]; holder != nil && holder != c {
return UserError("username already in use")
}
c.SetUsername(auth.Username)
c.SetPermissions(auth.Permissions)
for _, cc := range clients {
if !isSystemClient(cc) && cc.Username() == auth.Username {
return UserError("username already in use")
}
c.SetUsername(username)
c.SetPermissions(perms)
for _, cc := range clients {
if !isSystemClient(cc) && cc.Username() == username {
return nil, UserError("username already in use")
}
if !member("op", auth.Permissions) {
if !bypassLock && g.locked != nil {
m := *g.locked
if m == "" {
m = "Hall is locked."
}
return UserError(m)
}
now := time.Now()
if g.description.NotBefore != nil && g.description.NotBefore.After(now) {
return UserError("this hall is not open yet")
}
if g.description.Expires != nil && g.description.Expires.Before(now) {
return UserError("this hall is closed")
}
if g.description.Autokick {
ops := false
for _, existing := range clients {
if member("op", existing.Permissions()) {
ops = true
break
}
}
if !ops {
return UserError("there are no operators in this hall")
}
}
if !member("op", perms) {
if g.locked != nil {
m := *g.locked
if m == "" {
m = "Hall is locked."
}
return nil, UserError(m)
}
if g.description.NotBefore != nil ||
g.description.Expires != nil {
now := time.Now()
if g.description.NotBefore != nil &&
g.description.NotBefore.After(now) {
return nil, UserError(
"this hall is not open yet",
)
}
if g.description.Expires != nil &&
g.description.Expires.Before(now) {
return nil, UserError(
"this hall is closed",
)
}
}
if g.description.Autokick {
ops := false
for _, c := range clients {
if member("op", c.Permissions()) {
ops = true
break
}
}
if !ops {
return nil, UserError(
"there are no operators " +
"in this hall",
)
}
}
}
if !member("op", perms) && g.description.MaxClients > 0 {
if g.clientCountUnlocked() >= g.description.MaxClients {
return nil, UserError("too many users")
}
if g.description.MaxClients > 0 && g.clientCountUnlocked() >= g.description.MaxClients {
return UserError("too many users")
}
}
id := c.Id()
if id == "" {
return nil, errors.New("client has empty id")
return errors.New("client has empty id")
}
if g.clients[id] != nil {
return nil, ProtocolError("duplicate client id")
return ProtocolError("duplicate client id")
}
g.clients[id] = c
delete(g.reserved, auth.Username)
g.timestamp = time.Now()
c.Joined(g.Name(), "join")
u := c.Username()
p := c.Permissions()
s := c.Data()
if !systemClient {
c.PushClient(g.Name(), "add", c.Id(), u, p, s)
}
u, p, s := c.Username(), c.Permissions(), c.Data()
c.PushClient(g.Name(), "add", id, u, p, s)
for _, cc := range clients {
pp := cc.Permissions()
uu := cc.Username()
if !isSystemClient(cc) {
c.PushClient(
g.Name(), "add", cc.Id(), uu, pp, cc.Data(),
)
c.PushClient(g.Name(), "add", cc.Id(), cc.Username(), cc.Permissions(), cc.Data())
}
if !systemClient {
cc.PushClient(g.Name(), "add", id, u, p, s)
cc.PushClient(g.Name(), "add", id, u, p, s)
}
return nil
}
func AddClient(name string, c Client, creds ClientCredentials) (*Hall, error) {
if creds.System || isSystemClient(c) {
g, err := Add(name, nil)
if err != nil {
return nil, err
}
g.mu.Lock()
defer g.mu.Unlock()
if c.Id() == "" {
return nil, errors.New("client has empty id")
}
if g.clients[c.Id()] != nil {
return nil, ProtocolError("duplicate client id")
}
g.clients[c.Id()] = c
g.timestamp = time.Now()
c.Joined(g.Name(), "join")
return g, nil
}
g, auth, err := AuthenticateClient(name, creds)
if err != nil {
return nil, err
}
if err := AddAuthenticatedClient(g, c, auth, false); err != nil {
return nil, err
}
return g, nil
}
// WaitingStatus atomically reports the lock and active-operator state.
func (g *Hall) WaitingStatus() (bool, bool) {
g.mu.Lock()
defer g.mu.Unlock()
return g.locked != nil, g.hasOperatorUnlocked()
}
// called locked
func (g *Hall) hasOperatorUnlocked() bool {
for _, c := range g.clients {
if !isSystemClient(c) && member("op", c.Permissions()) {
return true
}
}
return g, nil
return false
}
// called locked
+32
View File
@@ -42,6 +42,20 @@ var hallUnlockSchedules = struct {
entries: make(map[*hall.Hall]*scheduledHallUnlock),
}
func scheduledUnlockDeadline(g *hall.Hall) (time.Time, bool) {
hallUnlockSchedules.Lock()
entry := hallUnlockSchedules.entries[g]
if entry == nil {
hallUnlockSchedules.Unlock()
return time.Time{}, false
}
entry.mu.Lock()
deadline, active := entry.deadline, entry.active
entry.mu.Unlock()
hallUnlockSchedules.Unlock()
return deadline, active
}
func scheduledUnlockRequestFromValue(value interface{}) (scheduledUnlockRequest, error) {
v, ok := value.(map[string]interface{})
if !ok {
@@ -265,6 +279,7 @@ func scheduleHallUnlock(g *hall.Hall, issuer *webClient, deadline, now time.Time
broadcastHallInfo(g, message, true)
}
entry.mu.Unlock()
waitingHallChanged(g)
return nil
}
@@ -310,6 +325,7 @@ func finishScheduledUnlock(entry *scheduledHallUnlock) {
}
entry.mu.Unlock()
hallUnlockSchedules.Unlock()
waitingHallChanged(entry.hall)
}
func setHallLockState(g *hall.Hall, locked bool, message string) bool {
@@ -329,6 +345,7 @@ func setHallLockState(g *hall.Hall, locked bool, message string) bool {
broadcastHallInfo(g, "Hall unlocked", false)
}
hallUnlockSchedules.Unlock()
waitingHallChanged(g)
return entry != nil
}
@@ -343,6 +360,21 @@ func cancelScheduledHallUnlock(g *hall.Hall) bool {
return entry != nil
}
func cancelScheduledUnlockByIssuer(g *hall.Hall, issuer *webClient) bool {
hallUnlockSchedules.Lock()
entry := hallUnlockSchedules.entries[g]
if entry == nil || entry.issuer != issuer {
hallUnlockSchedules.Unlock()
return false
}
delete(hallUnlockSchedules.entries, g)
deactivateScheduledUnlock(entry)
hallUnlockSchedules.Unlock()
broadcastHallInfo(g, "Scheduled hall unlock cancelled because the issuing operator is no longer present as an operator.", true)
waitingHallChanged(g)
return true
}
func deactivateScheduledUnlock(entry *scheduledHallUnlock) {
entry.mu.Lock()
entry.active = false
+330
View File
@@ -0,0 +1,330 @@
package rtpconn
import (
"errors"
"sync"
"time"
"git.stormux.org/storm/skald/hall"
)
type waitingEntry struct {
client *webClient
hall *hall.Hall
auth hall.AuthenticatedClient
autoJoin bool
approved bool
manualJoin bool
}
type waitingEventAction struct {
message clientMessage
}
type waitingState struct {
Hall string `json:"hall"`
DisplayName string `json:"displayName"`
Username string `json:"username"`
Locked bool `json:"locked"`
Deadline string `json:"deadline,omitempty"`
}
type waitingUser struct {
Username string `json:"username"`
Approved bool `json:"approved,omitempty"`
}
var waitingRooms = struct {
sync.Mutex
entries map[*hall.Hall][]*waitingEntry
}{
entries: make(map[*hall.Hall][]*waitingEntry),
}
var errWaitingUnavailable = errors.New("waiting room unavailable")
func waitingDisplayName(g *hall.Hall) string {
name := g.Status(true, nil).DisplayName
if name == "" {
name = g.Name()
}
return name
}
func currentWaitingState(g *hall.Hall, username string) waitingState {
locked, _ := g.Locked()
state := waitingState{
Hall: g.Name(), DisplayName: waitingDisplayName(g),
Username: username, Locked: locked,
}
if deadline, ok := scheduledUnlockDeadline(g); ok {
state.Deadline = deadline.Format(time.RFC3339)
}
return state
}
func sendWaiting(c *webClient, kind string, value interface{}) {
c.action(waitingEventAction{clientMessage{
Type: "waiting", Kind: kind, Value: value,
}})
}
func sendWaitingList(c *webClient, kind string, value interface{}) {
c.action(waitingEventAction{clientMessage{
Type: "waiting-list", Kind: kind, Value: value,
}})
}
func waitingSnapshotLocked(g *hall.Hall) []waitingUser {
entries := waitingRooms.entries[g]
users := make([]waitingUser, 0, len(entries))
for _, entry := range entries {
users = append(users, waitingUser{entry.auth.Username, entry.approved})
}
return users
}
func waitingClientHall(c *webClient) *hall.Hall {
waitingRooms.Lock()
defer waitingRooms.Unlock()
if c.waiting == nil {
return nil
}
return c.waiting.hall
}
func notifyWaitingOperators(g *hall.Hall, kind string, value interface{}) {
for _, client := range g.GetClients(nil) {
c, ok := client.(*webClient)
if ok && member("op", c.Permissions()) {
sendWaitingList(c, kind, value)
}
}
}
func addWaitingClient(c *webClient, g *hall.Hall, auth hall.AuthenticatedClient) error {
waitingRooms.Lock()
offered, err := g.ReserveUsernameWhileWaiting(auth.Username, c)
if err != nil {
waitingRooms.Unlock()
return err
}
if !offered {
waitingRooms.Unlock()
return errWaitingUnavailable
}
entry := &waitingEntry{client: c, hall: g, auth: auth}
waitingRooms.entries[g] = append(waitingRooms.entries[g], entry)
c.waiting = entry
c.SetUsername(auth.Username)
sendWaiting(c, "enter", currentWaitingState(g, auth.Username))
notifyWaitingOperators(g, "add", waitingUser{Username: auth.Username})
waitingRooms.Unlock()
return nil
}
func removeWaitingClient(c *webClient, notify bool) bool {
waitingRooms.Lock()
entry := c.waiting
if entry == nil {
waitingRooms.Unlock()
return false
}
removeWaitingEntryLocked(entry)
if notify {
notifyWaitingOperators(entry.hall, "remove", waitingUser{Username: entry.auth.Username})
}
waitingRooms.Unlock()
return true
}
func removeWaitingEntryLocked(entry *waitingEntry) {
entries := waitingRooms.entries[entry.hall]
for i, candidate := range entries {
if candidate == entry {
entries = append(entries[:i], entries[i+1:]...)
break
}
}
if len(entries) == 0 {
delete(waitingRooms.entries, entry.hall)
} else {
waitingRooms.entries[entry.hall] = entries
}
entry.client.waiting = nil
entry.hall.ReleaseUsername(entry.auth.Username, entry.client)
}
func cancelWaiting(g *hall.Hall, message string) {
waitingRooms.Lock()
cancelWaitingLocked(g, message)
waitingRooms.Unlock()
}
func cancelWaitingLocked(g *hall.Hall, message string) {
entries := append([]*waitingEntry(nil), waitingRooms.entries[g]...)
delete(waitingRooms.entries, g)
for _, entry := range entries {
entry.client.waiting = nil
entry.hall.ReleaseUsername(entry.auth.Username, entry.client)
entry.client.SetPermissions(nil)
entry.client.SetUsername("")
sendWaiting(entry.client, "cancel", message)
}
}
func waitingOperatorJoined(g *hall.Hall, c *webClient) {
if !member("op", c.Permissions()) {
return
}
waitingRooms.Lock()
snapshot := waitingSnapshotLocked(g)
sendWaitingList(c, "snapshot", snapshot)
waitingRooms.Unlock()
}
func waitingHallChanged(g *hall.Hall) {
waitingRooms.Lock()
locked, hasOperator := g.WaitingStatus()
if locked && !hasOperator {
cancelWaitingLocked(g, "Waiting ended because the last operator left the hall.")
waitingRooms.Unlock()
return
}
entries := waitingRooms.entries[g]
if locked {
for _, entry := range entries {
entry.manualJoin = false
}
}
kind := "update"
if !locked {
kind = "open"
}
for _, entry := range entries {
sendWaiting(entry.client, kind, currentWaitingState(g, entry.auth.Username))
}
waitingRooms.Unlock()
processWaiting(g)
}
func processWaiting(g *hall.Hall) {
for {
waitingRooms.Lock()
entries := waitingRooms.entries[g]
locked, hasOperator := g.WaitingStatus()
if locked && !hasOperator {
cancelWaitingLocked(g, "Waiting ended because the last operator left the hall.")
waitingRooms.Unlock()
return
}
var entry *waitingEntry
for _, candidate := range entries {
if candidate.approved || (!locked && (candidate.autoJoin || candidate.manualJoin)) {
entry = candidate
break
}
}
if entry == nil {
waitingRooms.Unlock()
return
}
// Publish the target hall before AddAuthenticatedClient enqueues its
// joined callback; admission may run on a different client's goroutine.
entry.client.hall = g
err := hall.AddAuthenticatedClient(g, entry.client, entry.auth, entry.approved)
if err != nil {
entry.client.hall = nil
if lockedNow, _ := g.Locked(); lockedNow && !entry.approved {
entry.manualJoin = false
waitingRooms.Unlock()
sendWaiting(entry.client, "update", currentWaitingState(g, entry.auth.Username))
return
}
if errors.Is(err, hall.UserError("too many users")) || err.Error() == "too many users" {
waitingRooms.Unlock()
return
}
removeWaitingEntryLocked(entry)
entry.client.SetPermissions(nil)
entry.client.SetUsername("")
notifyWaitingOperators(g, "remove", waitingUser{Username: entry.auth.Username})
waitingRooms.Unlock()
sendWaiting(entry.client, "cancel", "Admission cannot continue: "+err.Error())
continue
}
removeWaitingEntryLocked(entry)
notifyWaitingOperators(g, "remove", waitingUser{Username: entry.auth.Username})
waitingRooms.Unlock()
waitingOperatorJoined(g, entry.client)
}
}
// WaitingCapacityChanged retries pending admissions after a non-WebSocket
// client leaves the hall.
func WaitingCapacityChanged(g *hall.Hall) {
processWaiting(g)
}
func updateWaitingPreference(c *webClient, autoJoin bool) error {
waitingRooms.Lock()
if c.waiting == nil {
waitingRooms.Unlock()
return hall.UserError("you are not in a waiting room")
}
c.waiting.autoJoin = autoJoin
g := c.waiting.hall
waitingRooms.Unlock()
if autoJoin {
processWaiting(g)
}
return nil
}
func requestWaitingAdmission(c *webClient) error {
waitingRooms.Lock()
if c.waiting == nil {
waitingRooms.Unlock()
return hall.UserError("you are not in a waiting room")
}
g := c.waiting.hall
waitingRooms.Unlock()
locked, _ := g.Locked()
if locked {
return hall.UserError("the hall is still locked")
}
waitingRooms.Lock()
if c.waiting != nil {
c.waiting.manualJoin = true
}
waitingRooms.Unlock()
processWaiting(g)
return nil
}
func admitWaitingClient(operator *webClient, username string) error {
if operator.hall == nil || !member("op", operator.Permissions()) {
return hall.UserError("not authorised")
}
g := operator.hall
waitingRooms.Lock()
var found *waitingEntry
for _, entry := range waitingRooms.entries[g] {
if entry.auth.Username == username {
entry.approved = true
found = entry
break
}
}
snapshot := waitingSnapshotLocked(g)
if found != nil {
notifyWaitingOperators(g, "snapshot", snapshot)
}
waitingRooms.Unlock()
if found == nil {
return hall.UserError("no such waiting user")
}
sendWaiting(found.client, "approved", currentWaitingState(g, username))
processWaiting(g)
return nil
}
+315
View File
@@ -0,0 +1,315 @@
package rtpconn
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.stormux.org/storm/skald/hall"
)
func newWaitingTestHall(t *testing.T, maxClients int) (*hall.Hall, string) {
t.Helper()
name := "waiting-" + strings.NewReplacer("/", "-", " ", "-").Replace(t.Name())
oldDirectory := hall.Directory
hall.Directory = t.TempDir()
t.Cleanup(func() { hall.Directory = oldDirectory })
password := testPassword()
desc := &hall.Description{
DisplayName: "Stormux", MaxClients: maxClients,
Users: map[string]hall.UserDescription{
"Operator": {Password: password, Permissions: makePermission(t, "op")},
"Operator2": {Password: password, Permissions: makePermission(t, "op")},
"Alice": {Password: password, Permissions: makePermission(t, "present")},
"Bob": {Password: password, Permissions: makePermission(t, "present")},
},
}
data, err := json.Marshal(desc)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(hall.Directory, name+".json"), data, 0o600); err != nil {
t.Fatal(err)
}
g, err := hall.Add(name, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cancelScheduledHallUnlock(g)
cancelWaiting(g, "test cleanup")
for _, client := range g.GetClients(nil) {
hall.DelClient(client)
}
hall.Delete(name)
})
return g, name
}
func joinWaitingTestClient(t *testing.T, name, username string) *webClient {
t.Helper()
c := testWebClient(username + "-waiting-id")
err := handleClientMessage(c, clientMessage{
Type: "join", Kind: "join", Hall: name,
Username: &username, Password: "pw",
})
if err != nil {
t.Fatalf("waiting join: %v", err)
}
drainActions(t, c)
return c
}
func TestWaitingClientAuthenticatedWithoutMembership(t *testing.T) {
g, name := newWaitingTestHall(t, 0)
operator := addTestWebClient(t, name, "Operator", "op")
g.SetLocked(true, "")
drainActions(t, operator)
waiter := joinWaitingTestClient(t, name, "Alice")
if waiter.hall != nil || waiter.waiting == nil {
t.Fatalf("waiting client membership: hall=%v waiting=%v", waiter.hall, waiter.waiting)
}
if waiter.Username() != "Alice" || waiter.waiting.auth.Username != "Alice" {
t.Fatalf("authenticated identity was not retained: %#v", waiter.waiting.auth)
}
if g.GetClient(waiter.Id()) != nil {
t.Fatal("waiting client was visible as a hall member")
}
if err := handleClientMessage(waiter, clientMessage{Type: "chat", Value: "private"}); err != nil {
t.Fatalf("isolated chat attempt returned fatal error: %v", err)
}
if len(g.GetChatHistory()) != 0 {
t.Fatal("waiting client wrote to hall chat")
}
duplicate := testWebClient("direct-duplicate-id")
username := "Alice"
if _, err := hall.AddClient(name, duplicate, hall.ClientCredentials{
Username: &username, Password: "pw",
}); err == nil {
t.Fatal("waiting username was not reserved against direct admission")
}
messages := drainMessages(waiter)
foundEnter := false
for _, message := range messages {
if message.Type == "waiting" && message.Kind == "enter" {
foundEnter = true
}
if message.Type == "user" || message.Type == "chat" || message.Type == "chathistory" || message.Type == "offer" ||
(message.Type == "usermessage" && (message.Kind == "recording" || message.Kind == "chalkboard")) {
t.Fatalf("waiting client received hall-only message: %#v", message)
}
}
if !foundEnter {
t.Fatalf("missing waiting enter event: %#v", messages)
}
}
func TestWaitingManualAdmissionAndLastOperatorCancellation(t *testing.T) {
g, name := newWaitingTestHall(t, 0)
operator := addTestWebClient(t, name, "Operator", "op")
g.SetLocked(true, "")
drainActions(t, operator)
waiter := joinWaitingTestClient(t, name, "Alice")
if err := admitWaitingClient(operator, "Alice"); err != nil {
t.Fatal(err)
}
drainActions(t, waiter)
if waiter.hall != g || waiter.waiting != nil {
t.Fatalf("operator admission did not join: hall=%v waiting=%v", waiter.hall, waiter.waiting)
}
second := joinWaitingTestClient(t, name, "Bob")
leaveHall(operator)
drainActions(t, second)
if second.waiting != nil {
t.Fatal("waiting entry remained after last operator left")
}
if second.Username() != "" || len(second.Permissions()) != 0 {
t.Fatal("cancelled waiting client retained its authenticated identity")
}
foundCancel := false
for _, message := range drainMessages(second) {
if message.Type == "waiting" && message.Kind == "cancel" {
foundCancel = true
}
}
if !foundCancel {
t.Fatal("waiting client did not receive cancellation")
}
}
func TestWaitingAutomaticAdmissionRetriesInOrderAtCapacity(t *testing.T) {
g, name := newWaitingTestHall(t, 2)
operator := addTestWebClient(t, name, "Operator", "op")
g.SetLocked(true, "")
drainActions(t, operator)
alice := joinWaitingTestClient(t, name, "Alice")
bob := joinWaitingTestClient(t, name, "Bob")
if err := updateWaitingPreference(alice, true); err != nil {
t.Fatal(err)
}
if err := updateWaitingPreference(bob, true); err != nil {
t.Fatal(err)
}
setHallLockState(g, false, "")
drainActions(t, alice)
drainActions(t, bob)
if alice.hall != g || bob.hall != nil || bob.waiting == nil {
t.Fatalf("unexpected first admission: alice=%v bob=%v bobWaiting=%v", alice.hall, bob.hall, bob.waiting)
}
leaveHall(alice)
drainActions(t, bob)
if bob.hall != g || bob.waiting != nil {
t.Fatalf("capacity retry did not admit Bob: hall=%v waiting=%v", bob.hall, bob.waiting)
}
}
func TestWaitingAdmissionRequiresOperator(t *testing.T) {
g, name := newWaitingTestHall(t, 0)
operator := addTestWebClient(t, name, "Operator", "op")
g.SetLocked(true, "")
drainActions(t, operator)
alice := joinWaitingTestClient(t, name, "Alice")
if err := admitWaitingClient(alice, "Alice"); err == nil {
t.Fatal("waiting user admitted themself")
}
}
func TestWaitingRequiresOperatorAndReservesUsername(t *testing.T) {
g, name := newWaitingTestHall(t, 0)
g.SetLocked(true, "")
alice := joinWaitingTestClient(t, name, "Alice")
if alice.waiting != nil {
t.Fatal("client waited without an active operator")
}
foundFailure := false
for _, message := range drainMessages(alice) {
if message.Type == "joined" && message.Kind == "fail" {
foundFailure = true
}
}
if !foundFailure {
t.Fatal("locked hall without an operator did not reject the join")
}
operator := addTestWebClient(t, name, "Operator", "op")
drainActions(t, operator)
first := joinWaitingTestClient(t, name, "Alice")
second := joinWaitingTestClient(t, name, "Alice")
if first.waiting == nil || second.waiting != nil {
t.Fatalf("username reservation failed: first=%v second=%v", first.waiting, second.waiting)
}
removeWaitingClient(first, true)
third := joinWaitingTestClient(t, name, "Alice")
if third.waiting == nil {
t.Fatal("username was not released after waiting client departed")
}
}
func TestWaitingRelockDisablesManualAdmission(t *testing.T) {
g, name := newWaitingTestHall(t, 1)
operator := addTestWebClient(t, name, "Operator", "op")
g.SetLocked(true, "")
drainActions(t, operator)
alice := joinWaitingTestClient(t, name, "Alice")
setHallLockState(g, false, "")
setHallLockState(g, true, "")
if err := requestWaitingAdmission(alice); err == nil {
t.Fatal("manual admission succeeded after the hall was relocked")
}
if alice.hall != nil || alice.waiting == nil {
t.Fatalf("relocked client left waiting state: hall=%v waiting=%v", alice.hall, alice.waiting)
}
}
func TestWaitingManualJoinAfterOpening(t *testing.T) {
g, name := newWaitingTestHall(t, 0)
operator := addTestWebClient(t, name, "Operator", "op")
g.SetLocked(true, "")
drainActions(t, operator)
alice := joinWaitingTestClient(t, name, "Alice")
setHallLockState(g, false, "")
if err := requestWaitingAdmission(alice); err != nil {
t.Fatal(err)
}
drainActions(t, alice)
if alice.hall != g || alice.waiting != nil {
t.Fatalf("manual join did not complete: hall=%v waiting=%v", alice.hall, alice.waiting)
}
}
func TestWaitingApprovalSurvivesCapacityAndRelock(t *testing.T) {
g, name := newWaitingTestHall(t, 2)
operator := addTestWebClient(t, name, "Operator", "op")
g.SetLocked(true, "")
drainActions(t, operator)
alice := joinWaitingTestClient(t, name, "Alice")
bob := joinWaitingTestClient(t, name, "Bob")
if err := admitWaitingClient(operator, "Alice"); err != nil {
t.Fatal(err)
}
drainActions(t, alice)
if alice.hall != g {
t.Fatal("first approved user did not fill available capacity")
}
if err := admitWaitingClient(operator, "Bob"); err != nil {
t.Fatal(err)
}
if bob.waiting == nil || !bob.waiting.approved {
t.Fatal("approval was not retained while the hall was full")
}
setHallLockState(g, false, "")
setHallLockState(g, true, "")
leaveHall(alice)
drainActions(t, bob)
if bob.hall != g || bob.waiting != nil {
t.Fatalf("approved user was not admitted after capacity opened: hall=%v waiting=%v", bob.hall, bob.waiting)
}
}
func TestWaitingCancelledWhenLastOperatorLosesPermission(t *testing.T) {
g, name := newWaitingTestHall(t, 0)
operator := addTestWebClient(t, name, "Operator", "op")
g.SetLocked(true, "")
drainActions(t, operator)
alice := joinWaitingTestClient(t, name, "Alice")
if err := handleAction(operator, changePermissionsAction{kind: "unop"}); err != nil {
t.Fatal(err)
}
// Exercise the window before permissionsChangedAction is processed.
WaitingCapacityChanged(g)
drainActions(t, operator)
drainActions(t, alice)
if alice.waiting != nil {
t.Fatal("waiting continued after the last operator lost permission")
}
}
func TestWaitingScheduleRemovedWhenIssuerLeaves(t *testing.T) {
g, name := newWaitingTestHall(t, 0)
issuer := addTestWebClient(t, name, "Operator", "op")
other := addTestWebClient(t, name, "Operator2", "op")
g.SetLocked(true, "")
drainActions(t, issuer)
drainActions(t, other)
waiter := joinWaitingTestClient(t, name, "Alice")
now := unlockNow()
if err := scheduleHallUnlock(g, issuer, now.Add(10*time.Minute), now); err != nil {
t.Fatal(err)
}
if _, ok := scheduledUnlockDeadline(g); !ok {
t.Fatal("scheduled deadline was not recorded")
}
leaveHall(issuer)
if _, ok := scheduledUnlockDeadline(g); ok {
t.Fatal("scheduled deadline remained after issuer departure")
}
if waiter.waiting == nil {
t.Fatal("waiter was cancelled even though another operator remained")
}
}
+96 -8
View File
@@ -127,6 +127,8 @@ type webClient struct {
writerDone chan struct{}
actions *unbounded.Channel[any]
permissionsMu sync.RWMutex
identityMu sync.RWMutex
waiting *waitingEntry
mu sync.Mutex
down map[string]*rtpDownConnection
@@ -146,11 +148,15 @@ func (c *webClient) Id() string {
}
func (c *webClient) Username() string {
c.identityMu.RLock()
defer c.identityMu.RUnlock()
return c.username
}
func (c *webClient) SetUsername(username string) {
c.identityMu.Lock()
c.username = username
c.identityMu.Unlock()
}
func (c *webClient) Permissions() []string {
@@ -857,7 +863,7 @@ func readMessage(conn *websocket.Conn, m *clientMessage) error {
}
const maxWSMessageSize = 1024 * 1024
const protocolVersion = "2"
const protocolVersion = "3"
func StartClient(conn *websocket.Conn, addr net.Addr) (err error) {
var m clientMessage
@@ -1187,6 +1193,9 @@ func handleAction(c *webClient, a any) error {
}
case pushClientAction:
// Admission may be running on another client's goroutine. Taking
// the waiting lock is a barrier for c.hall and registry publication.
waitingClientHall(c)
if a.hall != c.hall.Name() {
log.Printf("got client for wrong hall")
return nil
@@ -1202,6 +1211,7 @@ func handleAction(c *webClient, a any) error {
Data: a.data,
})
case joinedAction:
waitingClientHall(c)
var status *hall.Status
var data map[string]interface{}
var g *hall.Hall
@@ -1215,7 +1225,7 @@ func handleAction(c *webClient, a any) error {
}
}
perms := append([]string(nil), c.permissions...)
username := c.username
username := c.Username()
err := c.write(clientMessage{
Type: "joined",
Kind: a.kind,
@@ -1295,9 +1305,14 @@ func handleAction(c *webClient, a any) error {
return errors.New("Permissions changed in no hall")
}
perms := c.Permissions()
if !member("op", perms) {
cancelScheduledUnlockByIssuer(g, c)
} else {
waitingOperatorJoined(g, c)
}
status := g.Status(true, nil)
status.Recording = hallRecording(g)
username := c.username
username := c.Username()
c.write(clientMessage{
Type: "joined",
Kind: "change",
@@ -1332,12 +1347,15 @@ func handleAction(c *webClient, a any) error {
)
}
}(clients)
waitingHallChanged(g)
case kickAction:
return hall.KickError{
Id: a.id,
Username: a.username,
Message: a.message,
}
case waitingEventAction:
return c.write(a.message)
default:
log.Printf("unexpected action %T", a)
return errors.New("unexpected action")
@@ -1365,6 +1383,11 @@ func failUpConnection(c *webClient, id string, message string) error {
}
func leaveHall(c *webClient) {
if removeWaitingClient(c, true) {
c.SetPermissions(nil)
c.SetUsername("")
return
}
if c.hall == nil {
return
}
@@ -1380,11 +1403,14 @@ func leaveHall(c *webClient) {
}
}
g := c.hall
cancelScheduledUnlockByIssuer(g, c)
hall.DelClient(c)
c.SetPermissions(nil)
c.data = nil
c.requested = make(map[string][]string)
c.hall = nil
waitingHallChanged(g)
}
func closeDownConn(c *webClient, id string, message string) error {
@@ -1441,10 +1467,19 @@ func handleClientMessage(c *webClient, m clientMessage) error {
}
}
}
if waitingClientHall(c) != nil && m.Type != "waiting" && m.Type != "join" && m.Type != "pong" {
return c.error(hall.UserError("you have not been admitted to the hall"))
}
switch m.Type {
case "join":
if m.Kind == "leave" {
if waitingHall := waitingClientHall(c); waitingHall != nil && waitingHall.Name() == m.Hall {
removeWaitingClient(c, true)
c.SetUsername("")
c.SetPermissions(nil)
return c.write(clientMessage{Type: "waiting", Kind: "leave", Hall: m.Hall})
}
if c.hall == nil || c.hall.Name() != m.Hall {
return hall.UserError("you are not joined")
}
@@ -1456,13 +1491,13 @@ func handleClientMessage(c *webClient, m clientMessage) error {
return hall.ProtocolError("unknown kind")
}
if c.hall != nil {
if c.hall != nil || waitingClientHall(c) != nil {
return hall.ProtocolError(
"cannot join multiple halls",
)
}
c.data = m.Data
g, err := hall.AddClient(m.Hall, c,
g, auth, err := hall.AuthenticateClient(m.Hall,
hall.ClientCredentials{
Username: m.Username,
Password: m.Password,
@@ -1490,7 +1525,7 @@ func handleClientMessage(c *webClient, m clientMessage) error {
s = "internal server error"
log.Printf("Join hall: %v", err)
}
username := c.username
username := c.Username()
return c.write(clientMessage{
Type: "joined",
Kind: "fail",
@@ -1503,7 +1538,7 @@ func handleClientMessage(c *webClient, m clientMessage) error {
if redirect := g.Description().Redirect; redirect != "" {
// We normally redirect at the HTTP level, but the hall
// description could have been edited in the meantime.
username := c.username
username := c.Username()
return c.write(clientMessage{
Type: "joined",
Kind: "redirect",
@@ -1512,7 +1547,60 @@ func handleClientMessage(c *webClient, m clientMessage) error {
Value: redirect,
})
}
if !member("op", auth.Permissions) {
if err := addWaitingClient(c, g, auth); err == nil {
return nil
} else if !errors.Is(err, errWaitingUnavailable) {
return c.write(clientMessage{
Type: "joined", Kind: "fail", Hall: m.Hall,
Username: &auth.Username, Value: err.Error(),
})
}
}
if err := hall.AddAuthenticatedClient(g, c, auth, false); err != nil {
username := auth.Username
return c.write(clientMessage{
Type: "joined", Kind: "fail", Hall: m.Hall,
Username: &username, Value: err.Error(),
})
}
c.hall = g
waitingOperatorJoined(g, c)
case "waiting":
switch m.Kind {
case "auto":
autoJoin, ok := m.Value.(bool)
if !ok {
return c.error(hall.UserError("invalid automatic join preference"))
}
if err := updateWaitingPreference(c, autoJoin); err != nil {
return c.error(err)
}
return nil
case "join":
if err := requestWaitingAdmission(c); err != nil {
return c.error(err)
}
return nil
case "leave":
if !removeWaitingClient(c, true) {
return c.error(hall.UserError("you are not in a waiting room"))
}
c.SetUsername("")
c.SetPermissions(nil)
return c.write(clientMessage{Type: "waiting", Kind: "leave"})
case "admit":
username, ok := m.Value.(string)
if !ok || username == "" {
return c.error(hall.UserError("invalid waiting username"))
}
if err := admitWaitingClient(c, username); err != nil {
return c.error(err)
}
return nil
default:
return hall.ProtocolError("unknown waiting action")
}
case "request":
requested, err := parseRequested(m.Request)
if err != nil {
@@ -1877,7 +1965,7 @@ func handleClientMessage(c *webClient, m clientMessage) error {
}
}
user := c.username
user := c.Username()
if user != "" {
tok.IssuedBy = &user
}
+1
View File
@@ -137,6 +137,7 @@ func (c *WhipClient) Close() error {
c.connection = nil
}
hall.DelClient(c)
WaitingCapacityChanged(g)
c.hall = nil
return nil
}
+9
View File
@@ -115,6 +115,15 @@ serverConnection.onjoined = function(kind, hall, perms, status, data, error, mes
Once you have joined a hall, you send chat messages with the `chat`
method of the `ServerConnection` class. No permission is needed to do that.
If authentication succeeds but admission is delayed by a locked hall,
`onwaiting(kind, state)` reports waiting-room entry and state changes. Use
`waitingAction('auto', boolean)`, `waitingAction('join')`, or
`waitingAction('leave')` to change the automatic-entry preference, request
entry after opening, or leave. Operators receive ordered waiting-list changes
through `onwaitinglist(kind, value)` and admit a username with
`waitingAction('admit', username)`. Waiting connections are not hall members,
so hall user, chat, history, and media callbacks do not run for them.
```javascript
serverConnection.chat(username, '', id, 'Hi!');
```
+51 -2
View File
@@ -95,7 +95,7 @@ start pipelining messages to the server.
```javascript
{
type: 'handshake',
version: ["2"],
version: ["3"],
id: id
}
```
@@ -105,6 +105,8 @@ decreasing preference order; the client may announce multiple versions,
but the server will always reply with a single version. If the field `id`
is absent, then the peer doesn't originate streams.
Protocol version 3 adds authenticated waiting-room messages and actions.
A peer may, at any time, send a `ping` message.
```javascript
@@ -136,7 +138,7 @@ The `join` message requests that the sender join or leave a hall:
}
```
If token-based authorisation is beling used, then the `username` and
If token-based authorisation is being used, then the `username` and
`password` fields are omitted, and a `token` field is included instead.
When the sender has effectively joined the hall, the peer will send
@@ -164,6 +166,53 @@ values `present`, `op` and `record`. The `status` field is a dictionary
that contains status information about the hall, and updates the data
obtained from the `.status` URL described above.
### Waiting-room authentication and admission
When a non-operator authenticates while the hall is locked and an operator
is present, authentication succeeds without hall membership. The server
sends a `waiting` message of kind `enter`; subsequent locked-state changes use
kind `update`, and opening uses kind `open`. The value contains the internal hall name, display name,
authenticated username, lock state, and an optional RFC 3339 deadline:
```javascript
{
type: 'waiting',
kind: 'enter' or 'update' or 'open' or 'approved',
value: {
hall: hall,
displayName: displayName,
username: username,
locked: true,
deadline: optionalDeadline
}
}
```
`cancel` ends waiting with its reason in `value`; `leave` confirms a
voluntary departure. A waiting client changes its automatic-join preference,
requests entry after opening, or leaves with:
```javascript
{type: 'waiting', kind: 'auto', value: trueOrFalse}
{type: 'waiting', kind: 'join'}
{type: 'waiting', kind: 'leave'}
```
Waiting clients are authenticated but are not hall members. They receive no
user list, chat or chat history, media, chalkboard, or recording state.
Operators receive `waiting-list` messages. `snapshot` has an array of
`{username, approved}` values; `add` and `remove` contain one such value.
An operator admits one user with:
```javascript
{type: 'waiting', kind: 'admit', value: username}
```
Admission approval bypasses only the hall lock. Authentication, hall time
restrictions, and capacity still apply. An approval that cannot fit remains
queued and is retried when capacity becomes available.
## Maintaining hall membership
Whenever a user joins or leaves a hall, the server will send all other
+20 -1
View File
@@ -30,7 +30,26 @@ of a user list, chat, and connection controls:
hall; every username doubles as a menu of hall and user actions;
- the chat pane contains the messages published to the hall;
- the controls area contains login, connection, microphone, and settings
controls.
controls.
### Waiting for a locked hall
If a non-operator signs in while a hall is locked and an operator is present,
Skald opens a waiting room. The waiting page names the hall and shows either
the scheduled unlock time remaining, rounded to minutes, or states that an
operator is present without an unlock time. Waiting users cannot hear hall
audio or access its participants, chat, history, or recording state.
The *Join hall automatically when it unlocks* checkbox starts unchecked on
every visit. If selected, Skald joins in waiting-room arrival order when the
hall opens and space is available. Otherwise, the *Join hall* button becomes
available when the hall opens. *Leave waiting room* returns to sign-in.
While a hall is locked, operators see a *Waiting room (count)* panel beneath
the participant list. Each waiting username has an *Admit* button. Admission
approval remains pending if the hall is full and completes when space becomes
available. Unlocking the hall allows all waiting users to enter according to
their automatic or manual choice.
On mobile, the interface is collapsed to fit the smaller screen. The top
controls expose the user list, chat, and settings.
+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;
+20
View File
@@ -145,6 +145,26 @@ func TestMicrophoneHardStopLivesInSelfMenu(t *testing.T) {
requireFunctionContains(t, js, "userMenu", "Restart audio connection")
}
func TestWaitingRoomUsesAccessibleNativeControlsAndLiveRegions(t *testing.T) {
html := readStaticFile(t, "skald.html")
js := readStaticFile(t, "skald.js")
requireContains(t, html, `aria-labelledby="waiting-heading"`, "waiting room")
requireContains(t, html, `id="waiting-heading" tabindex="-1"`, "waiting heading")
requireContains(t, html, `label for="waiting-auto-join"`, "automatic join label")
requireContains(t, html, `id="waiting-join" type="button"`, "join button")
requireContains(t, html, `id="waiting-leave" type="button"`, "leave button")
requireContains(t, html, `aria-labelledby="waiting-list-heading"`, "operator waiting list")
requireFunctionContains(t, js, "showWaitingRoom", "waiting-heading').focus")
requireFunctionContains(t, js, "updateWaitingStatus", "Math.ceil")
requireFunctionContains(t, js, "updateWaitingStatus", "announceChat(status)")
requireFunctionContains(t, js, "gotWaiting", "announceUrgent")
requireFunctionContains(t, js, "renderWaitingList", "document.createElement('button')")
requireFunctionContains(t, js, "renderWaitingList", "button.type = 'button'")
requireFunctionContains(t, js, "gotJoined", "Your microphone is not enabled")
requireFunctionContains(t, js, "gotJoined", "Your microphone is ${enabled ? 'unmuted' : 'muted'}")
}
func TestRecordingControlLivesInSelfMenu(t *testing.T) {
js := readStaticFile(t, "skald.js")
+2
View File
@@ -230,6 +230,7 @@ func whipEndpointHandler(w http.ResponseWriter, r *http.Request) {
if !canPresent(c.Permissions()) {
hall.DelClient(c)
rtpconn.WaitingCapacityChanged(g)
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@@ -239,6 +240,7 @@ func whipEndpointHandler(w http.ResponseWriter, r *http.Request) {
answer, err := c.NewConnection(r.Context(), body)
if err != nil {
hall.DelClient(c)
rtpconn.WaitingCapacityChanged(g)
log.Printf("WHIP offer: %v", err)
httpError(w, err)
return