Compare commits
2 Commits
2026.07.08
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a94f893b6 | |||
| 704b1a9558 |
@@ -22,17 +22,12 @@ server.
|
||||
|
||||
## Workflow
|
||||
|
||||
- Use `todo.txt` as the source of truth for staged work.
|
||||
- Preserve the todo status markers:
|
||||
- `-` means open.
|
||||
- `+` means implemented, needs testing.
|
||||
- `X` means tested and done.
|
||||
- Work in small batches and update `todo.txt` only for items actually completed
|
||||
or tested.
|
||||
- Before beginning a new phase or section, review all earlier `+` and `X`
|
||||
sections in `todo.txt`; confirm their status still matches the code, tests,
|
||||
docs, and user-facing surfaces, and downgrade stale `X` markers when evidence
|
||||
no longer supports them.
|
||||
- Skald is production software; prefer small, scoped maintenance changes with
|
||||
focused verification.
|
||||
- Use a temporary local plan file only for large or risky batches that need a
|
||||
resumable checklist.
|
||||
- Building the Arch package updates `distro-packages/Arch-Linux/skald-git/PKGBUILD`
|
||||
with the generated `pkgver`, so this file is often dirty after package builds.
|
||||
- Keep changes scoped to the current batch. Do not mix in unrelated cleanup.
|
||||
- Before behavior changes, inspect the current code path and tests rather than
|
||||
assuming Galene behavior still applies.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# shellcheck shell=bash disable=SC2034,SC2154
|
||||
|
||||
pkgname=skald-git
|
||||
pkgver=2026.07.08
|
||||
pkgver=2026.07.08.r1.g704b1a9
|
||||
pkgrel=1
|
||||
pkgdesc='Audio-only hall-based conferencing server'
|
||||
arch=('x86_64' 'aarch64')
|
||||
|
||||
+1
-2
@@ -34,11 +34,10 @@ type Permissions struct {
|
||||
}
|
||||
|
||||
var permissionsMap = map[string][]string{
|
||||
"op": {"op", "present", "message", "caption", "token"},
|
||||
"op": {"op", "present", "message", "token"},
|
||||
"present": {"present", "message"},
|
||||
"message": {"message"},
|
||||
"observe": {},
|
||||
"caption": {"caption"},
|
||||
"admin": {"admin"},
|
||||
}
|
||||
|
||||
|
||||
@@ -145,6 +145,41 @@ func (g *Hall) SetLocked(locked bool, message string) {
|
||||
}
|
||||
}
|
||||
|
||||
type ConditionalUnlockResult uint8
|
||||
|
||||
const (
|
||||
ConditionalUnlockSucceeded ConditionalUnlockResult = iota
|
||||
ConditionalUnlockAlreadyUnlocked
|
||||
ConditionalUnlockClientAbsent
|
||||
ConditionalUnlockClientNotOperator
|
||||
)
|
||||
|
||||
// UnlockIfOperatorPresent atomically verifies that c is still in this hall
|
||||
// with operator permission and unlocks the hall if so.
|
||||
func (g *Hall) UnlockIfOperatorPresent(c Client) ConditionalUnlockResult {
|
||||
g.mu.Lock()
|
||||
if g.locked == nil {
|
||||
g.mu.Unlock()
|
||||
return ConditionalUnlockAlreadyUnlocked
|
||||
}
|
||||
if g.getClientUnlocked(c.Id()) != c {
|
||||
g.mu.Unlock()
|
||||
return ConditionalUnlockClientAbsent
|
||||
}
|
||||
if !member("op", c.Permissions()) {
|
||||
g.mu.Unlock()
|
||||
return ConditionalUnlockClientNotOperator
|
||||
}
|
||||
g.locked = nil
|
||||
clients := g.getClientsUnlocked(nil)
|
||||
g.mu.Unlock()
|
||||
|
||||
for _, client := range clients {
|
||||
client.Joined(g.Name(), "change")
|
||||
}
|
||||
return ConditionalUnlockSucceeded
|
||||
}
|
||||
|
||||
func (g *Hall) Data() map[string]interface{} {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
+6
-14
@@ -396,8 +396,6 @@ var james = "james"
|
||||
var paul = "paul"
|
||||
var peter = "peter"
|
||||
var admin = "admin"
|
||||
var stt = "speech-to-text"
|
||||
|
||||
var badClients = []ClientCredentials{
|
||||
{Username: &jch, Password: "foo"},
|
||||
{Username: &john, Password: "foo"},
|
||||
@@ -418,9 +416,7 @@ var desc2JSON = `
|
||||
"john": {"password": "secret", "permissions": "present"},
|
||||
"james": {"password": "secret2", "permissions": "message"},
|
||||
"peter": {"password": "secret4"},
|
||||
"admin": {"password": "admin", "permissions": "admin"},
|
||||
"speech-to-text": {"password": {"type": "wildcard"},
|
||||
"permissions": "caption"}
|
||||
"admin": {"password": "admin", "permissions": "admin"}
|
||||
},
|
||||
"wildcard-user":
|
||||
{"permissions": "message", "password": {"type":"wildcard"}}
|
||||
@@ -429,7 +425,7 @@ var desc2JSON = `
|
||||
var goodClients = []credPerm{
|
||||
{
|
||||
ClientCredentials{Username: &jch, Password: "topsecret"},
|
||||
[]string{"op", "present", "message", "caption", "token"},
|
||||
[]string{"op", "present", "message", "token"},
|
||||
},
|
||||
{
|
||||
ClientCredentials{Username: &john, Password: "secret"},
|
||||
@@ -451,10 +447,6 @@ var goodClients = []credPerm{
|
||||
ClientCredentials{Username: &admin, Password: "admin"},
|
||||
[]string{"admin"},
|
||||
},
|
||||
{
|
||||
ClientCredentials{Username: &stt},
|
||||
[]string{"caption"},
|
||||
},
|
||||
}
|
||||
|
||||
func TestPermissions(t *testing.T) {
|
||||
@@ -513,7 +505,7 @@ func TestExtraPermissions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
doit("jch", []string{"op", "token", "present", "message", "caption"})
|
||||
doit("jch", []string{"op", "token", "present", "message"})
|
||||
doit("john", []string{"present", "message"})
|
||||
doit("james", []string{})
|
||||
|
||||
@@ -521,7 +513,7 @@ func TestExtraPermissions(t *testing.T) {
|
||||
d.UnrestrictedTokens = false
|
||||
|
||||
doit("jch", []string{
|
||||
"op", "record", "token", "present", "message", "caption",
|
||||
"op", "record", "token", "present", "message",
|
||||
})
|
||||
doit("john", []string{"present", "message"})
|
||||
doit("james", []string{})
|
||||
@@ -529,7 +521,7 @@ func TestExtraPermissions(t *testing.T) {
|
||||
d.AllowRecording = false
|
||||
d.UnrestrictedTokens = true
|
||||
|
||||
doit("jch", []string{"op", "token", "present", "message", "caption"})
|
||||
doit("jch", []string{"op", "token", "present", "message"})
|
||||
doit("john", []string{"token", "present", "message"})
|
||||
doit("james", []string{})
|
||||
|
||||
@@ -537,7 +529,7 @@ func TestExtraPermissions(t *testing.T) {
|
||||
d.UnrestrictedTokens = true
|
||||
|
||||
doit("jch", []string{
|
||||
"op", "record", "token", "present", "message", "caption",
|
||||
"op", "record", "token", "present", "message",
|
||||
})
|
||||
doit("john", []string{"token", "present", "message"})
|
||||
doit("james", []string{})
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
package rtpconn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/skald/hall"
|
||||
)
|
||||
|
||||
var (
|
||||
durationUnlockPattern = regexp.MustCompile(`^([0-9]+)[mM]$`)
|
||||
twentyFourHourPattern = regexp.MustCompile(`^([01][0-9]|2[0-3]):([0-5][0-9])$`)
|
||||
twelveHourPattern = regexp.MustCompile(`^(0?[1-9]|1[0-2]):([0-5][0-9])\s*([aApP])\.?[mM]\.?$`)
|
||||
unlockNow = time.Now
|
||||
)
|
||||
|
||||
type scheduledUnlockRequest struct {
|
||||
spec string
|
||||
timeZone string
|
||||
timezoneOffset int
|
||||
}
|
||||
|
||||
type scheduledHallUnlock struct {
|
||||
mu sync.Mutex
|
||||
active bool
|
||||
hall *hall.Hall
|
||||
issuer *webClient
|
||||
issuerName string
|
||||
deadline time.Time
|
||||
timers []*time.Timer
|
||||
}
|
||||
|
||||
var hallUnlockSchedules = struct {
|
||||
sync.Mutex
|
||||
entries map[*hall.Hall]*scheduledHallUnlock
|
||||
}{
|
||||
entries: make(map[*hall.Hall]*scheduledHallUnlock),
|
||||
}
|
||||
|
||||
func scheduledUnlockRequestFromValue(value interface{}) (scheduledUnlockRequest, error) {
|
||||
v, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return scheduledUnlockRequest{}, hall.UserError("Invalid scheduled unlock request.")
|
||||
}
|
||||
|
||||
spec, ok := v["spec"].(string)
|
||||
if !ok || strings.TrimSpace(spec) == "" {
|
||||
return scheduledUnlockRequest{}, hall.UserError("Specify a duration or time to schedule an unlock.")
|
||||
}
|
||||
timeZone, _ := v["timeZone"].(string)
|
||||
offsetValue, ok := v["timezoneOffset"].(float64)
|
||||
if !ok || math.Trunc(offsetValue) != offsetValue || offsetValue < -14*60 || offsetValue > 14*60 {
|
||||
return scheduledUnlockRequest{}, hall.UserError("Invalid browser timezone offset.")
|
||||
}
|
||||
|
||||
return scheduledUnlockRequest{
|
||||
spec: strings.TrimSpace(spec),
|
||||
timeZone: timeZone,
|
||||
timezoneOffset: int(offsetValue),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r scheduledUnlockRequest) location() (*time.Location, bool) {
|
||||
if r.timeZone != "" && len(r.timeZone) <= 100 {
|
||||
if location, err := time.LoadLocation(r.timeZone); err == nil {
|
||||
return location, true
|
||||
}
|
||||
}
|
||||
// JavaScript's getTimezoneOffset is UTC minus local time.
|
||||
return time.FixedZone("browser local time", -r.timezoneOffset*60), false
|
||||
}
|
||||
|
||||
func parseScheduledUnlock(value interface{}, now time.Time) (time.Time, error) {
|
||||
request, err := scheduledUnlockRequestFromValue(value)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
location, hasNamedTimeZone := request.location()
|
||||
localNow := now.In(location)
|
||||
|
||||
if match := durationUnlockPattern.FindStringSubmatch(request.spec); match != nil {
|
||||
minutes, err := strconv.ParseInt(match[1], 10, 32)
|
||||
if err != nil || minutes <= 0 {
|
||||
return time.Time{}, hall.UserError("The unlock duration must be at least 1 minute.")
|
||||
}
|
||||
if minutes > 24*60 {
|
||||
return time.Time{}, hall.UserError("The unlock duration must end later today.")
|
||||
}
|
||||
target := localNow.Add(time.Duration(minutes) * time.Minute)
|
||||
if !sameDate(localNow, target) {
|
||||
return time.Time{}, hall.UserError("The specified duration crosses midnight, cannot unlock on a different day.")
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
hour, minute, ok := parseUnlockClockTime(request.spec)
|
||||
if !ok {
|
||||
return time.Time{}, hall.UserError("Invalid unlock time. Use minutes such as 30m, 24-hour time such as 15:30, or 12-hour time such as 3:30 PM.")
|
||||
}
|
||||
if !hasNamedTimeZone {
|
||||
return time.Time{}, hall.UserError("Cannot schedule a clock time because the browser timezone is unavailable; use a duration such as 30m.")
|
||||
}
|
||||
candidates := clockTimeCandidates(localNow, hour, minute, location)
|
||||
if len(candidates) == 0 {
|
||||
return time.Time{}, hall.UserError("The specified local time does not exist today, cannot unlock.")
|
||||
}
|
||||
for _, target := range candidates {
|
||||
if target.After(localNow) {
|
||||
return target, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, hall.UserError(fmt.Sprintf(
|
||||
"The specified time is %s in the past, cannot unlock.",
|
||||
formatElapsed(localNow.Sub(candidates[len(candidates)-1])),
|
||||
))
|
||||
}
|
||||
|
||||
func clockTimeCandidates(date time.Time, hour, minute int, location *time.Location) []time.Time {
|
||||
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
|
||||
end := start.AddDate(0, 0, 1)
|
||||
var candidates []time.Time
|
||||
for candidate := start; candidate.Before(end); candidate = candidate.Add(time.Minute) {
|
||||
local := candidate.In(location)
|
||||
if sameDate(local, date) && local.Hour() == hour && local.Minute() == minute {
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func parseUnlockClockTime(spec string) (int, int, bool) {
|
||||
if match := twentyFourHourPattern.FindStringSubmatch(spec); match != nil {
|
||||
hour, _ := strconv.Atoi(match[1])
|
||||
minute, _ := strconv.Atoi(match[2])
|
||||
return hour, minute, true
|
||||
}
|
||||
if match := twelveHourPattern.FindStringSubmatch(spec); match != nil {
|
||||
hour, _ := strconv.Atoi(match[1])
|
||||
minute, _ := strconv.Atoi(match[2])
|
||||
if hour == 12 {
|
||||
hour = 0
|
||||
}
|
||||
if strings.EqualFold(match[3], "p") {
|
||||
hour += 12
|
||||
}
|
||||
return hour, minute, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func sameDate(a, b time.Time) bool {
|
||||
ay, am, ad := a.Date()
|
||||
by, bm, bd := b.Date()
|
||||
return ay == by && am == bm && ad == bd
|
||||
}
|
||||
|
||||
func formatElapsed(duration time.Duration) string {
|
||||
if duration >= time.Hour {
|
||||
hours := int(duration / time.Hour)
|
||||
if hours == 1 {
|
||||
return "1 hour"
|
||||
}
|
||||
return fmt.Sprintf("%d hours", hours)
|
||||
}
|
||||
if duration >= time.Minute {
|
||||
minutes := int(duration / time.Minute)
|
||||
if minutes == 1 {
|
||||
return "1 minute"
|
||||
}
|
||||
return fmt.Sprintf("%d minutes", minutes)
|
||||
}
|
||||
return "less than 1 minute"
|
||||
}
|
||||
|
||||
func formatMinutesUntil(duration time.Duration) string {
|
||||
minutes := int(math.Ceil(duration.Minutes()))
|
||||
if minutes < 1 {
|
||||
minutes = 1
|
||||
}
|
||||
if minutes == 1 {
|
||||
return "1 minute"
|
||||
}
|
||||
return fmt.Sprintf("%d minutes", minutes)
|
||||
}
|
||||
|
||||
func unlockWarningTimes(duration time.Duration) []time.Duration {
|
||||
minutes := int(math.Ceil(duration.Minutes()))
|
||||
if minutes >= 30 {
|
||||
return []time.Duration{20 * time.Minute, 10 * time.Minute, 5 * time.Minute}
|
||||
}
|
||||
if minutes >= 15 {
|
||||
return []time.Duration{10 * time.Minute, 5 * time.Minute}
|
||||
}
|
||||
if minutes >= 6 {
|
||||
return []time.Duration{5 * time.Minute}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scheduleHallUnlock(g *hall.Hall, issuer *webClient, deadline, now time.Time) error {
|
||||
hallUnlockSchedules.Lock()
|
||||
|
||||
locked, _ := g.Locked()
|
||||
if !locked {
|
||||
hallUnlockSchedules.Unlock()
|
||||
return hall.UserError("The hall is already unlocked.")
|
||||
}
|
||||
remaining := deadline.Sub(now)
|
||||
if remaining <= 0 {
|
||||
hallUnlockSchedules.Unlock()
|
||||
return hall.UserError("The specified unlock time has already passed.")
|
||||
}
|
||||
if remaining > 24*time.Hour {
|
||||
hallUnlockSchedules.Unlock()
|
||||
return hall.UserError("The unlock time must be later today.")
|
||||
}
|
||||
|
||||
issuerName := issuer.Username()
|
||||
if issuerName == "" {
|
||||
issuerName = "the issuing operator"
|
||||
}
|
||||
entry := &scheduledHallUnlock{
|
||||
active: true,
|
||||
hall: g,
|
||||
issuer: issuer,
|
||||
issuerName: issuerName,
|
||||
deadline: deadline,
|
||||
}
|
||||
|
||||
previous := hallUnlockSchedules.entries[g]
|
||||
if previous != nil {
|
||||
deactivateScheduledUnlock(previous)
|
||||
}
|
||||
hallUnlockSchedules.entries[g] = entry
|
||||
for _, warning := range unlockWarningTimes(remaining) {
|
||||
delay := deadline.Sub(now) - warning
|
||||
if delay <= 0 {
|
||||
continue
|
||||
}
|
||||
warning := warning
|
||||
entry.timers = append(entry.timers, time.AfterFunc(delay, func() {
|
||||
announceScheduledUnlock(entry, warning)
|
||||
}))
|
||||
}
|
||||
entry.timers = append(entry.timers, time.AfterFunc(remaining, func() {
|
||||
finishScheduledUnlock(entry)
|
||||
}))
|
||||
hallUnlockSchedules.Unlock()
|
||||
|
||||
message := fmt.Sprintf(
|
||||
"Hall scheduled to unlock in %s by %s.",
|
||||
formatMinutesUntil(remaining), entry.issuerName,
|
||||
)
|
||||
if previous != nil {
|
||||
message = "Previous scheduled unlock replaced. " + message
|
||||
}
|
||||
entry.mu.Lock()
|
||||
if entry.active {
|
||||
broadcastHallInfo(g, message)
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func announceScheduledUnlock(entry *scheduledHallUnlock, remaining time.Duration) {
|
||||
entry.mu.Lock()
|
||||
defer entry.mu.Unlock()
|
||||
if !entry.active {
|
||||
return
|
||||
}
|
||||
locked, _ := entry.hall.Locked()
|
||||
if !locked {
|
||||
return
|
||||
}
|
||||
broadcastHallInfo(entry.hall, fmt.Sprintf(
|
||||
"Hall will unlock in %s.", formatMinutesUntil(remaining),
|
||||
))
|
||||
}
|
||||
|
||||
func finishScheduledUnlock(entry *scheduledHallUnlock) {
|
||||
hallUnlockSchedules.Lock()
|
||||
if hallUnlockSchedules.entries[entry.hall] != entry {
|
||||
hallUnlockSchedules.Unlock()
|
||||
return
|
||||
}
|
||||
entry.mu.Lock()
|
||||
delete(hallUnlockSchedules.entries, entry.hall)
|
||||
entry.active = false
|
||||
stopScheduledUnlockTimers(entry)
|
||||
result := entry.hall.UnlockIfOperatorPresent(entry.issuer)
|
||||
switch result {
|
||||
case hall.ConditionalUnlockSucceeded:
|
||||
broadcastHallInfo(entry.hall, "Hall unlocked")
|
||||
case hall.ConditionalUnlockClientAbsent:
|
||||
broadcastHallInfo(entry.hall, fmt.Sprintf(
|
||||
"Scheduled unlock cancelled because %s is no longer present in the hall.",
|
||||
entry.issuerName,
|
||||
))
|
||||
case hall.ConditionalUnlockClientNotOperator:
|
||||
broadcastHallInfo(entry.hall, fmt.Sprintf(
|
||||
"Scheduled unlock cancelled because %s is no longer an operator.",
|
||||
entry.issuerName,
|
||||
))
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
hallUnlockSchedules.Unlock()
|
||||
}
|
||||
|
||||
func setHallLockState(g *hall.Hall, locked bool, message string) bool {
|
||||
hallUnlockSchedules.Lock()
|
||||
entry := hallUnlockSchedules.entries[g]
|
||||
if entry != nil {
|
||||
delete(hallUnlockSchedules.entries, g)
|
||||
deactivateScheduledUnlock(entry)
|
||||
}
|
||||
g.SetLocked(locked, message)
|
||||
if locked {
|
||||
if entry != nil {
|
||||
broadcastHallInfo(g, "Scheduled hall unlock cancelled because the hall was locked again.")
|
||||
}
|
||||
broadcastHallInfo(g, "Hall locked")
|
||||
} else {
|
||||
broadcastHallInfo(g, "Hall unlocked")
|
||||
}
|
||||
hallUnlockSchedules.Unlock()
|
||||
return entry != nil
|
||||
}
|
||||
|
||||
func cancelScheduledHallUnlock(g *hall.Hall) bool {
|
||||
hallUnlockSchedules.Lock()
|
||||
entry := hallUnlockSchedules.entries[g]
|
||||
if entry != nil {
|
||||
delete(hallUnlockSchedules.entries, g)
|
||||
deactivateScheduledUnlock(entry)
|
||||
}
|
||||
hallUnlockSchedules.Unlock()
|
||||
return entry != nil
|
||||
}
|
||||
|
||||
func deactivateScheduledUnlock(entry *scheduledHallUnlock) {
|
||||
entry.mu.Lock()
|
||||
entry.active = false
|
||||
stopScheduledUnlockTimers(entry)
|
||||
entry.mu.Unlock()
|
||||
}
|
||||
|
||||
func stopScheduledUnlockTimers(entry *scheduledHallUnlock) {
|
||||
for _, timer := range entry.timers {
|
||||
timer.Stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
package rtpconn
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/skald/hall"
|
||||
)
|
||||
|
||||
func unlockRequest(spec, timeZone string, timezoneOffset float64) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"spec": spec,
|
||||
"timeZone": timeZone,
|
||||
"timezoneOffset": timezoneOffset,
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScheduledUnlock(t *testing.T) {
|
||||
location, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLocation: %v", err)
|
||||
}
|
||||
now := time.Date(2026, time.July, 21, 15, 0, 0, 0, location)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
spec string
|
||||
wantHour int
|
||||
wantMin int
|
||||
wantErr string
|
||||
}{
|
||||
{name: "lowercase duration", spec: "30m", wantHour: 15, wantMin: 30},
|
||||
{name: "uppercase duration", spec: "30M", wantHour: 15, wantMin: 30},
|
||||
{name: "24 hour", spec: "15:45", wantHour: 15, wantMin: 45},
|
||||
{name: "one digit hour is not 24 hour", spec: "3:45", wantErr: "Invalid unlock time"},
|
||||
{name: "12 hour spaced", spec: "3:45 PM", wantHour: 15, wantMin: 45},
|
||||
{name: "12 hour compact lowercase", spec: "3:45pm", wantHour: 15, wantMin: 45},
|
||||
{name: "midnight", spec: "12:30 AM", wantErr: "14 hours in the past"},
|
||||
{name: "unqualified noon is 24 hour", spec: "12:00", wantErr: "3 hours in the past"},
|
||||
{name: "missing meridiem minutes", spec: "3 PM", wantErr: "Invalid unlock time"},
|
||||
{name: "invalid hour", spec: "25:00", wantErr: "Invalid unlock time"},
|
||||
{name: "zero duration", spec: "0m", wantErr: "at least 1 minute"},
|
||||
{name: "duration cannot contain space", spec: "30 m", wantErr: "Invalid unlock time"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := parseScheduledUnlock(
|
||||
unlockRequest(test.spec, "America/New_York", 240), now,
|
||||
)
|
||||
if test.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("parseScheduledUnlock(%q) error = %v, want containing %q", test.spec, err, test.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseScheduledUnlock(%q): %v", test.spec, err)
|
||||
}
|
||||
local := got.In(location)
|
||||
if local.Hour() != test.wantHour || local.Minute() != test.wantMin {
|
||||
t.Fatalf("parseScheduledUnlock(%q) = %v, want %02d:%02d", test.spec, local, test.wantHour, test.wantMin)
|
||||
}
|
||||
if !sameDate(local, now) {
|
||||
t.Fatalf("parseScheduledUnlock(%q) changed date: %v", test.spec, local)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScheduledUnlockRequiresNamedTimezoneForClockTime(t *testing.T) {
|
||||
now := time.Date(2026, time.July, 21, 15, 0, 0, 0, time.UTC)
|
||||
_, err := parseScheduledUnlock(unlockRequest("16:00", "", 0), now)
|
||||
if err == nil || !strings.Contains(err.Error(), "browser timezone is unavailable") {
|
||||
t.Fatalf("clock time without named timezone error = %v", err)
|
||||
}
|
||||
|
||||
target, err := parseScheduledUnlock(unlockRequest("30m", "", 0), now)
|
||||
if err != nil {
|
||||
t.Fatalf("duration with offset fallback: %v", err)
|
||||
}
|
||||
if got := target.Sub(now); got != 30*time.Minute {
|
||||
t.Fatalf("duration with offset fallback = %v, want 30m", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScheduledUnlockRejectsCrossMidnight(t *testing.T) {
|
||||
location, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLocation: %v", err)
|
||||
}
|
||||
now := time.Date(2026, time.July, 21, 23, 45, 0, 0, location)
|
||||
_, err = parseScheduledUnlock(
|
||||
unlockRequest("30m", "America/New_York", 240), now,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "crosses midnight") {
|
||||
t.Fatalf("cross-midnight duration error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScheduledUnlockUsesDaylightSavingTime(t *testing.T) {
|
||||
location, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLocation: %v", err)
|
||||
}
|
||||
now := time.Date(2026, time.March, 8, 1, 30, 0, 0, location)
|
||||
target, err := parseScheduledUnlock(
|
||||
unlockRequest("03:30", "America/New_York", 300), now,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("parseScheduledUnlock across DST boundary: %v", err)
|
||||
}
|
||||
if got := target.Sub(now); got != time.Hour {
|
||||
t.Fatalf("DST-aware duration = %v, want 1h", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScheduledUnlockRejectsNonexistentDaylightSavingTime(t *testing.T) {
|
||||
location, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLocation: %v", err)
|
||||
}
|
||||
now := time.Date(2026, time.March, 8, 1, 30, 0, 0, location)
|
||||
_, err = parseScheduledUnlock(
|
||||
unlockRequest("02:30", "America/New_York", 300), now,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not exist today") {
|
||||
t.Fatalf("nonexistent DST time error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseScheduledUnlockChoosesNextRepeatedDaylightSavingTime(t *testing.T) {
|
||||
location, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLocation: %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
nowUTC time.Time
|
||||
wantUTC time.Time
|
||||
}{
|
||||
{
|
||||
name: "before first occurrence",
|
||||
nowUTC: time.Date(2026, time.November, 1, 4, 30, 0, 0, time.UTC),
|
||||
wantUTC: time.Date(2026, time.November, 1, 5, 30, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "during second occurrence",
|
||||
nowUTC: time.Date(2026, time.November, 1, 6, 15, 0, 0, time.UTC),
|
||||
wantUTC: time.Date(2026, time.November, 1, 6, 30, 0, 0, time.UTC),
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
target, err := parseScheduledUnlock(
|
||||
unlockRequest("01:30", "America/New_York", 240), test.nowUTC,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("parse repeated clock time: %v", err)
|
||||
}
|
||||
if !target.Equal(test.wantUTC) {
|
||||
t.Fatalf("repeated clock target = %v (%v), want %v", target, target.In(location), test.wantUTC)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnlockWarningTimes(t *testing.T) {
|
||||
tests := []struct {
|
||||
duration time.Duration
|
||||
want []time.Duration
|
||||
}{
|
||||
{30 * time.Minute, []time.Duration{20 * time.Minute, 10 * time.Minute, 5 * time.Minute}},
|
||||
{29 * time.Minute, []time.Duration{10 * time.Minute, 5 * time.Minute}},
|
||||
{15 * time.Minute, []time.Duration{10 * time.Minute, 5 * time.Minute}},
|
||||
{6 * time.Minute, []time.Duration{5 * time.Minute}},
|
||||
{5 * time.Minute, nil},
|
||||
}
|
||||
for _, test := range tests {
|
||||
got := unlockWarningTimes(test.duration)
|
||||
if len(got) != len(test.want) {
|
||||
t.Fatalf("unlockWarningTimes(%v) = %v, want %v", test.duration, got, test.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != test.want[i] {
|
||||
t.Fatalf("unlockWarningTimes(%v) = %v, want %v", test.duration, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func latestHallInfo(messages []clientMessage) string {
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if messages[i].Type == "usermessage" && messages[i].Kind == "info" {
|
||||
message, _ := messages[i].Value.(string)
|
||||
return message
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func scheduledUnlockEntry(g *hall.Hall) *scheduledHallUnlock {
|
||||
hallUnlockSchedules.Lock()
|
||||
defer hallUnlockSchedules.Unlock()
|
||||
return hallUnlockSchedules.entries[g]
|
||||
}
|
||||
|
||||
func TestScheduledUnlockRequiresIssuingOperator(t *testing.T) {
|
||||
hallName := newChalkboardTestHall(t)
|
||||
operator := addTestWebClient(t, hallName, "Operator", "op")
|
||||
participant := addTestWebClient(t, hallName, "Participant", "present")
|
||||
g := operator.hall
|
||||
g.SetLocked(true, "")
|
||||
t.Cleanup(func() { cancelScheduledHallUnlock(g) })
|
||||
|
||||
now := time.Now()
|
||||
if err := scheduleHallUnlock(g, operator, now.Add(30*time.Minute), now); err != nil {
|
||||
t.Fatalf("scheduleHallUnlock: %v", err)
|
||||
}
|
||||
entry := scheduledUnlockEntry(g)
|
||||
if entry == nil {
|
||||
t.Fatal("scheduled unlock was not stored")
|
||||
}
|
||||
if got := len(entry.timers); got != 4 {
|
||||
t.Fatalf("30-minute schedule has %d timers, want 4", got)
|
||||
}
|
||||
if got := latestHallInfo(drainMessages(participant)); !strings.Contains(got, "30 minutes") {
|
||||
t.Fatalf("schedule announcement = %q", got)
|
||||
}
|
||||
|
||||
hall.DelClient(operator)
|
||||
drainMessages(participant)
|
||||
finishScheduledUnlock(entry)
|
||||
if locked, _ := g.Locked(); !locked {
|
||||
t.Fatal("hall unlocked after issuing operator left")
|
||||
}
|
||||
if got := latestHallInfo(drainMessages(participant)); !strings.Contains(got, "Operator is no longer present") {
|
||||
t.Fatalf("departure cancellation announcement = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduledUnlockCompletesWhileIssuerIsOperator(t *testing.T) {
|
||||
hallName := newChalkboardTestHall(t)
|
||||
operator := addTestWebClient(t, hallName, "Operator", "op")
|
||||
g := operator.hall
|
||||
g.SetLocked(true, "")
|
||||
t.Cleanup(func() { cancelScheduledHallUnlock(g) })
|
||||
|
||||
now := time.Now()
|
||||
if err := scheduleHallUnlock(g, operator, now.Add(6*time.Minute), now); err != nil {
|
||||
t.Fatalf("scheduleHallUnlock: %v", err)
|
||||
}
|
||||
entry := scheduledUnlockEntry(g)
|
||||
drainMessages(operator)
|
||||
finishScheduledUnlock(entry)
|
||||
if locked, _ := g.Locked(); locked {
|
||||
t.Fatal("hall remained locked while issuing operator was present")
|
||||
}
|
||||
if got := latestHallInfo(drainMessages(operator)); got != "Hall unlocked" {
|
||||
t.Fatalf("unlock announcement = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduledUnlockRequiresOperatorPermissionAtDeadline(t *testing.T) {
|
||||
hallName := newChalkboardTestHall(t)
|
||||
operator := addTestWebClient(t, hallName, "Operator", "op")
|
||||
participant := addTestWebClient(t, hallName, "Participant", "present")
|
||||
g := operator.hall
|
||||
g.SetLocked(true, "")
|
||||
t.Cleanup(func() { cancelScheduledHallUnlock(g) })
|
||||
|
||||
now := time.Now()
|
||||
if err := scheduleHallUnlock(g, operator, now.Add(15*time.Minute), now); err != nil {
|
||||
t.Fatalf("scheduleHallUnlock: %v", err)
|
||||
}
|
||||
entry := scheduledUnlockEntry(g)
|
||||
drainMessages(participant)
|
||||
operator.SetPermissions([]string{"present"})
|
||||
finishScheduledUnlock(entry)
|
||||
if locked, _ := g.Locked(); !locked {
|
||||
t.Fatal("hall unlocked after issuer lost operator permission")
|
||||
}
|
||||
if got := latestHallInfo(drainMessages(participant)); !strings.Contains(got, "Operator is no longer an operator") {
|
||||
t.Fatalf("permission cancellation announcement = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduledUnlockRequestReplacesPreviousSchedule(t *testing.T) {
|
||||
hallName := newChalkboardTestHall(t)
|
||||
operator := addTestWebClient(t, hallName, "Operator", "op")
|
||||
g := operator.hall
|
||||
g.SetLocked(true, "")
|
||||
t.Cleanup(func() { cancelScheduledHallUnlock(g) })
|
||||
|
||||
location, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLocation: %v", err)
|
||||
}
|
||||
now := time.Date(2026, time.July, 21, 15, 0, 0, 0, location)
|
||||
firstDeadline, err := parseScheduledUnlock(
|
||||
unlockRequest("30m", "America/New_York", 240), now,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("parse first schedule: %v", err)
|
||||
}
|
||||
if err := scheduleHallUnlock(g, operator, firstDeadline, now); err != nil {
|
||||
t.Fatalf("schedule first unlock: %v", err)
|
||||
}
|
||||
first := scheduledUnlockEntry(g)
|
||||
drainMessages(operator)
|
||||
|
||||
secondDeadline, err := parseScheduledUnlock(
|
||||
unlockRequest("20m", "America/New_York", 240), now,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("parse second schedule: %v", err)
|
||||
}
|
||||
if err := scheduleHallUnlock(g, operator, secondDeadline, now); err != nil {
|
||||
t.Fatalf("schedule second unlock: %v", err)
|
||||
}
|
||||
second := scheduledUnlockEntry(g)
|
||||
if second == nil || second == first {
|
||||
t.Fatal("second request did not replace the first schedule")
|
||||
}
|
||||
if got := latestHallInfo(drainMessages(operator)); !strings.Contains(got, "Previous scheduled unlock replaced") {
|
||||
t.Fatalf("replacement announcement = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimedUnlockHallActionCreatesSchedule(t *testing.T) {
|
||||
hallName := newChalkboardTestHall(t)
|
||||
operator := addTestWebClient(t, hallName, "Operator", "op")
|
||||
g := operator.hall
|
||||
g.SetLocked(true, "")
|
||||
t.Cleanup(func() { cancelScheduledHallUnlock(g) })
|
||||
oldUnlockNow := unlockNow
|
||||
location, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLocation: %v", err)
|
||||
}
|
||||
unlockNow = func() time.Time {
|
||||
return time.Date(2026, time.July, 21, 15, 0, 0, 0, location)
|
||||
}
|
||||
t.Cleanup(func() { unlockNow = oldUnlockNow })
|
||||
|
||||
if err := handleClientMessage(operator, clientMessage{
|
||||
Type: "hallaction",
|
||||
Kind: "unlock",
|
||||
Value: unlockRequest("30m", "America/New_York", 240),
|
||||
}); err != nil {
|
||||
t.Fatalf("timed unlock hall action: %v", err)
|
||||
}
|
||||
entry := scheduledUnlockEntry(g)
|
||||
if entry == nil {
|
||||
t.Fatal("timed unlock hall action did not create a schedule")
|
||||
}
|
||||
if got := latestHallInfo(drainMessages(operator)); !strings.Contains(got, "30 minutes") {
|
||||
t.Fatalf("timed unlock announcement = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImmediateUnlockCommandCancelsScheduledUnlock(t *testing.T) {
|
||||
hallName := newChalkboardTestHall(t)
|
||||
operator := addTestWebClient(t, hallName, "Operator", "op")
|
||||
g := operator.hall
|
||||
g.SetLocked(true, "")
|
||||
t.Cleanup(func() { cancelScheduledHallUnlock(g) })
|
||||
|
||||
now := time.Now()
|
||||
if err := scheduleHallUnlock(g, operator, now.Add(30*time.Minute), now); err != nil {
|
||||
t.Fatalf("scheduleHallUnlock: %v", err)
|
||||
}
|
||||
entry := scheduledUnlockEntry(g)
|
||||
drainMessages(operator)
|
||||
if err := handleClientMessage(operator, clientMessage{
|
||||
Type: "hallaction",
|
||||
Kind: "unlock",
|
||||
}); err != nil {
|
||||
t.Fatalf("immediate unlock hall action: %v", err)
|
||||
}
|
||||
if scheduledUnlockEntry(g) != nil {
|
||||
t.Fatal("immediate unlock left scheduled unlock active")
|
||||
}
|
||||
finishScheduledUnlock(entry)
|
||||
if locked, _ := g.Locked(); locked {
|
||||
t.Fatal("immediate unlock left hall locked")
|
||||
}
|
||||
if got := latestHallInfo(drainMessages(operator)); got != "Hall unlocked" {
|
||||
t.Fatalf("immediate unlock announcement = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockCommandCancelsScheduledUnlock(t *testing.T) {
|
||||
hallName := newChalkboardTestHall(t)
|
||||
operator := addTestWebClient(t, hallName, "Operator", "op")
|
||||
g := operator.hall
|
||||
g.SetLocked(true, "")
|
||||
t.Cleanup(func() { cancelScheduledHallUnlock(g) })
|
||||
|
||||
now := time.Now()
|
||||
if err := scheduleHallUnlock(g, operator, now.Add(30*time.Minute), now); err != nil {
|
||||
t.Fatalf("scheduleHallUnlock: %v", err)
|
||||
}
|
||||
entry := scheduledUnlockEntry(g)
|
||||
drainMessages(operator)
|
||||
if err := handleClientMessage(operator, clientMessage{
|
||||
Type: "hallaction",
|
||||
Kind: "lock",
|
||||
Value: "Remain closed",
|
||||
}); err != nil {
|
||||
t.Fatalf("lock hall action: %v", err)
|
||||
}
|
||||
if scheduledUnlockEntry(g) != nil {
|
||||
t.Fatal("lock command left scheduled unlock active")
|
||||
}
|
||||
if locked, message := g.Locked(); !locked || message != "Remain closed" {
|
||||
t.Fatalf("lock state = %v, %q", locked, message)
|
||||
}
|
||||
finishScheduledUnlock(entry)
|
||||
if locked, message := g.Locked(); !locked || message != "Remain closed" {
|
||||
t.Fatalf("cancelled deadline changed lock state to %v, %q", locked, message)
|
||||
}
|
||||
messages := drainMessages(operator)
|
||||
foundCancellation := false
|
||||
for _, message := range messages {
|
||||
if message.Kind == "info" && strings.Contains(message.Value.(string), "locked again") {
|
||||
foundCancellation = true
|
||||
}
|
||||
}
|
||||
if !foundCancellation {
|
||||
t.Fatalf("lock messages missing scheduled-unlock cancellation: %#v", messages)
|
||||
}
|
||||
}
|
||||
+42
-28
@@ -114,17 +114,18 @@ func broadcastChalkboard(g *hall.Hall, state hall.ChalkboardState) {
|
||||
}
|
||||
|
||||
type webClient struct {
|
||||
hall *hall.Hall
|
||||
addr net.Addr
|
||||
id string
|
||||
username string
|
||||
permissions []string
|
||||
data map[string]interface{}
|
||||
requested map[string][]string
|
||||
done chan struct{}
|
||||
writeCh chan interface{}
|
||||
writerDone chan struct{}
|
||||
actions *unbounded.Channel[any]
|
||||
hall *hall.Hall
|
||||
addr net.Addr
|
||||
id string
|
||||
username string
|
||||
permissions []string
|
||||
data map[string]interface{}
|
||||
requested map[string][]string
|
||||
done chan struct{}
|
||||
writeCh chan interface{}
|
||||
writerDone chan struct{}
|
||||
actions *unbounded.Channel[any]
|
||||
permissionsMu sync.RWMutex
|
||||
|
||||
mu sync.Mutex
|
||||
down map[string]*rtpDownConnection
|
||||
@@ -152,7 +153,9 @@ func (c *webClient) SetUsername(username string) {
|
||||
}
|
||||
|
||||
func (c *webClient) Permissions() []string {
|
||||
return c.permissions
|
||||
c.permissionsMu.RLock()
|
||||
defer c.permissionsMu.RUnlock()
|
||||
return append([]string(nil), c.permissions...)
|
||||
}
|
||||
|
||||
func (c *webClient) Data() map[string]interface{} {
|
||||
@@ -160,7 +163,9 @@ func (c *webClient) Data() map[string]interface{} {
|
||||
}
|
||||
|
||||
func (c *webClient) SetPermissions(perms []string) {
|
||||
c.permissions = perms
|
||||
c.permissionsMu.Lock()
|
||||
c.permissions = append([]string(nil), perms...)
|
||||
c.permissionsMu.Unlock()
|
||||
}
|
||||
|
||||
func (c *webClient) PushClient(hall, kind, id string, username string, perms []string, data map[string]interface{}) error {
|
||||
@@ -1249,11 +1254,16 @@ func handleAction(c *webClient, a any) error {
|
||||
}
|
||||
}
|
||||
case changePermissionsAction:
|
||||
allowRecording := false
|
||||
if a.kind == "op" {
|
||||
g := c.Hall()
|
||||
allowRecording = g != nil && g.Description().AllowRecording
|
||||
}
|
||||
c.permissionsMu.Lock()
|
||||
switch a.kind {
|
||||
case "op":
|
||||
c.permissions = addnew("op", c.permissions)
|
||||
g := c.Hall()
|
||||
if g != nil && g.Description().AllowRecording {
|
||||
if allowRecording {
|
||||
c.permissions = addnew("record", c.permissions)
|
||||
}
|
||||
case "unop":
|
||||
@@ -1272,15 +1282,17 @@ func handleAction(c *webClient, a any) error {
|
||||
case "unchalkboard":
|
||||
c.permissions = remove("chalkboard", c.permissions)
|
||||
default:
|
||||
c.permissionsMu.Unlock()
|
||||
return hall.UserError("unknown permission")
|
||||
}
|
||||
c.permissionsMu.Unlock()
|
||||
c.action(permissionsChangedAction{})
|
||||
case permissionsChangedAction:
|
||||
g := c.Hall()
|
||||
if g == nil {
|
||||
return errors.New("Permissions changed in no hall")
|
||||
}
|
||||
perms := append([]string(nil), c.permissions...)
|
||||
perms := c.Permissions()
|
||||
status := g.Status(true, nil)
|
||||
status.Recording = hallRecording(g)
|
||||
username := c.username
|
||||
@@ -1367,7 +1379,7 @@ func leaveHall(c *webClient) {
|
||||
}
|
||||
|
||||
hall.DelClient(c)
|
||||
c.permissions = nil
|
||||
c.SetPermissions(nil)
|
||||
c.data = nil
|
||||
c.requested = make(map[string][]string)
|
||||
c.hall = nil
|
||||
@@ -1606,11 +1618,7 @@ func handleClientMessage(c *webClient, m clientMessage) error {
|
||||
return c.error(hall.UserError("join a hall first"))
|
||||
}
|
||||
|
||||
required := "message"
|
||||
if m.Type == "chat" && m.Kind == "caption" {
|
||||
required = "caption"
|
||||
}
|
||||
if !member(required, c.permissions) {
|
||||
if !member("message", c.permissions) {
|
||||
return c.error(hall.UserError("not authorised"))
|
||||
}
|
||||
|
||||
@@ -1723,17 +1731,23 @@ func handleClientMessage(c *webClient, m clientMessage) error {
|
||||
if !member("op", c.permissions) {
|
||||
return c.error(hall.UserError("not authorised"))
|
||||
}
|
||||
if m.Kind == "unlock" && m.Value != nil {
|
||||
now := unlockNow()
|
||||
deadline, err := parseScheduledUnlock(m.Value, now)
|
||||
if err != nil {
|
||||
return c.error(err)
|
||||
}
|
||||
if err := scheduleHallUnlock(g, c, deadline, now); err != nil {
|
||||
return c.error(err)
|
||||
}
|
||||
break
|
||||
}
|
||||
message := ""
|
||||
v, ok := m.Value.(string)
|
||||
if ok {
|
||||
message = v
|
||||
}
|
||||
g.SetLocked(m.Kind == "lock", message)
|
||||
if m.Kind == "lock" {
|
||||
broadcastHallInfo(g, "Hall locked")
|
||||
} else {
|
||||
broadcastHallInfo(g, "Hall unlocked")
|
||||
}
|
||||
setHallLockState(g, m.Kind == "lock", message)
|
||||
case "record":
|
||||
if !member("record", c.permissions) {
|
||||
return c.error(hall.UserError("not authorised"))
|
||||
|
||||
+26
-4
@@ -306,7 +306,7 @@ A chat message may be sent using a `chat` message.
|
||||
```javascript
|
||||
{
|
||||
type: 'chat',
|
||||
kind: null or 'me' or 'caption',
|
||||
kind: null or 'me',
|
||||
source: source-id,
|
||||
username: username,
|
||||
dest: dest-id,
|
||||
@@ -320,9 +320,7 @@ A chat message may be sent using a `chat` message.
|
||||
The field `kind` can have one of the following values:
|
||||
|
||||
- `null` or the empty string, a normal chat message;
|
||||
- `'me'`, an IRC-style first-person message;
|
||||
- `'caption'`, a caption or subtitle (this requires the sender to have
|
||||
the `caption` permission).
|
||||
- `'me'`, an IRC-style first-person message.
|
||||
|
||||
If `dest` is empty, the message is a broadcast message, destined to all of
|
||||
the clients in the hall. If `source` is empty, then the message was
|
||||
@@ -415,6 +413,30 @@ temporary `chalkboard` permission:
|
||||
}
|
||||
```
|
||||
|
||||
An `unlock` action without a value unlocks the hall immediately. A browser
|
||||
may request an unlock later on the same local calendar day by including the
|
||||
user's command text and timezone information:
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: 'hallaction',
|
||||
kind: 'unlock',
|
||||
value: {
|
||||
spec: '30m',
|
||||
timeZone: 'America/New_York',
|
||||
timezoneOffset: 240
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `spec` is either a number of minutes followed by `m` or `M`, a 24-hour
|
||||
time such as `15:30`, or a 12-hour time with `AM` or `PM`. The server owns the
|
||||
timer, rejects past and next-day times, and only unlocks if the operator who
|
||||
scheduled it is still present in the same session with operator permission.
|
||||
A new timed request replaces the previous one. An immediate `unlock` or any
|
||||
`lock` action cancels a pending timer. Longer schedules broadcast polite
|
||||
countdowns; a 30-minute schedule announces 20, 10, and 5 minutes remaining.
|
||||
|
||||
|
||||
# Peer-to-peer file transfer protocol
|
||||
|
||||
|
||||
@@ -434,7 +434,6 @@ following strings:
|
||||
- `message`: a user with the right to send chat messages;
|
||||
- `observe`: a user that receives media streams and chat messages, but
|
||||
is not allowed to send them;
|
||||
- `caption`: a user with the right to display captions (only);
|
||||
- `admin`: a user with the right to administer the hall (only).
|
||||
|
||||
Usernames must be non-empty and unique among connected non-system clients in a
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestInteractiveHall(t *testing.T) {
|
||||
|
||||
func TestFormatPermissions(t *testing.T) {
|
||||
tests := []struct{ j, v, p string }{
|
||||
{`"op"`, "op", "[cmopt]"},
|
||||
{`"op"`, "op", "[mopt]"},
|
||||
{`"present"`, "present", "[mp]"},
|
||||
{`"observe"`, "observe", "[]"},
|
||||
{`"admin"`, "admin", "[a]"},
|
||||
|
||||
@@ -1220,26 +1220,6 @@ header .collapse:hover {
|
||||
|
||||
}
|
||||
|
||||
#captions-container {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1390;
|
||||
alpha: 0;
|
||||
}
|
||||
|
||||
#captions {
|
||||
margin: auto;
|
||||
padding: 0 0.4rem 0 0.4rem;
|
||||
width: max-content;
|
||||
font-size: 1.2rem;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
background-color: rgba(255,255,255,.7);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
:root {
|
||||
--skald-menu-bg: #eee;
|
||||
--skald-menu-shadow: 1px 1px 1px #444;
|
||||
|
||||
@@ -84,9 +84,6 @@
|
||||
<button class="chat-btn show-chat invisible" id="show-chat" aria-label="Show chat">
|
||||
<i class="far fa-comment-alt icon-chat" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div id="captions-container" class="invisible">
|
||||
<div id="captions"></div>
|
||||
</div>
|
||||
<div class="login-container invisible" id="login-container">
|
||||
<div class="login-box">
|
||||
<form id="loginform" class="loginform">
|
||||
|
||||
+30
-47
@@ -2417,7 +2417,7 @@ function gotUserMessage(id, dest, username, time, privileged, kind, error, messa
|
||||
}
|
||||
let text = '' + message;
|
||||
localMessage(text);
|
||||
displayMessage(text);
|
||||
displayMessage(text, false);
|
||||
break;
|
||||
}
|
||||
case 'recording': {
|
||||
@@ -2881,11 +2881,6 @@ function announceUrgent(text) {
|
||||
* @param {string|HTMLElement} message
|
||||
*/
|
||||
function addToChatbox(id, peerId, dest, nick, time, privileged, history, kind, message) {
|
||||
if(kind === 'caption') {
|
||||
displayCaption(message);
|
||||
return;
|
||||
}
|
||||
|
||||
let row = document.createElement('div');
|
||||
row.classList.add('message-row');
|
||||
let container = document.createElement('div');
|
||||
@@ -3046,38 +3041,6 @@ function chatMessageMenu(elt, trigger) {
|
||||
openActionMenu(items, trigger, `Actions for message from ${u}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|HTMLElement} message
|
||||
*/
|
||||
function setCaption(message) {
|
||||
let container = document.getElementById('captions-container');
|
||||
let captions = document.getElementById('captions');
|
||||
if(!message) {
|
||||
captions.replaceChildren();
|
||||
container.classList.add('invisible');
|
||||
} else {
|
||||
if(message instanceof HTMLElement)
|
||||
captions.replaceChildren(message);
|
||||
else
|
||||
captions.textContent = message;
|
||||
container.classList.remove('invisible');
|
||||
}
|
||||
}
|
||||
|
||||
let captionsTimer = null;
|
||||
|
||||
/**
|
||||
* @param {string|HTMLElement} message
|
||||
*/
|
||||
function displayCaption(message) {
|
||||
if(captionsTimer != null) {
|
||||
clearTimeout(captionsTimer);
|
||||
captionsTimer = null;
|
||||
}
|
||||
setCaption(message);
|
||||
captionsTimer = setTimeout(() => setCaption(null), 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|HTMLElement} message
|
||||
*/
|
||||
@@ -3241,9 +3204,25 @@ commands.lock = {
|
||||
|
||||
commands.unlock = {
|
||||
predicate: operatorPredicate,
|
||||
description: 'unlock this hall, revert the effect of /lock',
|
||||
description: 'unlock this hall now or later today',
|
||||
parameters: '[minutes[m|M] | HH:MM | h:mm AM/PM]',
|
||||
f: (c, r) => {
|
||||
serverConnection.hallAction('unlock');
|
||||
let spec = r.trim();
|
||||
if(!spec) {
|
||||
serverConnection.hallAction('unlock');
|
||||
return;
|
||||
}
|
||||
let timeZone = '';
|
||||
try {
|
||||
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||
} catch(e) {
|
||||
console.warn('Could not determine browser timezone', e);
|
||||
}
|
||||
serverConnection.hallAction('unlock', {
|
||||
spec: spec,
|
||||
timeZone: timeZone,
|
||||
timezoneOffset: new Date().getTimezoneOffset(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3943,8 +3922,9 @@ document.getElementById('resizer').addEventListener('keydown', function(e) {
|
||||
/**
|
||||
* @param {unknown} message
|
||||
* @param {string} [level]
|
||||
* @param {boolean} [announce]
|
||||
*/
|
||||
function displayError(message, level) {
|
||||
function displayError(message, level, announce = true) {
|
||||
if(!level)
|
||||
level = "error";
|
||||
let text = String(message);
|
||||
@@ -3963,10 +3943,12 @@ function displayError(message, level) {
|
||||
break;
|
||||
}
|
||||
|
||||
if(level === "info")
|
||||
announceChat(text);
|
||||
else
|
||||
announceUrgent(text);
|
||||
if(announce) {
|
||||
if(level === "info")
|
||||
announceChat(text);
|
||||
else
|
||||
announceUrgent(text);
|
||||
}
|
||||
|
||||
/** @ts-ignore */
|
||||
Toastify({
|
||||
@@ -3988,9 +3970,10 @@ function displayWarning(message) {
|
||||
|
||||
/**
|
||||
* @param {unknown} message
|
||||
* @param {boolean} [announce]
|
||||
*/
|
||||
function displayMessage(message) {
|
||||
return displayError(message, "info");
|
||||
function displayMessage(message, announce = true) {
|
||||
return displayError(message, "info", announce);
|
||||
}
|
||||
|
||||
document.getElementById('loginform').onsubmit = async function(e) {
|
||||
|
||||
@@ -69,6 +69,16 @@ func TestStaticLiveRegionAnnouncementsStayWired(t *testing.T) {
|
||||
requireFunctionContains(t, js, "setUserStatus", "announceUrgent(`${username}: hand lowered`)")
|
||||
}
|
||||
|
||||
func TestCaptionOverlayHasBeenRemoved(t *testing.T) {
|
||||
html := readStaticFile(t, "skald.html")
|
||||
js := readStaticFile(t, "skald.js")
|
||||
css := readStaticFile(t, "skald.css")
|
||||
|
||||
requireNotContains(t, html, "captions", "hall page")
|
||||
requireNotContains(t, js, "caption", "hall script")
|
||||
requireNotContains(t, css, "caption", "hall styles")
|
||||
}
|
||||
|
||||
func TestHallHeadingIncludesCurrentStatus(t *testing.T) {
|
||||
html := readStaticFile(t, "skald.html")
|
||||
js := readStaticFile(t, "skald.js")
|
||||
@@ -98,6 +108,18 @@ func TestGlobalShortcutAliasesStayDocumented(t *testing.T) {
|
||||
requireContains(t, js, "if(isGlobalShortcut(e, 't'))", "microphone shortcut")
|
||||
}
|
||||
|
||||
func TestTimedUnlockCommandStaysDocumentedAndUsesServerAnnouncements(t *testing.T) {
|
||||
js := readStaticFile(t, "skald.js")
|
||||
|
||||
requireContains(t, js, "[minutes[m|M] | HH:MM | h:mm AM/PM]", "unlock command help")
|
||||
requireContains(t, js, "Intl.DateTimeFormat().resolvedOptions().timeZone", "unlock timezone")
|
||||
requireContains(t, js, "timezoneOffset: new Date().getTimezoneOffset()", "unlock timezone fallback")
|
||||
requireContains(t, js, "serverConnection.hallAction('unlock', {", "scheduled unlock request")
|
||||
requireFunctionContains(t, js, "gotUserMessage", "displayMessage(text, false)")
|
||||
requireFunctionContains(t, js, "displayError", "if(announce)")
|
||||
requireFunctionContains(t, js, "displayError", "announceChat(text)")
|
||||
}
|
||||
|
||||
func TestMicrophoneHardStopLivesInSelfMenu(t *testing.T) {
|
||||
html := readStaticFile(t, "skald.html")
|
||||
js := readStaticFile(t, "skald.js")
|
||||
|
||||
Reference in New Issue
Block a user