Compare commits
2 Commits
2026.07.08
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a94f893b6 | |||
| 704b1a9558 |
@@ -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
@@ -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"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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{})
|
||||||
|
|||||||
@@ -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 {
|
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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]"},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user