From 9125e76d435703a989d55226c26b05c9576b3ff8 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Tue, 28 Jul 2026 17:22:24 -0400 Subject: [PATCH] Send notifications for events that are not announced when window is out of focus. --- distro-packages/Arch-Linux/skald-git/PKGBUILD | 2 +- rtpconn/unlock.go | 16 ++-- rtpconn/unlock_test.go | 56 ++++++++++++-- rtpconn/webclient.go | 4 +- skald-protocol.md | 5 ++ skald.md | 6 ++ static/protocol.js | 4 +- static/skald.js | 75 ++++++++++++++++++- webserver/static_accessibility_test.go | 12 +++ 9 files changed, 161 insertions(+), 19 deletions(-) diff --git a/distro-packages/Arch-Linux/skald-git/PKGBUILD b/distro-packages/Arch-Linux/skald-git/PKGBUILD index b599499..aa557b4 100644 --- a/distro-packages/Arch-Linux/skald-git/PKGBUILD +++ b/distro-packages/Arch-Linux/skald-git/PKGBUILD @@ -2,7 +2,7 @@ # shellcheck shell=bash disable=SC2034,SC2154 pkgname=skald-git -pkgver=2026.07.08.r1.g704b1a9 +pkgver=2026.07.08.r2.g6a94f89 pkgrel=1 pkgdesc='Audio-only hall-based conferencing server' arch=('x86_64' 'aarch64') diff --git a/rtpconn/unlock.go b/rtpconn/unlock.go index 76b76e3..9e16c81 100644 --- a/rtpconn/unlock.go +++ b/rtpconn/unlock.go @@ -262,7 +262,7 @@ func scheduleHallUnlock(g *hall.Hall, issuer *webClient, deadline, now time.Time } entry.mu.Lock() if entry.active { - broadcastHallInfo(g, message) + broadcastHallInfo(g, message, true) } entry.mu.Unlock() return nil @@ -280,7 +280,7 @@ func announceScheduledUnlock(entry *scheduledHallUnlock, remaining time.Duration } broadcastHallInfo(entry.hall, fmt.Sprintf( "Hall will unlock in %s.", formatMinutesUntil(remaining), - )) + ), true) } func finishScheduledUnlock(entry *scheduledHallUnlock) { @@ -296,17 +296,17 @@ func finishScheduledUnlock(entry *scheduledHallUnlock) { result := entry.hall.UnlockIfOperatorPresent(entry.issuer) switch result { case hall.ConditionalUnlockSucceeded: - broadcastHallInfo(entry.hall, "Hall unlocked") + broadcastHallInfo(entry.hall, "Hall unlocked", true) case hall.ConditionalUnlockClientAbsent: broadcastHallInfo(entry.hall, fmt.Sprintf( "Scheduled unlock cancelled because %s is no longer present in the hall.", entry.issuerName, - )) + ), true) case hall.ConditionalUnlockClientNotOperator: broadcastHallInfo(entry.hall, fmt.Sprintf( "Scheduled unlock cancelled because %s is no longer an operator.", entry.issuerName, - )) + ), true) } entry.mu.Unlock() hallUnlockSchedules.Unlock() @@ -322,11 +322,11 @@ func setHallLockState(g *hall.Hall, locked bool, message string) bool { g.SetLocked(locked, message) if locked { if entry != nil { - broadcastHallInfo(g, "Scheduled hall unlock cancelled because the hall was locked again.") + broadcastHallInfo(g, "Scheduled hall unlock cancelled because the hall was locked again.", true) } - broadcastHallInfo(g, "Hall locked") + broadcastHallInfo(g, "Hall locked", false) } else { - broadcastHallInfo(g, "Hall unlocked") + broadcastHallInfo(g, "Hall unlocked", false) } hallUnlockSchedules.Unlock() return entry != nil diff --git a/rtpconn/unlock_test.go b/rtpconn/unlock_test.go index 90c3567..7953565 100644 --- a/rtpconn/unlock_test.go +++ b/rtpconn/unlock_test.go @@ -191,13 +191,21 @@ func TestUnlockWarningTimes(t *testing.T) { } func latestHallInfo(messages []clientMessage) string { + message, ok := latestHallInfoMessage(messages) + if !ok { + return "" + } + text, _ := message.Value.(string) + return text +} + +func latestHallInfoMessage(messages []clientMessage) (clientMessage, bool) { 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 messages[i], true } } - return "" + return clientMessage{}, false } func scheduledUnlockEntry(g *hall.Hall) *scheduledHallUnlock { @@ -225,9 +233,28 @@ func TestScheduledUnlockRequiresIssuingOperator(t *testing.T) { 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") { + scheduleMessage, ok := latestHallInfoMessage(drainMessages(participant)) + if !ok { + t.Fatal("schedule announcement was not sent") + } + if got, _ := scheduleMessage.Value.(string); !strings.Contains(got, "30 minutes") { t.Fatalf("schedule announcement = %q", got) } + if !scheduleMessage.Notify { + t.Fatal("schedule announcement did not request a browser notification") + } + + announceScheduledUnlock(entry, 20*time.Minute) + warningMessage, ok := latestHallInfoMessage(drainMessages(participant)) + if !ok { + t.Fatal("countdown announcement was not sent") + } + if got, _ := warningMessage.Value.(string); got != "Hall will unlock in 20 minutes." { + t.Fatalf("countdown announcement = %q", got) + } + if !warningMessage.Notify { + t.Fatal("countdown announcement did not request a browser notification") + } hall.DelClient(operator) drainMessages(participant) @@ -257,9 +284,16 @@ func TestScheduledUnlockCompletesWhileIssuerIsOperator(t *testing.T) { if locked, _ := g.Locked(); locked { t.Fatal("hall remained locked while issuing operator was present") } - if got := latestHallInfo(drainMessages(operator)); got != "Hall unlocked" { + unlockMessage, ok := latestHallInfoMessage(drainMessages(operator)) + if !ok { + t.Fatal("unlock announcement was not sent") + } + if got, _ := unlockMessage.Value.(string); got != "Hall unlocked" { t.Fatalf("unlock announcement = %q", got) } + if !unlockMessage.Notify { + t.Fatal("scheduled unlock completion did not request a browser notification") + } } func TestScheduledUnlockRequiresOperatorPermissionAtDeadline(t *testing.T) { @@ -386,9 +420,16 @@ func TestImmediateUnlockCommandCancelsScheduledUnlock(t *testing.T) { if locked, _ := g.Locked(); locked { t.Fatal("immediate unlock left hall locked") } - if got := latestHallInfo(drainMessages(operator)); got != "Hall unlocked" { + unlockMessage, ok := latestHallInfoMessage(drainMessages(operator)) + if !ok { + t.Fatal("immediate unlock announcement was not sent") + } + if got, _ := unlockMessage.Value.(string); got != "Hall unlocked" { t.Fatalf("immediate unlock announcement = %q", got) } + if unlockMessage.Notify { + t.Fatal("immediate unlock requested a scheduled-unlock browser notification") + } } func TestLockCommandCancelsScheduledUnlock(t *testing.T) { @@ -426,6 +467,9 @@ func TestLockCommandCancelsScheduledUnlock(t *testing.T) { for _, message := range messages { if message.Kind == "info" && strings.Contains(message.Value.(string), "locked again") { foundCancellation = true + if !message.Notify { + t.Fatal("scheduled-unlock cancellation did not request a browser notification") + } } } if !foundCancellation { diff --git a/rtpconn/webclient.go b/rtpconn/webclient.go index b2be844..f0c4c2a 100644 --- a/rtpconn/webclient.go +++ b/rtpconn/webclient.go @@ -70,11 +70,12 @@ func broadcastRecordingStatus(g *hall.Hall, message string) { } } -func broadcastHallInfo(g *hall.Hall, message string) { +func broadcastHallInfo(g *hall.Hall, message string, notify bool) { err := broadcast(g.GetClients(nil), clientMessage{ Type: "usermessage", Kind: "info", Privileged: true, + Notify: notify, Value: message, }) if err != nil { @@ -188,6 +189,7 @@ type clientMessage struct { Password string `json:"password,omitempty"` Token string `json:"token,omitempty"` Privileged bool `json:"privileged,omitempty"` + Notify bool `json:"notify,omitempty"` Permissions []string `json:"permissions,omitempty"` Status *hall.Status `json:"status,omitempty"` Data map[string]interface{} `json:"data,omitempty"` diff --git a/skald-protocol.md b/skald-protocol.md index 7f89a61..678ae0c 100644 --- a/skald-protocol.md +++ b/skald-protocol.md @@ -348,6 +348,7 @@ chat history, and is not expected to contain user-visible content. username: username, dest: dest-id, privileged: boolean, + notify: boolean, value: value } ``` @@ -357,6 +358,10 @@ Currently defined kinds include `error`, `warning`, `info`, `kicked`, `chalkboard`, and `mute`. A `chalkboard` user message carries the current virtual chalkboard state: +When `notify` is true, the web client may also show the message as a browser +notification when the hall window is not active. The field is omitted when +false. + ```javascript { type: 'usermessage', diff --git a/skald.md b/skald.md index 4895788..e8a6654 100644 --- a/skald.md +++ b/skald.md @@ -66,6 +66,12 @@ recording control and an *Open recordings (opens in new tab)* action. *Open recordings (opens in new tab)* opens the hall's recording archive in a new tab, which lists completed recordings when there are multiple files. +The hall menu also includes an *Enable browser notifications* action until +notification permission has been granted or blocked. When enabled, timed +unlock status messages appear as browser notifications while the hall window +is not active. If notifications are blocked, permission must be changed in +the browser's site settings. + ### Chat pane The centre pane is a traditional chat interface, with an input form at the diff --git a/static/protocol.js b/static/protocol.js index bbe35ea..18170ae 100644 --- a/static/protocol.js +++ b/static/protocol.js @@ -224,7 +224,7 @@ function ServerConnection() { * 'id' is non-null, 'privileged' indicates whether the message was * sent by an operator. * - * @type {(this: ServerConnection, id: string, dest: string, username: string, time: Date, privileged: boolean, kind: string, error: string, message: unknown) => void} + * @type {(this: ServerConnection, id: string, dest: string, username: string, time: Date, privileged: boolean, kind: string, error: string, message: unknown, notify: boolean) => void} */ this.onusermessage = null; /** @@ -517,7 +517,7 @@ ServerConnection.prototype.connect = function(url) { else if(sc.onusermessage) sc.onusermessage.call( sc, m.source, m.dest, m.username, parseTime(m.time), - m.privileged, m.kind, m.error, m.value, + m.privileged, m.kind, m.error, m.value, !!m.notify, ); break; case 'ping': diff --git a/static/skald.js b/static/skald.js index 79fac8d..7415b17 100644 --- a/static/skald.js +++ b/static/skald.js @@ -278,6 +278,56 @@ function notificationSoundsEnabled() { return !!getSettings().notificationSounds; } +function browserNotificationsSupported() { + return 'Notification' in window; +} + +async function requestBrowserNotificationPermission() { + if(!browserNotificationsSupported()) { + displayWarning('This browser does not support browser notifications.'); + return; + } + + try { + let permission = await window.Notification.requestPermission(); + if(permission === 'granted') + displayMessage('Browser notifications enabled.'); + else + displayWarning( + 'Browser notifications are blocked. Allow them in your browser site settings.', + ); + } catch(e) { + console.error('Could not request browser notification permission:', e); + displayWarning('Could not request browser notification permission.'); + } +} + +/** + * @param {string} message + */ +function showBrowserNotification(message) { + if(!browserNotificationsSupported() || + window.Notification.permission !== 'granted') + return; + + let focused = typeof document.hasFocus !== 'function' || document.hasFocus(); + if(!document.hidden && focused) + return; + + let hallName = hallStatus.displayName || capitalise(hall) || 'Hall'; + try { + let notification = new window.Notification(`Skald: ${hallName}`, { + body: message, + }); + notification.onclick = function() { + window.focus(); + notification.close(); + }; + } catch(e) { + console.error('Could not show browser notification:', e); + } +} + function getNotificationAudioContext() { if(!notificationSoundsEnabled()) return null; @@ -1798,6 +1848,26 @@ function userMenu(elt) { window.open(recordingsUrl(), '_blank', 'noopener'); }}); } + if(browserNotificationsSupported()) { + if(window.Notification.permission === 'default') { + items.push({ + label: 'Enable browser notifications', + onClick: requestBrowserNotificationPermission, + }); + } else if(window.Notification.permission === 'granted') { + items.push({label: 'Browser notifications enabled', onClick: () => { + displayMessage( + 'Timed unlock messages will notify you when this window is not active.', + ); + }}); + } else { + items.push({label: 'Browser notifications blocked', onClick: () => { + displayWarning( + 'Allow browser notifications in your browser site settings.', + ); + }}); + } + } if(findUpMedia('audio')) items.push({label: 'Turn microphone off', onClick: () => { closeUpMedia('audio'); @@ -2396,8 +2466,9 @@ function gotFileTransferEvent(state, data) { * @param {string} kind * @param {string} error * @param {any} message + * @param {boolean} notify */ -function gotUserMessage(id, dest, username, time, privileged, kind, error, message) { +function gotUserMessage(id, dest, username, time, privileged, kind, error, message, notify) { switch(kind) { case 'kicked': case 'error': @@ -2418,6 +2489,8 @@ function gotUserMessage(id, dest, username, time, privileged, kind, error, messa let text = '' + message; localMessage(text); displayMessage(text, false); + if(notify) + showBrowserNotification(text); break; } case 'recording': { diff --git a/webserver/static_accessibility_test.go b/webserver/static_accessibility_test.go index bc1dd34..661f9a8 100644 --- a/webserver/static_accessibility_test.go +++ b/webserver/static_accessibility_test.go @@ -110,12 +110,24 @@ func TestGlobalShortcutAliasesStayDocumented(t *testing.T) { func TestTimedUnlockCommandStaysDocumentedAndUsesServerAnnouncements(t *testing.T) { js := readStaticFile(t, "skald.js") + protocol := readStaticFile(t, "protocol.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, "gotUserMessage", "if(notify)") + requireFunctionContains(t, js, "gotUserMessage", "showBrowserNotification(text)") + requireFunctionContains(t, js, "requestBrowserNotificationPermission", "window.Notification.requestPermission()") + requireFunctionContains(t, js, "showBrowserNotification", "window.Notification.permission !== 'granted'") + requireFunctionContains(t, js, "showBrowserNotification", "document.hidden") + requireFunctionContains(t, js, "showBrowserNotification", "document.hasFocus()") + requireFunctionContains(t, js, "showBrowserNotification", "new window.Notification") + requireFunctionContains(t, js, "showBrowserNotification", "window.focus()") + requireFunctionContains(t, js, "userMenu", "Enable browser notifications") + requireFunctionContains(t, js, "userMenu", "requestBrowserNotificationPermission") + requireContains(t, protocol, "m.value, !!m.notify", "browser notification protocol flag") requireFunctionContains(t, js, "displayError", "if(announce)") requireFunctionContains(t, js, "displayError", "announceChat(text)") }