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:
Lars Artmann
2026-10-07 10:25:38 -04:00
committed by GitHub
co-authored by bbedward
parent 650b731164
commit 8e59341c50
9 changed files with 310 additions and 41 deletions
+1
View File
@@ -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;
}
+76 -13
View File
@@ -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 {
+38 -1
View File
@@ -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
+38
View File
@@ -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);
});