Send notifications for events that are not announced when window is out of focus.
This commit is contained in:
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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': {
|
||||||
|
|||||||
@@ -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)")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user