diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index dc6a9b7e..67510f24 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -504,6 +504,7 @@ Singleton { property bool notificationHistorySaveLow: true property bool notificationHistorySaveNormal: true property bool notificationHistorySaveCritical: true + property var notificationRules: [] property bool osdAlwaysShowValue: false property int osdPosition: SettingsData.Position.BottomCenter @@ -2134,6 +2135,56 @@ Singleton { saveSettings(); } + function addNotificationRule() { + var rules = JSON.parse(JSON.stringify(notificationRules || [])); + rules.push({ + enabled: true, + field: "appName", + pattern: "", + matchType: "contains", + action: "mute", + urgency: "default" + }); + notificationRules = rules; + saveSettings(); + } + + function updateNotificationRule(index, ruleData) { + var rules = JSON.parse(JSON.stringify(notificationRules || [])); + if (index < 0 || index >= rules.length) + return; + var existing = rules[index] || {}; + rules[index] = Object.assign({}, existing, ruleData || {}); + notificationRules = rules; + saveSettings(); + } + + function updateNotificationRuleField(index, key, value) { + if (key === undefined || key === null || key === "") + return; + var patch = {}; + patch[key] = value; + updateNotificationRule(index, patch); + } + + function removeNotificationRule(index) { + var rules = JSON.parse(JSON.stringify(notificationRules || [])); + if (index < 0 || index >= rules.length) + return; + rules.splice(index, 1); + notificationRules = rules; + saveSettings(); + } + + function getDefaultNotificationRules() { + return Spec.SPEC.notificationRules.def; + } + + function resetNotificationRules() { + notificationRules = JSON.parse(JSON.stringify(Spec.SPEC.notificationRules.def)); + saveSettings(); + } + function getDefaultAppIdSubstitutions() { return Spec.SPEC.appIdSubstitutions.def; } diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 7e3a86bd..af7dd0a7 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -330,6 +330,7 @@ var SPEC = { notificationHistorySaveLow: { def: true }, notificationHistorySaveNormal: { def: true }, notificationHistorySaveCritical: { def: true }, + notificationRules: { def: [] }, osdAlwaysShowValue: { def: false }, osdPosition: { def: 5 }, diff --git a/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml b/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml index b2966dd6..39144a95 100644 --- a/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml +++ b/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml @@ -153,12 +153,12 @@ QtObject { if (!wrapper || !wrapper.notification) { return false; } - const incomingUrgency = wrapper.notification.urgency || 0; + const incomingUrgency = wrapper.urgency || 0; for (const p of activeWindows) { if (!p.notificationData || !p.notificationData.notification) { continue; } - const existingUrgency = p.notificationData.notification.urgency || 0; + const existingUrgency = p.notificationData.urgency || 0; if (existingUrgency < incomingUrgency) { return true; } @@ -188,10 +188,9 @@ QtObject { } function _selectPopupToRemove(activeWindows, incomingWrapper) { - const incomingUrgency = (incomingWrapper && incomingWrapper.notification) ? incomingWrapper.notification.urgency || 0 : 0; const sortedWindows = activeWindows.slice().sort((a, b) => { - const aUrgency = (a.notificationData && a.notificationData.notification) ? a.notificationData.notification.urgency || 0 : 0; - const bUrgency = (b.notificationData && b.notificationData.notification) ? b.notificationData.notification.urgency || 0 : 0; + const aUrgency = (a.notificationData) ? a.notificationData.urgency || 0 : 0; + const bUrgency = (b.notificationData) ? b.notificationData.urgency || 0 : 0; if (aUrgency !== bUrgency) { return aUrgency - bUrgency; } diff --git a/quickshell/Modules/Settings/NotificationsTab.qml b/quickshell/Modules/Settings/NotificationsTab.qml index c6222700..e8c7d55a 100644 --- a/quickshell/Modules/Settings/NotificationsTab.qml +++ b/quickshell/Modules/Settings/NotificationsTab.qml @@ -57,6 +57,82 @@ Item { } ] + readonly property var notificationRuleFieldOptions: [ + { + value: "appName", + label: I18n.tr("App Names", "notification rule match field option") + }, + { + value: "desktopEntry", + label: I18n.tr("Desktop Entry", "notification rule match field option") + }, + { + value: "summary", + label: I18n.tr("Summary", "notification rule match field option") + }, + { + value: "body", + label: I18n.tr("Body", "notification rule match field option") + } + ] + + readonly property var notificationRuleMatchTypeOptions: [ + { + value: "contains", + label: I18n.tr("Contains", "notification rule match type option") + }, + { + value: "exact", + label: I18n.tr("Exact", "notification rule match type option") + }, + { + value: "regex", + label: I18n.tr("Regex", "notification rule match type option") + } + ] + + readonly property var notificationRuleActionOptions: [ + { + value: "default", + label: I18n.tr("Default", "notification rule action option") + }, + { + value: "mute", + label: I18n.tr("Mute Popups", "notification rule action option") + }, + { + value: "ignore", + label: I18n.tr("Ignore Completely", "notification rule action option") + }, + { + value: "popup_only", + label: I18n.tr("Popup Only", "notification rule action option") + }, + { + value: "no_history", + label: I18n.tr("No History", "notification rule action option") + } + ] + + readonly property var notificationRuleUrgencyOptions: [ + { + value: "default", + label: I18n.tr("Default", "notification rule urgency option") + }, + { + value: "low", + label: I18n.tr("Low Priority", "notification rule urgency option") + }, + { + value: "normal", + label: I18n.tr("Normal Priority", "notification rule urgency option") + }, + { + value: "critical", + label: I18n.tr("Critical Priority", "notification rule urgency option") + } + ] + function getTimeoutText(value) { if (value === undefined || value === null || isNaN(value)) return I18n.tr("5 seconds"); @@ -73,6 +149,22 @@ Item { return Math.round(value / 60000) + " " + I18n.tr("minutes"); } + function getRuleOptionLabel(options, value, fallback) { + for (let i = 0; i < options.length; i++) { + if (options[i].value === value) + return options[i].label; + } + return fallback; + } + + function getRuleOptionValue(options, label, fallback) { + for (let i = 0; i < options.length; i++) { + if (options[i].label === label) + return options[i].value; + } + return fallback; + } + DankFlickable { anchors.fill: parent clip: true @@ -165,6 +257,228 @@ Item { } } + SettingsCard { + width: parent.width + iconName: "rule_settings" + title: I18n.tr("Notification Rules") + settingKey: "notificationRules" + tags: ["notification", "rules", "mute", "ignore", "priority", "regex", "history"] + collapsible: true + expanded: false + + headerActions: [ + DankActionButton { + buttonSize: 36 + iconName: "restart_alt" + iconSize: 20 + visible: JSON.stringify(SettingsData.notificationRules) !== JSON.stringify(SettingsData.getDefaultNotificationRules()) + backgroundColor: Theme.surfaceContainer + iconColor: Theme.surfaceVariantText + onClicked: SettingsData.resetNotificationRules() + }, + DankActionButton { + buttonSize: 36 + iconName: "add" + iconSize: 20 + backgroundColor: Theme.surfaceContainer + iconColor: Theme.primary + onClicked: SettingsData.addNotificationRule() + } + ] + + Column { + width: parent.width + spacing: Theme.spacingS + + StyledText { + text: I18n.tr("Create rules to mute, ignore, hide from history, or override notification priority.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + width: parent.width + bottomPadding: Theme.spacingS + } + + Repeater { + model: SettingsData.notificationRules + + delegate: Rectangle { + id: ruleItem + width: parent.width + height: ruleColumn.implicitHeight + Theme.spacingM + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.surfaceContainer, 0.5) + + Column { + id: ruleColumn + anchors.fill: parent + anchors.margins: Theme.spacingS + spacing: Theme.spacingS + + Row { + width: parent.width + spacing: Theme.spacingS + + StyledText { + id: ruleLabel + text: I18n.tr("Rule") + " " + (index + 1) + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + anchors.verticalCenter: parent.verticalCenter + } + + Item { + width: Math.max(0, parent.width - ruleLabel.implicitWidth - enableToggle.width - deleteBtn.width - Theme.spacingS * 3) + height: 1 + } + + DankToggle { + id: enableToggle + width: 40 + height: 24 + hideText: true + checked: modelData.enabled !== false + onToggled: checked => SettingsData.updateNotificationRuleField(index, "enabled", checked) + } + + Item { + id: deleteBtn + width: 28 + height: 28 + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + anchors.fill: parent + radius: Theme.cornerRadius + color: deleteArea.containsMouse ? Theme.withAlpha(Theme.error, 0.2) : "transparent" + } + + DankIcon { + anchors.centerIn: parent + name: "delete" + size: 18 + color: deleteArea.containsMouse ? Theme.error : Theme.surfaceVariantText + } + + MouseArea { + id: deleteArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: SettingsData.removeNotificationRule(index) + } + } + } + + Column { + width: parent.width + spacing: 2 + + StyledText { + text: I18n.tr("Pattern") + font.pixelSize: Theme.fontSizeSmall - 1 + color: Theme.surfaceVariantText + } + + DankTextField { + width: parent.width + text: modelData.pattern || "" + font.pixelSize: Theme.fontSizeSmall + placeholderText: I18n.tr("Pattern") + onEditingFinished: SettingsData.updateNotificationRuleField(index, "pattern", text) + } + } + + Row { + width: parent.width + spacing: Theme.spacingS + + Column { + width: (parent.width - Theme.spacingS * 3) / 4 + spacing: 2 + + StyledText { + text: I18n.tr("Field") + font.pixelSize: Theme.fontSizeSmall - 1 + color: Theme.surfaceVariantText + } + + DankDropdown { + width: parent.width + compactMode: true + dropdownWidth: parent.width + currentValue: root.getRuleOptionLabel(root.notificationRuleFieldOptions, modelData.field, root.notificationRuleFieldOptions[0].label) + options: root.notificationRuleFieldOptions.map(o => o.label) + onValueChanged: value => SettingsData.updateNotificationRuleField(index, "field", root.getRuleOptionValue(root.notificationRuleFieldOptions, value, "appName")) + } + } + + Column { + width: (parent.width - Theme.spacingS * 3) / 4 + spacing: 2 + + StyledText { + text: I18n.tr("Type") + font.pixelSize: Theme.fontSizeSmall - 1 + color: Theme.surfaceVariantText + } + + DankDropdown { + width: parent.width + compactMode: true + dropdownWidth: parent.width + currentValue: root.getRuleOptionLabel(root.notificationRuleMatchTypeOptions, modelData.matchType, root.notificationRuleMatchTypeOptions[0].label) + options: root.notificationRuleMatchTypeOptions.map(o => o.label) + onValueChanged: value => SettingsData.updateNotificationRuleField(index, "matchType", root.getRuleOptionValue(root.notificationRuleMatchTypeOptions, value, "contains")) + } + } + + Column { + width: (parent.width - Theme.spacingS * 3) / 4 + spacing: 2 + + StyledText { + text: I18n.tr("Action") + font.pixelSize: Theme.fontSizeSmall - 1 + color: Theme.surfaceVariantText + } + + DankDropdown { + width: parent.width + compactMode: true + dropdownWidth: parent.width + currentValue: root.getRuleOptionLabel(root.notificationRuleActionOptions, modelData.action, root.notificationRuleActionOptions[0].label) + options: root.notificationRuleActionOptions.map(o => o.label) + onValueChanged: value => SettingsData.updateNotificationRuleField(index, "action", root.getRuleOptionValue(root.notificationRuleActionOptions, value, "default")) + } + } + + Column { + width: (parent.width - Theme.spacingS * 3) / 4 + spacing: 2 + + StyledText { + text: I18n.tr("Priority") + font.pixelSize: Theme.fontSizeSmall - 1 + color: Theme.surfaceVariantText + } + + DankDropdown { + width: parent.width + compactMode: true + dropdownWidth: parent.width + currentValue: root.getRuleOptionLabel(root.notificationRuleUrgencyOptions, modelData.urgency, root.notificationRuleUrgencyOptions[0].label) + options: root.notificationRuleUrgencyOptions.map(o => o.label) + onValueChanged: value => SettingsData.updateNotificationRuleField(index, "urgency", root.getRuleOptionValue(root.notificationRuleUrgencyOptions, value, "default")) + } + } + } + } + } + } + } + } + SettingsCard { width: parent.width iconName: "lock" diff --git a/quickshell/Services/NotificationService.qml b/quickshell/Services/NotificationService.qml index 658e15b3..21c4c08d 100644 --- a/quickshell/Services/NotificationService.qml +++ b/quickshell/Services/NotificationService.qml @@ -260,14 +260,14 @@ Singleton { return Date.now() / 1000.0; } - function _ingressAllowed(notif) { + function _ingressAllowed(urgency) { const t = _nowSec(); if (t - _lastIngressSec >= 1.0) { _lastIngressSec = t; _ingressCountThisSec = 0; } _ingressCountThisSec += 1; - if (notif.urgency === NotificationUrgency.Critical) { + if (urgency === NotificationUrgency.Critical) { return true; } return _ingressCountThisSec <= maxIngressPerSecond; @@ -294,11 +294,13 @@ Singleton { function _initWrapperPersistence(wrapper) { const timeoutMs = wrapper.timer ? wrapper.timer.interval : 5000; - const isCritical = wrapper.notification && wrapper.notification.urgency === NotificationUrgency.Critical; + const isCritical = wrapper && wrapper.urgency === NotificationUrgency.Critical; wrapper.isPersistent = isCritical || (timeoutMs === 0); } - function _shouldSaveToHistory(urgency) { + function _shouldSaveToHistory(urgency, forceDisable) { + if (forceDisable === true) + return false; if (!SettingsData.notificationHistoryEnabled) return false; switch (urgency) { @@ -311,6 +313,126 @@ Singleton { } } + function _resolveAppNameForRule(notif) { + if (!notif) + return ""; + if (notif.appName && notif.appName !== "") + return notif.appName; + const entry = DesktopEntries.heuristicLookup(notif.desktopEntry); + if (entry && entry.name) + return entry.name; + return ""; + } + + function _ruleFieldValue(field, info) { + switch ((field || "").toString()) { + case "desktopEntry": + return info.desktopEntry; + case "summary": + return info.summary; + case "body": + return info.body; + case "appName": + default: + return info.appName; + } + } + + function _coerceRuleUrgency(value, fallbackUrgency) { + if (typeof value === "number" && value >= NotificationUrgency.Low && value <= NotificationUrgency.Critical) + return value; + + const mapped = (value || "default").toString().toLowerCase(); + switch (mapped) { + case "low": + return NotificationUrgency.Low; + case "normal": + return NotificationUrgency.Normal; + case "critical": + return NotificationUrgency.Critical; + default: + return fallbackUrgency; + } + } + + function _matchesNotificationRule(rule, info) { + if (!rule) + return false; + if (rule.enabled === false) + return false; + + const pattern = (rule.pattern || "").toString(); + if (!pattern.trim()) + return false; + + const value = (_ruleFieldValue(rule.field, info) || "").toString(); + const matchType = (rule.matchType || "contains").toString().toLowerCase(); + + if (matchType === "exact") + return value.toLowerCase() === pattern.toLowerCase(); + if (matchType === "regex") { + try { + return new RegExp(pattern, "i").test(value); + } catch (e) { + console.warn("NotificationService: invalid notification rule regex:", pattern); + return false; + } + } + + return value.toLowerCase().includes(pattern.toLowerCase()); + } + + function _evaluateNotificationPolicy(notif) { + const baseUrgency = typeof notif.urgency === "number" ? notif.urgency : NotificationUrgency.Normal; + const policy = { + "drop": false, + "disablePopup": false, + "hideFromCenter": false, + "disableHistory": false, + "urgency": baseUrgency + }; + + const rules = SettingsData.notificationRules || []; + if (!rules.length) + return policy; + + const info = { + "appName": _resolveAppNameForRule(notif), + "desktopEntry": notif.desktopEntry || "", + "summary": notif.summary || "", + "body": notif.body || "" + }; + + for (const rule of rules) { + if (!_matchesNotificationRule(rule, info)) + continue; + + const action = (rule.action || "default").toString().toLowerCase(); + switch (action) { + case "ignore": + policy.drop = true; + break; + case "mute": + policy.disablePopup = true; + break; + case "popup_only": + policy.hideFromCenter = true; + policy.disableHistory = true; + break; + case "no_history": + policy.disableHistory = true; + break; + default: + break; + } + + policy.urgency = _coerceRuleUrgency(rule.urgency, policy.urgency); + return policy; + } + + return policy; + } + function pruneHistory() { const maxAgeDays = SettingsData.notificationHistoryMaxAgeDays; if (maxAgeDays <= 0) @@ -440,8 +562,16 @@ Singleton { onNotification: notif => { notif.tracked = true; - if (!_ingressAllowed(notif)) { - if (notif.urgency !== NotificationUrgency.Critical) { + const policy = _evaluateNotificationPolicy(notif); + if (policy.drop) { + try { + notif.dismiss(); + } catch (e) {} + return; + } + + if (!_ingressAllowed(policy.urgency)) { + if (policy.urgency !== NotificationUrgency.Critical) { try { notif.dismiss(); } catch (e) {} @@ -450,25 +580,35 @@ Singleton { } if (SettingsData.soundsEnabled && SettingsData.soundNewNotification) { - if (notif.urgency === NotificationUrgency.Critical) { + if (policy.urgency === NotificationUrgency.Critical) { AudioService.playCriticalNotificationSound(); } else { AudioService.playNormalNotificationSound(); } } - const shouldShowPopup = !root.popupsDisabled && !SessionData.doNotDisturb; + const shouldShowPopup = !root.popupsDisabled && !SessionData.doNotDisturb && !policy.disablePopup; const isTransient = notif.transient; + const shouldKeepInCenter = !isTransient && !policy.hideFromCenter; + + if (!shouldShowPopup && !shouldKeepInCenter) { + try { + notif.dismiss(); + } catch (e) {} + return; + } + const wrapper = notifComponent.createObject(root, { "popup": shouldShowPopup, - "notification": notif + "notification": notif, + "urgencyOverride": policy.urgency }); if (wrapper) { root.allWrappers.push(wrapper); - if (!isTransient) { + if (shouldKeepInCenter) { root.notifications.push(wrapper); - if (_shouldSaveToHistory(notif.urgency)) { + if (_shouldSaveToHistory(wrapper.urgency, policy.disableHistory)) { root.addToHistory(wrapper); } } @@ -505,7 +645,7 @@ Singleton { interval: { if (!wrapper.notification) return 5000; - switch (wrapper.notification.urgency) { + switch (wrapper.urgency) { case NotificationUrgency.Low: return SettingsData.notificationTimeoutLow; case NotificationUrgency.Critical: @@ -600,7 +740,8 @@ Singleton { return ""; return Paths.strip(image); } - readonly property int urgency: notification?.urgency ?? 1 + property int urgencyOverride: notification?.urgency ?? NotificationUrgency.Normal + readonly property int urgency: urgencyOverride readonly property list actions: notification?.actions ?? [] readonly property Connections conn: Connections {