2 Commits

Author SHA1 Message Date
Storm Dragon 6a94f893b6 Unlock commands now support a given time, e.g 10 minutes. 2026-07-22 21:37:34 -04:00
Storm Dragon 704b1a9558 Removed unused captions code. 2026-07-09 11:15:35 -04:00
15 changed files with 961 additions and 132 deletions
+6 -11
View File
@@ -22,17 +22,12 @@ server.
## Workflow ## Workflow
- Use `todo.txt` as the source of truth for staged work. - Skald is production software; prefer small, scoped maintenance changes with
- Preserve the todo status markers: focused verification.
- `-` means open. - Use a temporary local plan file only for large or risky batches that need a
- `+` means implemented, needs testing. resumable checklist.
- `X` means tested and done. - Building the Arch package updates `distro-packages/Arch-Linux/skald-git/PKGBUILD`
- Work in small batches and update `todo.txt` only for items actually completed with the generated `pkgver`, so this file is often dirty after package builds.
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.
- Keep changes scoped to the current batch. Do not mix in unrelated cleanup. - 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 - Before behavior changes, inspect the current code path and tests rather than
assuming Galene behavior still applies. assuming Galene behavior still applies.
@@ -2,7 +2,7 @@
# shellcheck shell=bash disable=SC2034,SC2154 # shellcheck shell=bash disable=SC2034,SC2154
pkgname=skald-git pkgname=skald-git
pkgver=2026.07.08 pkgver=2026.07.08.r1.g704b1a9
pkgrel=1 pkgrel=1
pkgdesc='Audio-only hall-based conferencing server' pkgdesc='Audio-only hall-based conferencing server'
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
+1 -2
View File
@@ -34,11 +34,10 @@ type Permissions struct {
} }
var permissionsMap = map[string][]string{ var permissionsMap = map[string][]string{
"op": {"op", "present", "message", "caption", "token"}, "op": {"op", "present", "message", "token"},
"present": {"present", "message"}, "present": {"present", "message"},
"message": {"message"}, "message": {"message"},
"observe": {}, "observe": {},
"caption": {"caption"},
"admin": {"admin"}, "admin": {"admin"},
} }
+35
View File
@@ -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{} { func (g *Hall) Data() map[string]interface{} {
g.mu.Lock() g.mu.Lock()
defer g.mu.Unlock() defer g.mu.Unlock()
+6 -14
View File
@@ -396,8 +396,6 @@ var james = "james"
var paul = "paul" var paul = "paul"
var peter = "peter" var peter = "peter"
var admin = "admin" var admin = "admin"
var stt = "speech-to-text"
var badClients = []ClientCredentials{ var badClients = []ClientCredentials{
{Username: &jch, Password: "foo"}, {Username: &jch, Password: "foo"},
{Username: &john, Password: "foo"}, {Username: &john, Password: "foo"},
@@ -418,9 +416,7 @@ var desc2JSON = `
"john": {"password": "secret", "permissions": "present"}, "john": {"password": "secret", "permissions": "present"},
"james": {"password": "secret2", "permissions": "message"}, "james": {"password": "secret2", "permissions": "message"},
"peter": {"password": "secret4"}, "peter": {"password": "secret4"},
"admin": {"password": "admin", "permissions": "admin"}, "admin": {"password": "admin", "permissions": "admin"}
"speech-to-text": {"password": {"type": "wildcard"},
"permissions": "caption"}
}, },
"wildcard-user": "wildcard-user":
{"permissions": "message", "password": {"type":"wildcard"}} {"permissions": "message", "password": {"type":"wildcard"}}
@@ -429,7 +425,7 @@ var desc2JSON = `
var goodClients = []credPerm{ var goodClients = []credPerm{
{ {
ClientCredentials{Username: &jch, Password: "topsecret"}, ClientCredentials{Username: &jch, Password: "topsecret"},
[]string{"op", "present", "message", "caption", "token"}, []string{"op", "present", "message", "token"},
}, },
{ {
ClientCredentials{Username: &john, Password: "secret"}, ClientCredentials{Username: &john, Password: "secret"},
@@ -451,10 +447,6 @@ var goodClients = []credPerm{
ClientCredentials{Username: &admin, Password: "admin"}, ClientCredentials{Username: &admin, Password: "admin"},
[]string{"admin"}, []string{"admin"},
}, },
{
ClientCredentials{Username: &stt},
[]string{"caption"},
},
} }
func TestPermissions(t *testing.T) { 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("john", []string{"present", "message"})
doit("james", []string{}) doit("james", []string{})
@@ -521,7 +513,7 @@ func TestExtraPermissions(t *testing.T) {
d.UnrestrictedTokens = false d.UnrestrictedTokens = false
doit("jch", []string{ doit("jch", []string{
"op", "record", "token", "present", "message", "caption", "op", "record", "token", "present", "message",
}) })
doit("john", []string{"present", "message"}) doit("john", []string{"present", "message"})
doit("james", []string{}) doit("james", []string{})
@@ -529,7 +521,7 @@ func TestExtraPermissions(t *testing.T) {
d.AllowRecording = false d.AllowRecording = false
d.UnrestrictedTokens = true 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("john", []string{"token", "present", "message"})
doit("james", []string{}) doit("james", []string{})
@@ -537,7 +529,7 @@ func TestExtraPermissions(t *testing.T) {
d.UnrestrictedTokens = true d.UnrestrictedTokens = true
doit("jch", []string{ doit("jch", []string{
"op", "record", "token", "present", "message", "caption", "op", "record", "token", "present", "message",
}) })
doit("john", []string{"token", "present", "message"}) doit("john", []string{"token", "present", "message"})
doit("james", []string{}) doit("james", []string{})
+357
View File
@@ -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()
}
}
+434
View File
@@ -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
View File
@@ -114,17 +114,18 @@ func broadcastChalkboard(g *hall.Hall, state hall.ChalkboardState) {
} }
type webClient struct { type webClient struct {
hall *hall.Hall hall *hall.Hall
addr net.Addr addr net.Addr
id string id string
username string username string
permissions []string permissions []string
data map[string]interface{} data map[string]interface{}
requested map[string][]string requested map[string][]string
done chan struct{} done chan struct{}
writeCh chan interface{} writeCh chan interface{}
writerDone chan struct{} writerDone chan struct{}
actions *unbounded.Channel[any] actions *unbounded.Channel[any]
permissionsMu sync.RWMutex
mu sync.Mutex mu sync.Mutex
down map[string]*rtpDownConnection down map[string]*rtpDownConnection
@@ -152,7 +153,9 @@ func (c *webClient) SetUsername(username string) {
} }
func (c *webClient) Permissions() []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{} { func (c *webClient) Data() map[string]interface{} {
@@ -160,7 +163,9 @@ func (c *webClient) Data() map[string]interface{} {
} }
func (c *webClient) SetPermissions(perms []string) { 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 { 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: case changePermissionsAction:
allowRecording := false
if a.kind == "op" {
g := c.Hall()
allowRecording = g != nil && g.Description().AllowRecording
}
c.permissionsMu.Lock()
switch a.kind { switch a.kind {
case "op": case "op":
c.permissions = addnew("op", c.permissions) c.permissions = addnew("op", c.permissions)
g := c.Hall() if allowRecording {
if g != nil && g.Description().AllowRecording {
c.permissions = addnew("record", c.permissions) c.permissions = addnew("record", c.permissions)
} }
case "unop": case "unop":
@@ -1272,15 +1282,17 @@ func handleAction(c *webClient, a any) error {
case "unchalkboard": case "unchalkboard":
c.permissions = remove("chalkboard", c.permissions) c.permissions = remove("chalkboard", c.permissions)
default: default:
c.permissionsMu.Unlock()
return hall.UserError("unknown permission") return hall.UserError("unknown permission")
} }
c.permissionsMu.Unlock()
c.action(permissionsChangedAction{}) c.action(permissionsChangedAction{})
case permissionsChangedAction: case permissionsChangedAction:
g := c.Hall() g := c.Hall()
if g == nil { if g == nil {
return errors.New("Permissions changed in no hall") return errors.New("Permissions changed in no hall")
} }
perms := append([]string(nil), c.permissions...) perms := c.Permissions()
status := g.Status(true, nil) status := g.Status(true, nil)
status.Recording = hallRecording(g) status.Recording = hallRecording(g)
username := c.username username := c.username
@@ -1367,7 +1379,7 @@ func leaveHall(c *webClient) {
} }
hall.DelClient(c) hall.DelClient(c)
c.permissions = nil c.SetPermissions(nil)
c.data = nil c.data = nil
c.requested = make(map[string][]string) c.requested = make(map[string][]string)
c.hall = nil c.hall = nil
@@ -1606,11 +1618,7 @@ func handleClientMessage(c *webClient, m clientMessage) error {
return c.error(hall.UserError("join a hall first")) return c.error(hall.UserError("join a hall first"))
} }
required := "message" if !member("message", c.permissions) {
if m.Type == "chat" && m.Kind == "caption" {
required = "caption"
}
if !member(required, c.permissions) {
return c.error(hall.UserError("not authorised")) return c.error(hall.UserError("not authorised"))
} }
@@ -1723,17 +1731,23 @@ func handleClientMessage(c *webClient, m clientMessage) error {
if !member("op", c.permissions) { if !member("op", c.permissions) {
return c.error(hall.UserError("not authorised")) 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 := "" message := ""
v, ok := m.Value.(string) v, ok := m.Value.(string)
if ok { if ok {
message = v message = v
} }
g.SetLocked(m.Kind == "lock", message) setHallLockState(g, m.Kind == "lock", message)
if m.Kind == "lock" {
broadcastHallInfo(g, "Hall locked")
} else {
broadcastHallInfo(g, "Hall unlocked")
}
case "record": case "record":
if !member("record", c.permissions) { if !member("record", c.permissions) {
return c.error(hall.UserError("not authorised")) return c.error(hall.UserError("not authorised"))
+26 -4
View File
@@ -306,7 +306,7 @@ A chat message may be sent using a `chat` message.
```javascript ```javascript
{ {
type: 'chat', type: 'chat',
kind: null or 'me' or 'caption', kind: null or 'me',
source: source-id, source: source-id,
username: username, username: username,
dest: dest-id, 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: The field `kind` can have one of the following values:
- `null` or the empty string, a normal chat message; - `null` or the empty string, a normal chat message;
- `'me'`, an IRC-style first-person message; - `'me'`, an IRC-style first-person message.
- `'caption'`, a caption or subtitle (this requires the sender to have
the `caption` permission).
If `dest` is empty, the message is a broadcast message, destined to all of 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 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 # Peer-to-peer file transfer protocol
-1
View File
@@ -434,7 +434,6 @@ following strings:
- `message`: a user with the right to send chat messages; - `message`: a user with the right to send chat messages;
- `observe`: a user that receives media streams and chat messages, but - `observe`: a user that receives media streams and chat messages, but
is not allowed to send them; 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). - `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 Usernames must be non-empty and unique among connected non-system clients in a
+1 -1
View File
@@ -97,7 +97,7 @@ func TestInteractiveHall(t *testing.T) {
func TestFormatPermissions(t *testing.T) { func TestFormatPermissions(t *testing.T) {
tests := []struct{ j, v, p string }{ tests := []struct{ j, v, p string }{
{`"op"`, "op", "[cmopt]"}, {`"op"`, "op", "[mopt]"},
{`"present"`, "present", "[mp]"}, {`"present"`, "present", "[mp]"},
{`"observe"`, "observe", "[]"}, {`"observe"`, "observe", "[]"},
{`"admin"`, "admin", "[a]"}, {`"admin"`, "admin", "[a]"},
-20
View File
@@ -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 { :root {
--skald-menu-bg: #eee; --skald-menu-bg: #eee;
--skald-menu-shadow: 1px 1px 1px #444; --skald-menu-shadow: 1px 1px 1px #444;
-3
View File
@@ -84,9 +84,6 @@
<button class="chat-btn show-chat invisible" id="show-chat" aria-label="Show chat"> <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> <i class="far fa-comment-alt icon-chat" aria-hidden="true"></i>
</button> </button>
<div id="captions-container" class="invisible">
<div id="captions"></div>
</div>
<div class="login-container invisible" id="login-container"> <div class="login-container invisible" id="login-container">
<div class="login-box"> <div class="login-box">
<form id="loginform" class="loginform"> <form id="loginform" class="loginform">
+30 -47
View File
@@ -2417,7 +2417,7 @@ function gotUserMessage(id, dest, username, time, privileged, kind, error, messa
} }
let text = '' + message; let text = '' + message;
localMessage(text); localMessage(text);
displayMessage(text); displayMessage(text, false);
break; break;
} }
case 'recording': { case 'recording': {
@@ -2881,11 +2881,6 @@ function announceUrgent(text) {
* @param {string|HTMLElement} message * @param {string|HTMLElement} message
*/ */
function addToChatbox(id, peerId, dest, nick, time, privileged, history, kind, message) { function addToChatbox(id, peerId, dest, nick, time, privileged, history, kind, message) {
if(kind === 'caption') {
displayCaption(message);
return;
}
let row = document.createElement('div'); let row = document.createElement('div');
row.classList.add('message-row'); row.classList.add('message-row');
let container = document.createElement('div'); let container = document.createElement('div');
@@ -3046,38 +3041,6 @@ function chatMessageMenu(elt, trigger) {
openActionMenu(items, trigger, `Actions for message from ${u}`); 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 * @param {string|HTMLElement} message
*/ */
@@ -3241,9 +3204,25 @@ commands.lock = {
commands.unlock = { commands.unlock = {
predicate: operatorPredicate, 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) => { 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 {unknown} message
* @param {string} [level] * @param {string} [level]
* @param {boolean} [announce]
*/ */
function displayError(message, level) { function displayError(message, level, announce = true) {
if(!level) if(!level)
level = "error"; level = "error";
let text = String(message); let text = String(message);
@@ -3963,10 +3943,12 @@ function displayError(message, level) {
break; break;
} }
if(level === "info") if(announce) {
announceChat(text); if(level === "info")
else announceChat(text);
announceUrgent(text); else
announceUrgent(text);
}
/** @ts-ignore */ /** @ts-ignore */
Toastify({ Toastify({
@@ -3988,9 +3970,10 @@ function displayWarning(message) {
/** /**
* @param {unknown} message * @param {unknown} message
* @param {boolean} [announce]
*/ */
function displayMessage(message) { function displayMessage(message, announce = true) {
return displayError(message, "info"); return displayError(message, "info", announce);
} }
document.getElementById('loginform').onsubmit = async function(e) { document.getElementById('loginform').onsubmit = async function(e) {
+22
View File
@@ -69,6 +69,16 @@ func TestStaticLiveRegionAnnouncementsStayWired(t *testing.T) {
requireFunctionContains(t, js, "setUserStatus", "announceUrgent(`${username}: hand lowered`)") 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) { func TestHallHeadingIncludesCurrentStatus(t *testing.T) {
html := readStaticFile(t, "skald.html") html := readStaticFile(t, "skald.html")
js := readStaticFile(t, "skald.js") js := readStaticFile(t, "skald.js")
@@ -98,6 +108,18 @@ func TestGlobalShortcutAliasesStayDocumented(t *testing.T) {
requireContains(t, js, "if(isGlobalShortcut(e, 't'))", "microphone shortcut") 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) { func TestMicrophoneHardStopLivesInSelfMenu(t *testing.T) {
html := readStaticFile(t, "skald.html") html := readStaticFile(t, "skald.html")
js := readStaticFile(t, "skald.js") js := readStaticFile(t, "skald.js")