diff --git a/Assets/Translations/en.json b/Assets/Translations/en.json index 279de3221..8a2d065f1 100644 --- a/Assets/Translations/en.json +++ b/Assets/Translations/en.json @@ -465,6 +465,8 @@ "media-player": "Media Player", "memory": "Memory", "monitors": "Monitors", + "move-down": "Move down", + "move-up": "Move up", "network": "Network", "next": "Next", "night-light": "Night Light", @@ -1494,7 +1496,23 @@ "toast-keyboard-description": "Show a toast when the keyboard layout changes.", "toast-keyboard-label": "Keyboard layout", "toast-media-description": "Show a toast when media playback state changes.", - "toast-media-label": "Media" + "toast-media-label": "Media", + "rules-add": "Add rule", + "rules-action-block": "Block", + "rules-action-label": "Action", + "rules-action-hide": "Hide", + "rules-action-mute": "Mute", + "rules-delete": "Delete rule", + "rules-description": "Match app name or content. First match wins.", + "rules-edit": "Edit rule", + "rules-empty-pattern": "(empty)", + "rules-label": "Filter rules", + "rules-pattern-label": "Pattern", + "rules-pattern-placeholder": "firefox, discord, or /regex/", + "rules-action-block-desc": "Skips completely.", + "rules-action-hide-desc": "No popup, no sound, adds to history.", + "rules-action-mute-desc": "No sound, still shows popup and in history.", + "rules-tab": "Rules" }, "osd": { "always-on-top-description": "Display OSD above fullscreen windows and other layers.", diff --git a/Modules/Panels/Settings/Tabs/Notifications/NotificationRuleEditPopup.qml b/Modules/Panels/Settings/Tabs/Notifications/NotificationRuleEditPopup.qml new file mode 100644 index 000000000..3cc181a5d --- /dev/null +++ b/Modules/Panels/Settings/Tabs/Notifications/NotificationRuleEditPopup.qml @@ -0,0 +1,120 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Commons +import qs.Services.System +import qs.Widgets + +Popup { + id: root + modal: true + closePolicy: Popup.NoAutoClose + dim: true + anchors.centerIn: parent + + width: Math.min(500 * Style.uiScaleRatio, parent.width * 0.9) + padding: Style.marginL + + property int editIndex: -1 + property string patternValue: "" + property string actionValue: "block" + + signal saved(string pattern, string action) + + property var _savedSlot: null + property string _selectedAction: "block" + + background: Rectangle { + color: Color.mSurface + radius: Style.radiusL + border.color: Color.mOutline + border.width: Style.borderS + } + + onOpened: { + patternInput.text = patternValue; + actionCombo.currentKey = actionValue; + _selectedAction = actionValue; + patternInput.forceActiveFocus(); + } + + contentItem: ColumnLayout { + id: contentLayout + spacing: Style.marginL + + RowLayout { + Layout.fillWidth: true + NText { + text: editIndex >= 0 ? I18n.tr("panels.notifications.rules-edit") : I18n.tr("panels.notifications.rules-add") + font.weight: Style.fontWeightBold + pointSize: Style.fontSizeL + Layout.fillWidth: true + } + NIconButton { + icon: "close" + onClicked: root.close() + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginM + + NTextInput { + id: patternInput + Layout.fillWidth: true + label: I18n.tr("panels.notifications.rules-pattern-label") + placeholderText: I18n.tr("panels.notifications.rules-pattern-placeholder") + fontFamily: Settings.data.ui.fontFixed + } + + NComboBox { + id: actionCombo + Layout.fillWidth: true + label: I18n.tr("panels.notifications.rules-action-label") + model: [ + { "key": "block", "name": I18n.tr("panels.notifications.rules-action-block") }, + { "key": "mute", "name": I18n.tr("panels.notifications.rules-action-mute") }, + { "key": "hide", "name": I18n.tr("panels.notifications.rules-action-hide") } + ] + currentKey: actionValue + onSelected: key => { + actionValue = key; + _selectedAction = key; + } + } + + NLabel { + Layout.fillWidth: true + label: _selectedAction === "block" ? I18n.tr("panels.notifications.rules-action-block-desc") : (_selectedAction === "mute" ? I18n.tr("panels.notifications.rules-action-mute-desc") : I18n.tr("panels.notifications.rules-action-hide-desc")) + labelColor: Color.mOnSurfaceVariant + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginM + + Item { + Layout.fillWidth: true + } + + NButton { + text: I18n.tr("common.cancel") + outlined: true + onClicked: root.close() + } + + NButton { + text: I18n.tr("common.save") + icon: "check" + backgroundColor: Color.mPrimary + textColor: Color.mOnPrimary + onClicked: { + root.saved(patternInput.text.trim(), _selectedAction || "block"); + root.close(); + } + } + } + } +} diff --git a/Modules/Panels/Settings/Tabs/Notifications/NotificationsTab.qml b/Modules/Panels/Settings/Tabs/Notifications/NotificationsTab.qml index a787de843..831dbb958 100644 --- a/Modules/Panels/Settings/Tabs/Notifications/NotificationsTab.qml +++ b/Modules/Panels/Settings/Tabs/Notifications/NotificationsTab.qml @@ -68,6 +68,11 @@ ColumnLayout { tabIndex: 4 checked: subTabBar.currentIndex === 4 } + NTabButton { + text: I18n.tr("panels.notifications.rules-tab") + tabIndex: 5 + checked: subTabBar.currentIndex === 5 + } } Item { @@ -92,6 +97,7 @@ ColumnLayout { onOpenCriticalPicker: root.openCriticalSoundPicker() } ToastSubTab {} + RulesSubTab {} } // File Pickers for Sound Files diff --git a/Modules/Panels/Settings/Tabs/Notifications/RulesSubTab.qml b/Modules/Panels/Settings/Tabs/Notifications/RulesSubTab.qml new file mode 100644 index 000000000..cda49af01 --- /dev/null +++ b/Modules/Panels/Settings/Tabs/Notifications/RulesSubTab.qml @@ -0,0 +1,108 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.Commons +import qs.Services.System +import qs.Widgets + +ColumnLayout { + id: root + spacing: Style.marginL + Layout.fillWidth: true + enabled: Settings.data.notifications.enabled + + function _saveToService() { + NotificationRulesService.save(); + } + + function _removeRule(index) { + var arr = (NotificationRulesService.rules || []).slice(); + arr.splice(index, 1); + NotificationRulesService.rules = arr; + _saveToService(); + } + + NotificationRuleEditPopup { + id: editPopup + parent: Overlay.overlay + } + + function openEdit(index, patternVal, actionVal) { + editPopup.editIndex = index; + editPopup.patternValue = patternVal || ""; + editPopup.actionValue = actionVal || "block"; + + try { + editPopup.saved.disconnect(editPopup._savedSlot); + } catch (e) {} + + editPopup._savedSlot = function (pattern, action) { + var arr = (NotificationRulesService.rules || []).slice(); + var rule = { "pattern": pattern, "action": action }; + if (index >= 0 && index < arr.length) { + arr[index] = rule; + } else { + arr.push(rule); + } + NotificationRulesService.rules = arr; + _saveToService(); + }; + + editPopup.saved.connect(editPopup._savedSlot); + editPopup.open(); + } + + NLabel { + label: I18n.tr("panels.notifications.rules-label") + description: I18n.tr("panels.notifications.rules-description") + } + + NDivider { + Layout.fillWidth: true + Layout.topMargin: Style.marginS + Layout.bottomMargin: Style.marginS + } + + Repeater { + model: NotificationRulesService.rules || [] + + delegate: RowLayout { + id: entryDelegate + required property int index + required property var modelData + + property string pattern: modelData.pattern || "" + property string action: modelData.action || "block" + property bool isRegex: pattern.length >= 3 && pattern.startsWith("/") && pattern.endsWith("/") + + spacing: Style.marginM + Layout.fillWidth: true + + NLabel { + Layout.fillWidth: true + label: (entryDelegate.isRegex ? "regex: " : "") + (entryDelegate.pattern || I18n.tr("panels.notifications.rules-empty-pattern")) + description: entryDelegate.action === "block" ? I18n.tr("panels.notifications.rules-action-block") : (entryDelegate.action === "mute" ? I18n.tr("panels.notifications.rules-action-mute") : I18n.tr("panels.notifications.rules-action-hide")) + labelColor: entryDelegate.pattern ? Color.mPrimary : Color.mOnSurface + } + + NIconButton { + icon: "settings" + tooltipText: I18n.tr("common.edit") + onClicked: root.openEdit(entryDelegate.index, entryDelegate.pattern, entryDelegate.action) + } + + NIconButton { + icon: "trash" + tooltipText: I18n.tr("panels.notifications.rules-delete") + onClicked: root._removeRule(entryDelegate.index) + } + } + } + + NButton { + text: I18n.tr("panels.notifications.rules-add") + icon: "add" + enabled: Settings.data.notifications.enabled + onClicked: root.openEdit(-1, "", "block") + } +} diff --git a/Services/System/NotificationRulesService.qml b/Services/System/NotificationRulesService.qml new file mode 100644 index 000000000..5aff43f4b --- /dev/null +++ b/Services/System/NotificationRulesService.qml @@ -0,0 +1,84 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Commons + +Singleton { + id: root + + readonly property string rulesFilePath: Settings.configDir + "notification-rules.json" + + property var rules: [] + + property FileView rulesFileView: FileView { + id: rulesFileView + path: root.rulesFilePath + watchChanges: true + printErrors: false + + adapter: JsonAdapter { + id: rulesAdapter + property var rules: [] + } + + onLoaded: { + try { + const parsed = JSON.parse(rulesFileView.text()); + root.rules = Array.isArray(parsed.rules) ? parsed.rules : []; + } catch (e) { + root.rules = []; + } + } + + onLoadFailed: function (error) { + root.rules = []; + } + } + + function init() { + } + + function save() { + Quickshell.execDetached(["mkdir", "-p", Settings.configDir]); + rulesAdapter.rules = root.rules; + rulesFileView.writeAdapter(); + } + + function evaluate(appName, summary, body) { + const haystack = [appName || "", summary || "", body || ""].join(" "); + for (let i = 0; i < root.rules.length; i++) { + const r = root.rules[i]; + const pattern = (r.pattern || "").trim(); + if (pattern === "") + continue; + let matched = false; + if (pattern.length >= 3 && pattern.startsWith("/") && pattern.endsWith("/")) { + try { + matched = new RegExp(pattern.slice(1, -1)).test(haystack); + } catch (e) { + Logger.w("NotificationRulesService", "Invalid regex:", pattern, e); + } + } else if (pattern.includes("*")) { + try { + const reStr = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); + matched = new RegExp(reStr, "i").test(haystack); + } catch (e) { + matched = haystack.toLowerCase().includes(pattern.toLowerCase()); + } + } else { + matched = haystack.toLowerCase().includes(pattern.toLowerCase()); + } + if (matched) { + const a = (r.action || "block").toLowerCase(); + if (a === "mute" || a === "hide") + return a; + if (a === "silence") + return "hide"; + return "block"; + } + } + return null; + } +} diff --git a/Services/System/NotificationService.qml b/Services/System/NotificationService.qml index 824e6b828..baea37f5e 100644 --- a/Services/System/NotificationService.qml +++ b/Services/System/NotificationService.qml @@ -11,6 +11,7 @@ import qs.Commons import qs.Services.Compositor import qs.Services.Media import qs.Services.Power +import qs.Services.System import qs.Services.UI Singleton { @@ -139,29 +140,16 @@ Singleton { const quickshellId = notification.id; const data = createData(notification); - // Check if we should save to history based on urgency - const saveToHistorySettings = Settings.data.notifications?.saveToHistory; - if (saveToHistorySettings && !notification.transient) { - let shouldSave = true; - switch (data.urgency) { - case 0: // low - shouldSave = saveToHistorySettings.low !== false; - break; - case 1: // normal - shouldSave = saveToHistorySettings.normal !== false; - break; - case 2: // critical - shouldSave = saveToHistorySettings.critical !== false; - break; - } - if (shouldSave) { - addToHistory(data); - } - } else if (!notification.transient) { - // Default behavior: save all if settings not configured - addToHistory(data); + const ruleAction = NotificationRulesService.evaluate(data.appName, data.summary, data.body); + if (ruleAction === "block") + return; + if (ruleAction === "hide") { + trySaveToHistory(data, notification); + return; } + trySaveToHistory(data, notification); + if (root.doNotDisturb || PowerProfileService.noctaliaPerformanceMode) return; @@ -180,7 +168,8 @@ Singleton { // Add new notification addPopup(quickshellId, notification, data); - playNotificationSound(data.urgency, data.appName); + if (ruleAction !== "mute") + playNotificationSound(data.urgency, data.appName); } function playNotificationSound(urgency, appName) { @@ -586,6 +575,25 @@ Singleton { } // History management + function trySaveToHistory(data, notification) { + if (notification.transient) + return; + const s = Settings.data.notifications?.saveToHistory; + if (s) { + let ok = true; + if (data.urgency === 0) + ok = s.low !== false; + else if (data.urgency === 1) + ok = s.normal !== false; + else if (data.urgency === 2) + ok = s.critical !== false; + if (ok) + addToHistory(data); + } else { + addToHistory(data); + } + } + function addToHistory(data) { // Defer list insertion to prevent re-entrant QML incubation crash. // See addPopup for full explanation. diff --git a/shell.qml b/shell.qml index f21fb2c4b..27339a8bb 100644 --- a/shell.qml +++ b/shell.qml @@ -112,6 +112,7 @@ ShellRoot { IdleService.init(); PowerProfileService.init(); HostService.init(); + NotificationRulesService.init(); GitHubService.init(); SupporterService.init(); CustomButtonIPCService.init();