From d9525908f1042fc8b0a6dc6895b9a5ac4dc5888b Mon Sep 17 00:00:00 2001 From: purian23 Date: Sun, 24 May 2026 22:22:34 -0400 Subject: [PATCH] refactor(Notifications): further support for duplicate notification logic - New setting to stack or suppress identical alerts (on by default) Closes #2334 --- quickshell/Common/SettingsData.qml | 1 + quickshell/Common/settings/SettingsSpec.js | 1 + .../Center/HistoryNotificationCard.qml | 38 ++++---- .../Modules/Settings/NotificationsTab.qml | 11 +++ quickshell/Services/NotificationService.qml | 87 ++++++++++++++----- .../translations/settings_search_index.json | 22 +++++ 6 files changed, 122 insertions(+), 38 deletions(-) diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 399b827e..14dfe8f5 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -688,6 +688,7 @@ Singleton { property int notificationTimeoutNormal: 5000 property int notificationTimeoutCritical: 0 property bool notificationCompactMode: false + property bool notificationDedupeEnabled: true property int notificationPopupPosition: SettingsData.Position.Top property int notificationAnimationSpeed: SettingsData.AnimationSpeed.Short property int notificationCustomAnimationDuration: 400 diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index b4bdd49c..76d072a3 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -399,6 +399,7 @@ var SPEC = { notificationTimeoutNormal: { def: 5000 }, notificationTimeoutCritical: { def: 0 }, notificationCompactMode: { def: false }, + notificationDedupeEnabled: { def: true }, notificationPopupPosition: { def: 0 }, notificationAnimationSpeed: { def: 1 }, notificationCustomAnimationDuration: { def: 400 }, diff --git a/quickshell/Modules/Notifications/Center/HistoryNotificationCard.qml b/quickshell/Modules/Notifications/Center/HistoryNotificationCard.qml index 978620be..5c092670 100644 --- a/quickshell/Modules/Notifications/Center/HistoryNotificationCard.qml +++ b/quickshell/Modules/Notifications/Center/HistoryNotificationCard.qml @@ -182,26 +182,30 @@ Rectangle { Row { width: parent.width spacing: Theme.spacingXS - readonly property real reservedTrailingWidth: historySeparator.implicitWidth + Math.max(historyTimeText.implicitWidth, 72) + spacing - StyledText { - id: historyTitleText - width: Math.min(implicitWidth, Math.max(0, parent.width - parent.reservedTrailingWidth)) - text: { - let title = historyItem.summary || ""; - const appName = historyItem.appName || ""; - const prefix = appName + " • "; - if (appName && title.toLowerCase().startsWith(prefix.toLowerCase())) { - title = title.substring(prefix.length); + Item { + width: Math.max(0, parent.width - historySeparator.implicitWidth - Math.max(historyTimeText.implicitWidth, 72) - parent.spacing * 2) + height: historyTitleText.implicitHeight + visible: historyTitleText.text.length > 0 + + StyledText { + id: historyTitleText + anchors.fill: parent + text: { + let title = historyItem.summary || ""; + const appName = historyItem.appName || ""; + const prefix = appName + " • "; + if (appName && title.toLowerCase().startsWith(prefix.toLowerCase())) { + title = title.substring(prefix.length); + } + return title; } - return title; + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + elide: Text.ElideRight + maximumLineCount: 1 } - color: Theme.surfaceText - font.pixelSize: Theme.fontSizeMedium - font.weight: Font.Medium - elide: Text.ElideRight - maximumLineCount: 1 - visible: text.length > 0 } StyledText { id: historySeparator diff --git a/quickshell/Modules/Settings/NotificationsTab.qml b/quickshell/Modules/Settings/NotificationsTab.qml index c43a5e21..9287dfb7 100644 --- a/quickshell/Modules/Settings/NotificationsTab.qml +++ b/quickshell/Modules/Settings/NotificationsTab.qml @@ -273,6 +273,17 @@ Item { onToggled: checked => SettingsData.set("notificationCompactMode", checked) } + SettingsToggleRow { + settingKey: "notificationDedupeEnabled" + tags: ["notification", "duplicate", "dedupe", "stack", "coalesce", "repeat"] + text: I18n.tr("Suppress Duplicate Notifications") + description: SettingsData.notificationDedupeEnabled + ? I18n.tr("Identical alerts show as one popup instead of stacking") + : I18n.tr("Identical alerts stack as separate notification cards") + checked: SettingsData.notificationDedupeEnabled + onToggled: checked => SettingsData.set("notificationDedupeEnabled", checked) + } + SettingsToggleRow { settingKey: "notificationPopupShadowEnabled" tags: ["notification", "popup", "shadow", "radius", "rounded"] diff --git a/quickshell/Services/NotificationService.qml b/quickshell/Services/NotificationService.qml index fe1a2536..82084098 100644 --- a/quickshell/Services/NotificationService.qml +++ b/quickshell/Services/NotificationService.qml @@ -35,6 +35,8 @@ Singleton { property int maxIngressPerSecond: 20 property double _lastIngressSec: 0 property int _ingressCountThisSec: 0 + readonly property int notificationDedupBurstMs: 5000 + property var _recentDedupKeys: [] property var _dismissQueue: [] property int _dismissBatchSize: 8 @@ -291,18 +293,58 @@ Singleton { return Date.now() / 1000.0; } + function _normalizeDedupText(text) { + if (!text) + return ""; + let normalized = text.toString(); + normalized = normalized.replace(/]*>/gi, ""); + normalized = normalized.replace(/<[^>]+>/g, ""); + normalized = normalized.replace(/\s+/g, " ").trim(); + return normalized.toLowerCase(); + } + + function _dedupAppId(source) { + if (!source) + return ""; + const desktopEntry = (source.desktopEntry || "").toString().trim().toLowerCase(); + if (desktopEntry) + return desktopEntry; + return (source.appName || "").toString().trim().toLowerCase(); + } + function _notificationDedupKey(source) { if (!source) return ""; - const app = (source.appName || source.desktopEntry || "").toString(); - const summary = (source.summary || "").toString(); - const body = (source.body || "").toString(); + const app = _dedupAppId(source); + const summary = _normalizeDedupText(source.summary); + const body = _normalizeDedupText(source.body); const urgency = typeof source.urgency === "number" ? source.urgency : NotificationUrgency.Normal; - const icon = (source.appIcon || "").toString(); if (!app && !summary && !body) return ""; const sep = ""; - return app + sep + summary + sep + body + sep + urgency + sep + icon; + return app + sep + summary + sep + body + sep + urgency; + } + + function _pruneRecentDedupKeys() { + const cutoff = Date.now() - notificationDedupBurstMs; + _recentDedupKeys = _recentDedupKeys.filter(entry => entry && entry.atMs >= cutoff); + } + + function _hasRecentDuplicate(key) { + if (!key) + return false; + _pruneRecentDedupKeys(); + return _recentDedupKeys.some(entry => entry && entry.key === key); + } + + function _recordDedupKey(key) { + if (!key) + return; + _pruneRecentDedupKeys(); + _recentDedupKeys.push({ + "key": key, + "atMs": Date.now() + }); } function _findActiveDuplicate(notif) { @@ -310,17 +352,14 @@ Singleton { if (!key) return null; - for (const w of visibleNotifications) { + for (const w of allWrappers) { if (!w || !w.notification || !w.popup) continue; - if (_notificationDedupKey(w.notification) === key) - return w; - } - - for (const w of notificationQueue) { - if (!w || !w.notification) + if (_notificationDedupKey(w.notification) !== key) continue; - if (_notificationDedupKey(w.notification) === key) + if (visibleNotifications.indexOf(w) !== -1 || notificationQueue.indexOf(w) !== -1) + return w; + if (w.timer && w.timer.running) return w; } @@ -637,14 +676,17 @@ Singleton { return; } - const duplicate = _findActiveDuplicate(notif); - if (duplicate) { - if (duplicate.timer && duplicate.timer.running) - duplicate.timer.restart(); - try { - notif.dismiss(); - } catch (e) {} - return; + if (SettingsData.notificationDedupeEnabled) { + const dedupKey = _notificationDedupKey(notif); + const duplicate = _findActiveDuplicate(notif); + if (duplicate || _hasRecentDuplicate(dedupKey)) { + if (duplicate && duplicate.timer && duplicate.timer.running) + duplicate.timer.restart(); + try { + notif.dismiss(); + } catch (e) {} + return; + } } if (!_ingressAllowed(policy.urgency)) { @@ -686,6 +728,9 @@ Singleton { }); if (wrapper) { + if (SettingsData.notificationDedupeEnabled) + _recordDedupKey(_notificationDedupKey(notif)); + root.allWrappers.push(wrapper); if (shouldKeepInCenter) { root.notifications.push(wrapper); diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 12da5d9f..e8d99d60 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -5807,6 +5807,28 @@ ], "description": "Use smaller notification cards" }, + { + "section": "notificationDedupeEnabled", + "label": "Suppress Duplicate Notifications", + "tabIndex": 17, + "category": "Notifications", + "keywords": [ + "alert", + "alerts", + "coalesce", + "dedupe", + "duplicate", + "duplicates", + "messages", + "notif", + "notification", + "notifications", + "repeat", + "stack", + "toast" + ], + "description": "Control whether identical alerts stack or show as a single popup" + }, { "section": "notificationHistorySaveCritical", "label": "Critical Priority",