mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user