Send notifications for events that are not announced when window is out of focus.

This commit is contained in:
Storm Dragon
2026-07-28 17:22:24 -04:00
parent 6a94f893b6
commit 9125e76d43
9 changed files with 161 additions and 19 deletions
@@ -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.r1.g704b1a9 pkgver=2026.07.08.r2.g6a94f89
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')
+8 -8
View File
@@ -262,7 +262,7 @@ func scheduleHallUnlock(g *hall.Hall, issuer *webClient, deadline, now time.Time
} }
entry.mu.Lock() entry.mu.Lock()
if entry.active { if entry.active {
broadcastHallInfo(g, message) broadcastHallInfo(g, message, true)
} }
entry.mu.Unlock() entry.mu.Unlock()
return nil return nil
@@ -280,7 +280,7 @@ func announceScheduledUnlock(entry *scheduledHallUnlock, remaining time.Duration
} }
broadcastHallInfo(entry.hall, fmt.Sprintf( broadcastHallInfo(entry.hall, fmt.Sprintf(
"Hall will unlock in %s.", formatMinutesUntil(remaining), "Hall will unlock in %s.", formatMinutesUntil(remaining),
)) ), true)
} }
func finishScheduledUnlock(entry *scheduledHallUnlock) { func finishScheduledUnlock(entry *scheduledHallUnlock) {
@@ -296,17 +296,17 @@ func finishScheduledUnlock(entry *scheduledHallUnlock) {
result := entry.hall.UnlockIfOperatorPresent(entry.issuer) result := entry.hall.UnlockIfOperatorPresent(entry.issuer)
switch result { switch result {
case hall.ConditionalUnlockSucceeded: case hall.ConditionalUnlockSucceeded:
broadcastHallInfo(entry.hall, "Hall unlocked") broadcastHallInfo(entry.hall, "Hall unlocked", true)
case hall.ConditionalUnlockClientAbsent: case hall.ConditionalUnlockClientAbsent:
broadcastHallInfo(entry.hall, fmt.Sprintf( broadcastHallInfo(entry.hall, fmt.Sprintf(
"Scheduled unlock cancelled because %s is no longer present in the hall.", "Scheduled unlock cancelled because %s is no longer present in the hall.",
entry.issuerName, entry.issuerName,
)) ), true)
case hall.ConditionalUnlockClientNotOperator: case hall.ConditionalUnlockClientNotOperator:
broadcastHallInfo(entry.hall, fmt.Sprintf( broadcastHallInfo(entry.hall, fmt.Sprintf(
"Scheduled unlock cancelled because %s is no longer an operator.", "Scheduled unlock cancelled because %s is no longer an operator.",
entry.issuerName, entry.issuerName,
)) ), true)
} }
entry.mu.Unlock() entry.mu.Unlock()
hallUnlockSchedules.Unlock() hallUnlockSchedules.Unlock()
@@ -322,11 +322,11 @@ func setHallLockState(g *hall.Hall, locked bool, message string) bool {
g.SetLocked(locked, message) g.SetLocked(locked, message)
if locked { if locked {
if entry != nil { 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 { } else {
broadcastHallInfo(g, "Hall unlocked") broadcastHallInfo(g, "Hall unlocked", false)
} }
hallUnlockSchedules.Unlock() hallUnlockSchedules.Unlock()
return entry != nil return entry != nil
+50 -6
View File
@@ -191,13 +191,21 @@ func TestUnlockWarningTimes(t *testing.T) {
} }
func latestHallInfo(messages []clientMessage) string { 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-- { for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Type == "usermessage" && messages[i].Kind == "info" { if messages[i].Type == "usermessage" && messages[i].Kind == "info" {
message, _ := messages[i].Value.(string) return messages[i], true
return message
} }
} }
return "" return clientMessage{}, false
} }
func scheduledUnlockEntry(g *hall.Hall) *scheduledHallUnlock { func scheduledUnlockEntry(g *hall.Hall) *scheduledHallUnlock {
@@ -225,9 +233,28 @@ func TestScheduledUnlockRequiresIssuingOperator(t *testing.T) {
if got := len(entry.timers); got != 4 { if got := len(entry.timers); got != 4 {
t.Fatalf("30-minute schedule has %d timers, want 4", got) 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) 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) hall.DelClient(operator)
drainMessages(participant) drainMessages(participant)
@@ -257,9 +284,16 @@ func TestScheduledUnlockCompletesWhileIssuerIsOperator(t *testing.T) {
if locked, _ := g.Locked(); locked { if locked, _ := g.Locked(); locked {
t.Fatal("hall remained locked while issuing operator was present") 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) 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) { func TestScheduledUnlockRequiresOperatorPermissionAtDeadline(t *testing.T) {
@@ -386,9 +420,16 @@ func TestImmediateUnlockCommandCancelsScheduledUnlock(t *testing.T) {
if locked, _ := g.Locked(); locked { if locked, _ := g.Locked(); locked {
t.Fatal("immediate unlock left hall 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) 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) { func TestLockCommandCancelsScheduledUnlock(t *testing.T) {
@@ -426,6 +467,9 @@ func TestLockCommandCancelsScheduledUnlock(t *testing.T) {
for _, message := range messages { for _, message := range messages {
if message.Kind == "info" && strings.Contains(message.Value.(string), "locked again") { if message.Kind == "info" && strings.Contains(message.Value.(string), "locked again") {
foundCancellation = true foundCancellation = true
if !message.Notify {
t.Fatal("scheduled-unlock cancellation did not request a browser notification")
}
} }
} }
if !foundCancellation { if !foundCancellation {
+3 -1
View File
@@ -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{ err := broadcast(g.GetClients(nil), clientMessage{
Type: "usermessage", Type: "usermessage",
Kind: "info", Kind: "info",
Privileged: true, Privileged: true,
Notify: notify,
Value: message, Value: message,
}) })
if err != nil { if err != nil {
@@ -188,6 +189,7 @@ type clientMessage struct {
Password string `json:"password,omitempty"` Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"` Token string `json:"token,omitempty"`
Privileged bool `json:"privileged,omitempty"` Privileged bool `json:"privileged,omitempty"`
Notify bool `json:"notify,omitempty"`
Permissions []string `json:"permissions,omitempty"` Permissions []string `json:"permissions,omitempty"`
Status *hall.Status `json:"status,omitempty"` Status *hall.Status `json:"status,omitempty"`
Data map[string]interface{} `json:"data,omitempty"` Data map[string]interface{} `json:"data,omitempty"`
+5
View File
@@ -348,6 +348,7 @@ chat history, and is not expected to contain user-visible content.
username: username, username: username,
dest: dest-id, dest: dest-id,
privileged: boolean, privileged: boolean,
notify: boolean,
value: value value: value
} }
``` ```
@@ -357,6 +358,10 @@ Currently defined kinds include `error`, `warning`, `info`, `kicked`,
`chalkboard`, and `mute`. A `chalkboard` user message carries the current `chalkboard`, and `mute`. A `chalkboard` user message carries the current
virtual chalkboard state: 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 ```javascript
{ {
type: 'usermessage', type: 'usermessage',
+6
View File
@@ -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, recordings (opens in new tab)* opens the hall's recording archive in a new tab,
which lists completed recordings when there are multiple files. 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 ### Chat pane
The centre pane is a traditional chat interface, with an input form at the The centre pane is a traditional chat interface, with an input form at the
+2 -2
View File
@@ -224,7 +224,7 @@ function ServerConnection() {
* 'id' is non-null, 'privileged' indicates whether the message was * 'id' is non-null, 'privileged' indicates whether the message was
* sent by an operator. * 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; this.onusermessage = null;
/** /**
@@ -517,7 +517,7 @@ ServerConnection.prototype.connect = function(url) {
else if(sc.onusermessage) else if(sc.onusermessage)
sc.onusermessage.call( sc.onusermessage.call(
sc, m.source, m.dest, m.username, parseTime(m.time), 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; break;
case 'ping': case 'ping':
+74 -1
View File
@@ -278,6 +278,56 @@ function notificationSoundsEnabled() {
return !!getSettings().notificationSounds; 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() { function getNotificationAudioContext() {
if(!notificationSoundsEnabled()) if(!notificationSoundsEnabled())
return null; return null;
@@ -1798,6 +1848,26 @@ function userMenu(elt) {
window.open(recordingsUrl(), '_blank', 'noopener'); 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')) if(findUpMedia('audio'))
items.push({label: 'Turn microphone off', onClick: () => { items.push({label: 'Turn microphone off', onClick: () => {
closeUpMedia('audio'); closeUpMedia('audio');
@@ -2396,8 +2466,9 @@ function gotFileTransferEvent(state, data) {
* @param {string} kind * @param {string} kind
* @param {string} error * @param {string} error
* @param {any} message * @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) { switch(kind) {
case 'kicked': case 'kicked':
case 'error': case 'error':
@@ -2418,6 +2489,8 @@ function gotUserMessage(id, dest, username, time, privileged, kind, error, messa
let text = '' + message; let text = '' + message;
localMessage(text); localMessage(text);
displayMessage(text, false); displayMessage(text, false);
if(notify)
showBrowserNotification(text);
break; break;
} }
case 'recording': { case 'recording': {
+12
View File
@@ -110,12 +110,24 @@ func TestGlobalShortcutAliasesStayDocumented(t *testing.T) {
func TestTimedUnlockCommandStaysDocumentedAndUsesServerAnnouncements(t *testing.T) { func TestTimedUnlockCommandStaysDocumentedAndUsesServerAnnouncements(t *testing.T) {
js := readStaticFile(t, "skald.js") 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, "[minutes[m|M] | HH:MM | h:mm AM/PM]", "unlock command help")
requireContains(t, js, "Intl.DateTimeFormat().resolvedOptions().timeZone", "unlock timezone") requireContains(t, js, "Intl.DateTimeFormat().resolvedOptions().timeZone", "unlock timezone")
requireContains(t, js, "timezoneOffset: new Date().getTimezoneOffset()", "unlock timezone fallback") requireContains(t, js, "timezoneOffset: new Date().getTimezoneOffset()", "unlock timezone fallback")
requireContains(t, js, "serverConnection.hallAction('unlock', {", "scheduled unlock request") requireContains(t, js, "serverConnection.hallAction('unlock', {", "scheduled unlock request")
requireFunctionContains(t, js, "gotUserMessage", "displayMessage(text, false)") 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", "if(announce)")
requireFunctionContains(t, js, "displayError", "announceChat(text)") requireFunctionContains(t, js, "displayError", "announceChat(text)")
} }