From 8b7c9085caa2ae2f21664cba8aad2a04ad68f1ac Mon Sep 17 00:00:00 2001 From: Marlon Kranz Date: Sat, 3 Oct 2026 20:39:48 +0200 Subject: [PATCH] fix(notifications): edit rules from the menu instead of deleting them (#3670) * fix(notifications): edit rules from the menu instead of deleting them A notification rule can set an action and Bypass DND together, but Unmute and Block in Do Not Disturb spliced the whole rule, dropping the other setting. They now reset only their own field, and a rule is removed once it no longer does anything (action and urgency default, no bypass). Mute and Allow in Do Not Disturb set their field on the app's existing rule instead of appending one that first-match policy would shadow. The menu now only reads and edits enabled, exact rules on the app name or desktop entry. It used to match any rule whose pattern equaled the app name, including summary, body, contains and regex rules, and could delete disabled ones. Also adds a Copy item to the notification menu on popups and center cards, copying the summary and body as plain text. * fix(notifications): let mute apply alongside other rule actions Rule actions are first-match, so a mute rule behind an app's existing rule (e.g. No history) never applied while the menu showed Unmute. Mute is now evaluated like Bypass DND: any matching enabled mute rule disables the popup, and the other actions stay first-match, so No history and Mute both apply. * fix(notifications): skip the sound for dropped and muted notifications The notification sound played before the check that dismisses a notification with neither a popup nor a center entry, so dropped notifications (transient while muted, Popup only plus Mute) still made a sound. Play it only for notifications that are kept, and not when a mute rule applies, like Do Not Disturb. stable-1.6: ported the rule edits and the sound/mute policy only, the Copy menu item depends on NotificationActions which stable does not have (cherry picked from commit 51f115f11e6db58ba5c6aa24bbfb202e11a6a39c) --- quickshell/Common/SettingsData.qml | 47 +++++++++++++++------ quickshell/Services/NotificationService.qml | 36 ++++++++-------- 2 files changed, 50 insertions(+), 33 deletions(-) diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index eb553c8c5..d28b2a5cf 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -3225,10 +3225,11 @@ Singleton { if (!app && !desktop) return -1; return rules.findIndex(rule => { - if (!predicate(rule)) + if (!predicate(rule) || (rule.matchType || "contains").toString().toLowerCase() !== "exact") return false; const pattern = (rule.pattern || "").toString().toLowerCase(); - return pattern !== "" && (pattern === app || pattern === desktop); + const value = !rule.field || rule.field === "appName" ? app : rule.field === "desktopEntry" ? desktop : ""; + return pattern !== "" && pattern === value; }); } @@ -3246,20 +3247,31 @@ Singleton { saveSettings(); } - function _removeAppRule(appName, desktopEntry, predicate) { + function _hasNoAction(rule) { + return (rule.action || "default").toString().toLowerCase() === "default"; + } + + // 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 || [])); - const index = _appRuleIndex(rules, appName, desktopEntry, predicate); + const index = _appRuleIndex(rules, appName, desktopEntry, rule => rule.enabled !== false && predicate(rule)); if (index === -1) - return; - rules.splice(index, 1); + return false; + const rule = Object.assign(rules[index], changes); + if (_hasNoAction(rule) && (rule.urgency || "default").toString().toLowerCase() === "default" && !_isDndBypassRule(rule)) + rules.splice(index, 1); notificationRules = rules; saveSettings(); + return true; } function addMuteRuleForApp(appName, desktopEntry) { - _addAppRule(appName, desktopEntry, { - action: "mute" - }); + if (!_updateAppRule(appName, desktopEntry, _hasNoAction, { + action: "mute" + })) + _addAppRule(appName, desktopEntry, { + action: "mute" + }); } function isAppMuted(appName, desktopEntry) { @@ -3267,7 +3279,9 @@ Singleton { } function removeMuteRuleForApp(appName, desktopEntry) { - _removeAppRule(appName, desktopEntry, _isMuteRule); + _updateAppRule(appName, desktopEntry, _isMuteRule, { + action: "default" + }); } function isAppDndBypassed(appName, desktopEntry) { @@ -3276,14 +3290,19 @@ Singleton { function setAppDndBypass(appName, desktopEntry, enabled) { if (!enabled) { - _removeAppRule(appName, desktopEntry, _isDndBypassRule); + _updateAppRule(appName, desktopEntry, _isDndBypassRule, { + bypassDnd: false + }); return; } if (isAppDndBypassed(appName, desktopEntry)) return; - _addAppRule(appName, desktopEntry, { - bypassDnd: true - }); + if (!_updateAppRule(appName, desktopEntry, () => true, { + bypassDnd: true + })) + _addAppRule(appName, desktopEntry, { + bypassDnd: true + }); } function updateNotificationRule(index, ruleData) { diff --git a/quickshell/Services/NotificationService.qml b/quickshell/Services/NotificationService.qml index 6bbf54449..1884f1f6e 100644 --- a/quickshell/Services/NotificationService.qml +++ b/quickshell/Services/NotificationService.qml @@ -504,6 +504,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)); for (const rule of rules) { if (!_matchesNotificationRule(rule, info)) @@ -514,9 +515,6 @@ Singleton { case "ignore": policy.drop = true; break; - case "mute": - policy.disablePopup = true; - break; case "popup_only": policy.hideFromCenter = true; policy.disableHistory = true; @@ -699,23 +697,7 @@ Singleton { } } - // Honor the freedesktop "suppress-sound" hint: the sender - // plays its own audio for this notification and asks the - // server not to double up. "sound-name" is the opposite — an - // explicit request for audio — so it plays even when the - // global new-notification sound is off. - const soundHints = notif.hints || {}; - const suppressSound = !!soundHints["suppress-sound"]; - const requestsSound = !!soundHints["sound-name"]; const dndBlocked = SessionData.doNotDisturb && !_allowedInDnd(policy.urgency, policy.bypassDnd); - if (!dndBlocked && SettingsData.soundsEnabled && (SettingsData.soundNewNotification || requestsSound) && !suppressSound) { - if (policy.urgency === NotificationUrgency.Critical) { - AudioService.playCriticalNotificationSound(); - } else { - AudioService.playNormalNotificationSound(); - } - } - const shouldShowPopup = !root.popupsDisabled && !dndBlocked && !policy.disablePopup; const isTransient = notif.transient; const shouldKeepInCenter = !isTransient && !policy.hideFromCenter; @@ -727,6 +709,22 @@ Singleton { return; } + // Honor the freedesktop "suppress-sound" hint: the sender + // plays its own audio for this notification and asks the + // server not to double up. "sound-name" is the opposite — an + // explicit request for audio — so it plays even when the + // global new-notification sound is off. + const soundHints = notif.hints || {}; + const suppressSound = !!soundHints["suppress-sound"]; + const requestsSound = !!soundHints["sound-name"]; + if (!dndBlocked && !policy.disablePopup && SettingsData.soundsEnabled && (SettingsData.soundNewNotification || requestsSound) && !suppressSound) { + if (policy.urgency === NotificationUrgency.Critical) { + AudioService.playCriticalNotificationSound(); + } else { + AudioService.playNormalNotificationSound(); + } + } + const wrapper = notifComponent.createObject(root, { "popup": shouldShowPopup, "notification": notif,