mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-10 13:27:21 -04:00
feat(notifications): timed per-app popup mutes (1h / 4h) (#3628)
* feat(notifications): timed per-app popup mutes (1h / 4h) with expiry * fix test assertions * notifications: let a timed mute expire without taking the rule's other settings --------- Co-authored-by: bbedward <[email protected]>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
This file is more of a quick reference so I know what to account for before next releases.
|
||||
|
||||
# Next
|
||||
- notifications: timed per-app popup mutes (1h / 4h) from the notification context menu
|
||||
- Go core migrated to dankgo (shared log/paths/errdefs/ipc/shellapp)
|
||||
- Quickshell UI embedded in the dms binary, -c / DMS_SHELL_DIR override - breaking for setups relying on ~/.config/quickshell/dms auto-discovery (XDG search removed)
|
||||
- release binaries + all -git packages build with withshell; sockets/pidfiles/env names unchanged, IPC APIVersion 28 wire-compatible
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Pure helpers for notification-rule expiry (timed mute rules). Kept free of
|
||||
// QML dependencies so the logic test suite can exercise them directly.
|
||||
// A rule with expiresAt > 0 (ms since epoch) stops muting once the
|
||||
// timestamp passes; expiresAt of 0 or absent means "never expires".
|
||||
// Remaining-time text is deliberately NOT formatted here: callers use the
|
||||
// shared Common/Format.js formatter with translated format strings.
|
||||
|
||||
function isRuleExpired(rule, nowMs) {
|
||||
const expiresAt = rule && rule.expiresAt ? rule.expiresAt : 0;
|
||||
if (expiresAt <= 0)
|
||||
return false;
|
||||
const now = nowMs === undefined ? Date.now() : nowMs;
|
||||
return now > expiresAt;
|
||||
}
|
||||
|
||||
// True when any rule carries an expiry timestamp (expired or not). Gates
|
||||
// the expiry clock and sweeper so shells without timed rules run no
|
||||
// periodic work at all; expired-but-unswept rules keep counting so the
|
||||
// sweeper stays on until they are dropped.
|
||||
function hasTimedRule(rules) {
|
||||
const source = rules || [];
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const expiresAt = source[i] && source[i].expiresAt ? source[i].expiresAt : 0;
|
||||
if (expiresAt > 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import qs.Common.settings
|
||||
import qs.Services
|
||||
import "GSettings.js" as GSettings
|
||||
import "LayoutResolver.js" as LayoutResolver
|
||||
import "NotificationRuleExpiry.js" as RuleExpiry
|
||||
import "settings/SettingsSpec.js" as Spec
|
||||
import "settings/SettingsStore.js" as Store
|
||||
import "../DCommon/Common/settings/SpecUtil.js" as SpecUtil
|
||||
@@ -3374,6 +3375,23 @@ Singleton {
|
||||
return rule.bypassDnd === true;
|
||||
}
|
||||
|
||||
// Timed mute rules carry an expiresAt timestamp (ms since epoch); 0 or
|
||||
// absent means the mute never expires. Only the mute lapses: a DND bypass
|
||||
// or urgency on the same rule stays in force, and
|
||||
// pruneExpiredNotificationRules() clears the lapsed mute from the
|
||||
// persisted rule. nowMs is optional (defaults to the current time);
|
||||
// reactive callers pass NotificationService.notificationRuleNowMs so
|
||||
// their bindings refresh as rules expire instead of freezing on a stale
|
||||
// Date.now().
|
||||
function isNotificationRuleExpired(rule, nowMs) {
|
||||
return RuleExpiry.isRuleExpired(rule, nowMs);
|
||||
}
|
||||
|
||||
// True while any rule carries an expiry timestamp. Gates the expiry
|
||||
// clock and sweeper in NotificationService so they only run when a
|
||||
// timed rule can actually expire.
|
||||
readonly property bool hasTimedNotificationRules: RuleExpiry.hasTimedRule(notificationRules)
|
||||
|
||||
function _appRuleIndex(rules, appName, desktopEntry, predicate) {
|
||||
const app = (appName || "").toString().toLowerCase();
|
||||
const desktop = (desktopEntry || "").toString().toLowerCase();
|
||||
@@ -3393,11 +3411,14 @@ Singleton {
|
||||
if (!pattern)
|
||||
return;
|
||||
var rules = JSON.parse(JSON.stringify(notificationRules || []));
|
||||
rules.push(_newNotificationRule(Object.assign({
|
||||
const rule = _newNotificationRule(Object.assign({
|
||||
field: desktopEntry ? "desktopEntry" : "appName",
|
||||
pattern: pattern,
|
||||
matchType: "exact"
|
||||
}, overrides)));
|
||||
}, overrides));
|
||||
if (!rule.expiresAt)
|
||||
delete rule.expiresAt;
|
||||
rules.push(rule);
|
||||
notificationRules = rules;
|
||||
saveSettings();
|
||||
}
|
||||
@@ -3406,6 +3427,10 @@ Singleton {
|
||||
return (rule.action || "default").toString().toLowerCase() === "default";
|
||||
}
|
||||
|
||||
function _isNoopRule(rule) {
|
||||
return _hasNoAction(rule) && (rule.urgency || "default").toString().toLowerCase() === "default" && !_isDndBypassRule(rule);
|
||||
}
|
||||
|
||||
// Edits the first enabled matching rule and drops it once it no longer does anything.
|
||||
function _updateAppRule(appName, desktopEntry, predicate, changes) {
|
||||
var rules = JSON.parse(JSON.stringify(notificationRules || []));
|
||||
@@ -3413,29 +3438,67 @@ Singleton {
|
||||
if (index === -1)
|
||||
return false;
|
||||
const rule = Object.assign(rules[index], changes);
|
||||
if (_hasNoAction(rule) && (rule.urgency || "default").toString().toLowerCase() === "default" && !_isDndBypassRule(rule))
|
||||
if (!rule.expiresAt)
|
||||
delete rule.expiresAt;
|
||||
if (_isNoopRule(rule))
|
||||
rules.splice(index, 1);
|
||||
notificationRules = rules;
|
||||
saveSettings();
|
||||
return true;
|
||||
}
|
||||
|
||||
function addMuteRuleForApp(appName, desktopEntry) {
|
||||
if (!_updateAppRule(appName, desktopEntry, _hasNoAction, {
|
||||
action: "mute"
|
||||
}))
|
||||
_addAppRule(appName, desktopEntry, {
|
||||
action: "mute"
|
||||
});
|
||||
function addMuteRuleForApp(appName, desktopEntry, expiresAt) {
|
||||
// Re-muting edits the app's existing no-action or mute rule (including an
|
||||
// expired one not yet swept) so duplicates never accumulate.
|
||||
const changes = {
|
||||
action: "mute",
|
||||
expiresAt: expiresAt || 0
|
||||
};
|
||||
if (!_updateAppRule(appName, desktopEntry, rule => _hasNoAction(rule) || _isMuteRule(rule), changes))
|
||||
_addAppRule(appName, desktopEntry, changes);
|
||||
}
|
||||
|
||||
function isAppMuted(appName, desktopEntry) {
|
||||
return _appRuleIndex(notificationRules || [], appName, desktopEntry, rule => rule.enabled !== false && _isMuteRule(rule)) !== -1;
|
||||
function isAppMuted(appName, desktopEntry, nowMs) {
|
||||
return _appRuleIndex(notificationRules || [], appName, desktopEntry, rule => rule.enabled !== false && _isMuteRule(rule) && !isNotificationRuleExpired(rule, nowMs)) !== -1;
|
||||
}
|
||||
|
||||
// ExpiresAt of the active mute rule for an app (ms since epoch),
|
||||
// or 0 when the app is not muted or the mute is permanent.
|
||||
function muteExpiresAt(appName, desktopEntry, nowMs) {
|
||||
const rules = notificationRules || [];
|
||||
const index = _appRuleIndex(rules, appName, desktopEntry, rule => rule.enabled !== false && _isMuteRule(rule) && !isNotificationRuleExpired(rule, nowMs));
|
||||
return index === -1 ? 0 : (rules[index].expiresAt || 0);
|
||||
}
|
||||
|
||||
// Clears lapsed timed mutes from the persisted list so the settings UI
|
||||
// stays truthful. Only the mute goes; the rule is dropped once nothing
|
||||
// else is left on it. The idle sweep must stay cheap, so nothing is
|
||||
// copied unless a rule actually expired.
|
||||
function pruneExpiredNotificationRules() {
|
||||
const rules = notificationRules || [];
|
||||
if (!rules.some(rule => isNotificationRuleExpired(rule)))
|
||||
return;
|
||||
const kept = [];
|
||||
for (const source of rules) {
|
||||
if (!isNotificationRuleExpired(source)) {
|
||||
kept.push(source);
|
||||
continue;
|
||||
}
|
||||
const rule = Object.assign({}, source, {
|
||||
action: "default"
|
||||
});
|
||||
delete rule.expiresAt;
|
||||
if (!_isNoopRule(rule))
|
||||
kept.push(rule);
|
||||
}
|
||||
notificationRules = kept;
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function removeMuteRuleForApp(appName, desktopEntry) {
|
||||
_updateAppRule(appName, desktopEntry, _isMuteRule, {
|
||||
action: "default"
|
||||
action: "default",
|
||||
expiresAt: 0
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,40 +11,73 @@ QtObject {
|
||||
property string dismissText: I18n.tr("Dismiss")
|
||||
property var notification: null
|
||||
readonly property string copyText: [notification?.summary, notification?.body].map(text => HtmlElide.stripHtmlTags((text || "").replace(/<br\s*\/?>/gi, "\n")).trim()).filter(text => text).join("\n")
|
||||
readonly property bool isMuted: SettingsData.isAppMuted(appName, desktopEntry)
|
||||
// nowMs comes from NotificationService's ticking clock so these
|
||||
// bindings refresh every minute and flip the moment a timed mute lapses
|
||||
// (a raw Date.now() in a binding is not reactive and would freeze).
|
||||
readonly property bool isMuted: SettingsData.isAppMuted(appName, desktopEntry, NotificationService.notificationRuleNowMs)
|
||||
// double, not int: epoch milliseconds exceed the 32-bit QML int range
|
||||
readonly property double muteExpiresAt: SettingsData.muteExpiresAt(appName, desktopEntry, NotificationService.notificationRuleNowMs)
|
||||
readonly property bool isDndBypassed: SettingsData.isAppDndBypassed(appName, desktopEntry)
|
||||
signal dismissRequested
|
||||
signal appMuted
|
||||
|
||||
readonly property var items: [
|
||||
{
|
||||
icon: "tune",
|
||||
label: I18n.tr("Set notification rules"),
|
||||
action: "rules"
|
||||
},
|
||||
{
|
||||
icon: isMuted ? "notifications" : "notifications_off",
|
||||
label: isMuted ? I18n.tr("Unmute popups for %1", "notification menu action, %1 is the app name").arg(appName || I18n.tr("this app")) : I18n.tr("Mute popups for %1", "notification menu action, %1 is the app name").arg(appName || I18n.tr("this app")),
|
||||
action: "mute"
|
||||
},
|
||||
{
|
||||
readonly property string muteRemainingSuffix: {
|
||||
if (!isMuted || muteExpiresAt <= 0)
|
||||
return "";
|
||||
const remainingText = NotificationService.formatRuleRemaining(muteExpiresAt);
|
||||
return remainingText ? I18n.tr(" (%1 left)", "notification menu, %1 = remaining time until a timed mute expires").arg(remainingText) : "";
|
||||
}
|
||||
|
||||
readonly property var items: {
|
||||
const list = [
|
||||
{
|
||||
icon: "tune",
|
||||
label: I18n.tr("Set notification rules"),
|
||||
action: "rules"
|
||||
}
|
||||
];
|
||||
if (isMuted) {
|
||||
list.push({
|
||||
icon: "notifications",
|
||||
label: I18n.tr("Unmute popups for %1", "notification menu action, %1 is the app name").arg(appName || I18n.tr("this app")) + muteRemainingSuffix,
|
||||
action: "mute"
|
||||
});
|
||||
} else {
|
||||
list.push({
|
||||
icon: "notifications_off",
|
||||
label: I18n.tr("Mute popups for %1 (1 hour)", "notification menu action, %1 is the app name").arg(appName || I18n.tr("this app")),
|
||||
action: "mute_1h"
|
||||
});
|
||||
list.push({
|
||||
icon: "notifications_off",
|
||||
label: I18n.tr("Mute popups for %1 (4 hours)", "notification menu action, %1 is the app name").arg(appName || I18n.tr("this app")),
|
||||
action: "mute_4h"
|
||||
});
|
||||
list.push({
|
||||
icon: "notifications_off",
|
||||
label: I18n.tr("Mute popups for %1", "notification menu action, %1 is the app name").arg(appName || I18n.tr("this app")),
|
||||
action: "mute"
|
||||
});
|
||||
}
|
||||
list.push({
|
||||
icon: isDndBypassed ? "do_not_disturb_on" : "do_not_disturb_off",
|
||||
label: isDndBypassed ? I18n.tr("Block %1 in Do Not Disturb", "notification menu action, %1 is the app name").arg(appName || I18n.tr("this app")) : I18n.tr("Allow %1 in Do Not Disturb", "notification menu action, %1 is the app name").arg(appName || I18n.tr("this app")),
|
||||
action: "dnd"
|
||||
},
|
||||
...(root.copyText ? [
|
||||
{
|
||||
icon: "content_copy",
|
||||
label: I18n.tr("Copy"),
|
||||
action: "copy"
|
||||
}
|
||||
] : []),
|
||||
{
|
||||
});
|
||||
if (root.copyText) {
|
||||
list.push({
|
||||
icon: "content_copy",
|
||||
label: I18n.tr("Copy"),
|
||||
action: "copy"
|
||||
});
|
||||
}
|
||||
list.push({
|
||||
icon: "close",
|
||||
label: root.dismissText,
|
||||
action: "dismiss"
|
||||
}
|
||||
]
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
function defaultAction(notification) {
|
||||
const actions = notification?.actions || [];
|
||||
@@ -65,6 +98,14 @@ QtObject {
|
||||
SettingsData.addMuteRuleForApp(appName, desktopEntry);
|
||||
appMuted();
|
||||
return;
|
||||
case "mute_1h":
|
||||
SettingsData.addMuteRuleForApp(appName, desktopEntry, Date.now() + 60 * 60 * 1000);
|
||||
appMuted();
|
||||
return;
|
||||
case "mute_4h":
|
||||
SettingsData.addMuteRuleForApp(appName, desktopEntry, Date.now() + 4 * 60 * 60 * 1000);
|
||||
appMuted();
|
||||
return;
|
||||
case "dnd":
|
||||
SettingsData.setAppDndBypass(appName, desktopEntry, !isDndBypassed);
|
||||
return;
|
||||
|
||||
@@ -20,7 +20,9 @@ Item {
|
||||
})).filter(entry => predicate(entry.rule));
|
||||
}
|
||||
|
||||
readonly property var mutedRules: indexedRules(rule => (rule.action || "").toString().toLowerCase() === "mute")
|
||||
// Expired timed mutes drop out of the list the minute they lapse
|
||||
// (notificationRuleNowMs ticks), without waiting for the sweep.
|
||||
readonly property var mutedRules: indexedRules(rule => (rule.action || "").toString().toLowerCase() === "mute" && !SettingsData.isNotificationRuleExpired(rule, NotificationService.notificationRuleNowMs))
|
||||
|
||||
readonly property var notificationRuleFieldOptions: [
|
||||
{
|
||||
@@ -110,6 +112,14 @@ Item {
|
||||
return [getRuleOptionLabel(notificationRuleFieldOptions, rule.field, notificationRuleFieldOptions[0].label), getRuleOptionLabel(notificationRuleMatchTypeOptions, rule.matchType, notificationRuleMatchTypeOptions[0].label)].join(" · ");
|
||||
}
|
||||
|
||||
function remainingLabel(rule) {
|
||||
const expiresAt = rule && rule.expiresAt ? rule.expiresAt : 0;
|
||||
if (expiresAt <= NotificationService.notificationRuleNowMs)
|
||||
return "";
|
||||
const remaining = NotificationService.formatRuleRemaining(expiresAt);
|
||||
return remaining ? I18n.tr("expires in %1", "timed notification rule, %1 = remaining time until it expires").arg(remaining) : "";
|
||||
}
|
||||
|
||||
function outcomeBadges(rule) {
|
||||
const badges = [];
|
||||
if ((rule.action || "default") !== "default")
|
||||
@@ -118,6 +128,9 @@ Item {
|
||||
badges.push(getRuleOptionLabel(notificationRuleUrgencyOptions, rule.urgency, rule.urgency));
|
||||
if (rule.bypassDnd === true)
|
||||
badges.push(I18n.tr("Allow in Do Not Disturb"));
|
||||
const remaining = remainingLabel(rule);
|
||||
if (remaining !== "")
|
||||
badges.push(remaining);
|
||||
return badges;
|
||||
}
|
||||
|
||||
@@ -263,6 +276,7 @@ Item {
|
||||
required property var modelData
|
||||
|
||||
title: modelData.rule?.pattern || I18n.tr("Unknown")
|
||||
subtitle: root.remainingLabel(modelData.rule)
|
||||
singleLineTitle: true
|
||||
|
||||
DButton {
|
||||
|
||||
@@ -7,6 +7,7 @@ import Quickshell.Io
|
||||
import Quickshell.Services.Notifications
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import "../Common/Format.js" as Format
|
||||
import "../Common/markdown2html.js" as Markdown2Html
|
||||
|
||||
Singleton {
|
||||
@@ -504,7 +505,7 @@ Singleton {
|
||||
};
|
||||
|
||||
policy.bypassDnd = rules.some(rule => rule.bypassDnd === true && _matchesNotificationRule(rule, info));
|
||||
policy.disablePopup = rules.some(rule => (rule.action || "default").toString().toLowerCase() === "mute" && _matchesNotificationRule(rule, info));
|
||||
policy.disablePopup = rules.some(rule => (rule.action || "default").toString().toLowerCase() === "mute" && !SettingsData.isNotificationRuleExpired(rule) && _matchesNotificationRule(rule, info));
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!_matchesNotificationRule(rule, info))
|
||||
@@ -606,6 +607,42 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
// Date.now() inside a QML binding is not reactive, so anything counting
|
||||
// down to a rule's expiresAt needs a ticking time source. Minute
|
||||
// precision matches the countdown granularity, and the clock only runs
|
||||
// while timed rules exist, so shells without them pay nothing.
|
||||
SystemClock {
|
||||
id: ruleExpiryClock
|
||||
precision: SystemClock.Minutes
|
||||
enabled: SettingsData.hasTimedNotificationRules
|
||||
}
|
||||
|
||||
// Current time in ms for rule-expiry bindings. Reads 0 while the clock is
|
||||
// off, which is safe: the clock is off exactly when no rule carries an
|
||||
// expiry, so no binding can depend on the value moving.
|
||||
readonly property double notificationRuleNowMs: ruleExpiryClock.enabled ? ruleExpiryClock.date.getTime() : 0
|
||||
|
||||
// Formatted remaining time for a rule's expiresAt, driven by the ticking
|
||||
// clock above so bindings calling this refresh every minute. Single home
|
||||
// for the translated duration strings; "" once the timestamp passes.
|
||||
function formatRuleRemaining(expiresAt) {
|
||||
return Format.formatRemaining(expiresAt - notificationRuleNowMs, "", I18n.tr("%1 min", "timed duration, %1 is a number of minutes"), I18n.tr("%1 h", "timed duration, %1 is a number of hours"), I18n.tr("%1 h %2 m", "timed duration, %1 is a number of hours, %2 is a number of minutes"));
|
||||
}
|
||||
|
||||
// Clears lapsed timed mutes (SettingsData.addMuteRuleForApp with
|
||||
// expiresAt) from the persisted rules. Matching already ignores them;
|
||||
// this keeps the stored list and the settings UI truthful. Runs only
|
||||
// while timed rules exist, and fires on start so mutes that lapsed while
|
||||
// the shell was offline are cleared at launch.
|
||||
Timer {
|
||||
id: expiredRuleSweeper
|
||||
interval: 60000
|
||||
repeat: true
|
||||
running: SettingsData.hasTimedNotificationRules
|
||||
triggeredOnStart: true
|
||||
onTriggered: SettingsData.pruneExpiredNotificationRules()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: dismissPump
|
||||
interval: _dismissTickMs
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
import { loadScript } from "./qml-script.mjs";
|
||||
|
||||
const format = loadScript(new URL("../Common/Format.js", import.meta.url));
|
||||
|
||||
// QML's JS engine provides QString-style String.prototype.arg(); the plain vm
|
||||
// realm does not. Install a minimal equivalent (replace the lowest-numbered
|
||||
// %n per call) so format helpers behave as they do in the shell.
|
||||
vm.runInContext("String.prototype.arg = function() { let out = this.toString(); for (let i = 0; i < arguments.length; i++) out = out.replace(/%(\\d+)/, String(arguments[i])); return out; }", format);
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
test("formatRemaining returns zeroText at or below zero", () => {
|
||||
assert.equal(format.formatRemaining(0, "Off", "%1 min", "%1 h", "%1 h %2 m"), "Off");
|
||||
assert.equal(format.formatRemaining(-5, "Off", "%1 min", "%1 h", "%1 h %2 m"), "Off");
|
||||
assert.equal(format.formatRemaining(0, "", "%1 min", "%1 h", "%1 h %2 m"), "");
|
||||
});
|
||||
|
||||
test("formatRemaining formats minutes below an hour, rounding up", () => {
|
||||
assert.equal(format.formatRemaining(30 * 1000, "", "%1 min", "%1 h", "%1 h %2 m"), "1 min");
|
||||
assert.equal(format.formatRemaining(43 * 60 * 1000, "", "%1 min", "%1 h", "%1 h %2 m"), "43 min");
|
||||
});
|
||||
|
||||
test("formatRemaining formats whole hours without minutes", () => {
|
||||
assert.equal(format.formatRemaining(HOUR_MS, "", "%1 min", "%1 h", "%1 h %2 m"), "1 h");
|
||||
assert.equal(format.formatRemaining(2 * HOUR_MS, "", "%1 min", "%1 h", "%1 h %2 m"), "2 h");
|
||||
});
|
||||
|
||||
test("formatRemaining rounds 59m59s up to one hour, not 60 min", () => {
|
||||
assert.equal(format.formatRemaining(59 * 60 * 1000 + 59000, "", "%1 min", "%1 h", "%1 h %2 m"), "1 h");
|
||||
});
|
||||
|
||||
test("formatRemaining formats hours and minutes together", () => {
|
||||
assert.equal(format.formatRemaining(2 * HOUR_MS + 5 * 60 * 1000, "", "%1 min", "%1 h", "%1 h %2 m"), "2 h 5 m");
|
||||
assert.equal(format.formatRemaining(4 * HOUR_MS + 59 * 60 * 1000 + 59000, "", "%1 min", "%1 h", "%1 h %2 m"), "5 h");
|
||||
});
|
||||
@@ -2,9 +2,11 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
import { loadScript } from "./qml-script.mjs";
|
||||
|
||||
const source = readFileSync(new URL("../Common/SettingsData.qml", import.meta.url), "utf8");
|
||||
const serviceSource = readFileSync(new URL("../Services/NotificationService.qml", import.meta.url), "utf8");
|
||||
const ruleExpiry = loadScript(new URL("../Common/NotificationRuleExpiry.js", import.meta.url));
|
||||
|
||||
function load(text, globals, only) {
|
||||
const context = vm.createContext(globals);
|
||||
@@ -16,14 +18,14 @@ function load(text, globals, only) {
|
||||
}
|
||||
|
||||
function settings(rules) {
|
||||
const context = load(source, { notificationRules: rules });
|
||||
const context = load(source, { notificationRules: rules, RuleExpiry: ruleExpiry });
|
||||
context.saveSettings = () => {};
|
||||
return context;
|
||||
}
|
||||
|
||||
function policy(rules, notif) {
|
||||
const functions = ["_resolveAppNameForRule", "_ruleFieldValue", "_coerceRuleUrgency", "_matchesNotificationRule", "_evaluateNotificationPolicy"];
|
||||
const context = load(serviceSource, { SettingsData: { notificationRules: rules }, NotificationUrgency: { Low: 0, Normal: 1, Critical: 2 } }, functions);
|
||||
const context = load(serviceSource, { SettingsData: { notificationRules: rules, isNotificationRuleExpired: ruleExpiry.isRuleExpired }, NotificationUrgency: { Low: 0, Normal: 1, Critical: 2 } }, functions);
|
||||
return plain(context._evaluateNotificationPolicy(notif));
|
||||
}
|
||||
|
||||
@@ -128,3 +130,14 @@ test("rules that are not exact app or desktop-entry rules are never read or edit
|
||||
assert.equal(u.isAppMuted("org.mozilla.firefox", "firefox"), false, "a desktopEntry rule is not matched against the app name");
|
||||
assert.equal(u.isAppMuted("Firefox", "org.mozilla.firefox"), true);
|
||||
});
|
||||
|
||||
test("an expired timed mute releases the popup but keeps the rule's other settings", () => {
|
||||
const notif = { appName: "Firefox", summary: "", body: "" };
|
||||
const p = policy([rule({ action: "mute", bypassDnd: true, expiresAt: 1 })], notif);
|
||||
assert.equal(p.disablePopup, false);
|
||||
assert.equal(p.bypassDnd, true);
|
||||
|
||||
const s = settings([rule({ action: "mute", bypassDnd: true, expiresAt: 1 }), rule({ action: "mute", expiresAt: 1 })]);
|
||||
s.pruneExpiredNotificationRules();
|
||||
assert.deepEqual(plain(s.notificationRules), [rule({ bypassDnd: true })]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { loadScript } from "./qml-script.mjs";
|
||||
|
||||
const expiry = loadScript(new URL("../Common/NotificationRuleExpiry.js", import.meta.url));
|
||||
|
||||
// The script runs inside a vm context, so returned objects live in another
|
||||
// realm; clone them into plain host objects before deep comparisons
|
||||
// (bar-content.test.mjs pattern).
|
||||
const plain = value => JSON.parse(JSON.stringify(value));
|
||||
|
||||
test("rules without expiresAt never expire", () => {
|
||||
assert.equal(expiry.isRuleExpired({}, 1000), false);
|
||||
assert.equal(expiry.isRuleExpired(null, 1000), false);
|
||||
assert.equal(expiry.isRuleExpired({ expiresAt: 0 }, 1000), false);
|
||||
assert.equal(expiry.isRuleExpired({ action: "mute" }, Number.MAX_SAFE_INTEGER), false);
|
||||
});
|
||||
|
||||
test("timed rules expire strictly after their timestamp", () => {
|
||||
const rule = { action: "mute", expiresAt: 5000 };
|
||||
assert.equal(expiry.isRuleExpired(rule, 4999), false);
|
||||
assert.equal(expiry.isRuleExpired(rule, 5000), false);
|
||||
assert.equal(expiry.isRuleExpired(rule, 5001), true);
|
||||
});
|
||||
|
||||
test("hasTimedRule reports whether any rule carries an expiry", () => {
|
||||
assert.equal(expiry.hasTimedRule(null), false);
|
||||
assert.equal(expiry.hasTimedRule([]), false);
|
||||
assert.equal(expiry.hasTimedRule([{}, { action: "mute" }, { expiresAt: 0 }]), false);
|
||||
assert.equal(expiry.hasTimedRule([null, { expiresAt: 0 }, { expiresAt: 5000 }]), true);
|
||||
// Expired-but-unswept rules still count: they keep the sweeper alive
|
||||
// until they are dropped from the persisted list.
|
||||
assert.equal(expiry.hasTimedRule([{ expiresAt: 1 }]), true);
|
||||
});
|
||||
Reference in New Issue
Block a user