feat(notifications): add notification display rules

- block: skips the notification completely
- mute: does not play sound (played by noctalia), shows popup, adds to
history
- hide: no sound, no popup, still adds to history
This commit is contained in:
Lysec
2026-03-18 21:24:14 +01:00
parent 9f8bf988f0
commit 381444bc2c
7 changed files with 368 additions and 23 deletions
+19 -1
View File
@@ -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.",
@@ -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();
}
}
}
}
}
@@ -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
@@ -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")
}
}
@@ -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;
}
}
+30 -22
View File
@@ -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.
+1
View File
@@ -112,6 +112,7 @@ ShellRoot {
IdleService.init();
PowerProfileService.init();
HostService.init();
NotificationRulesService.init();
GitHubService.init();
SupporterService.init();
CustomButtonIPCService.init();