1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-04-04 04:42:05 -04:00

feat(notifications): add configurable notification rules (#1655)

This commit is contained in:
Bernardo Gomes
2026-02-12 17:04:02 -03:00
committed by GitHub
parent a3baf8ce31
commit 425715e0f0
5 changed files with 524 additions and 18 deletions

View File

@@ -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;
}

View File

@@ -330,6 +330,7 @@ var SPEC = {
notificationHistorySaveLow: { def: true },
notificationHistorySaveNormal: { def: true },
notificationHistorySaveCritical: { def: true },
notificationRules: { def: [] },
osdAlwaysShowValue: { def: false },
osdPosition: { def: 5 },

View File

@@ -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;
}

View File

@@ -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"

View File

@@ -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<NotificationAction> actions: notification?.actions ?? []
readonly property Connections conn: Connections {