Files
DankMaterialShell/quickshell/Modules/Notifications/Center/NotificationCard.qml
T
Marlon Kranz 51f115f11e fix(notifications): edit rules from the menu instead of deleting them (#3670)
* fix(notifications): edit rules from the menu instead of deleting them

A notification rule can set an action and Bypass DND together, but
Unmute and Block in Do Not Disturb spliced the whole rule, dropping the
other setting. They now reset only their own field, and a rule is
removed once it no longer does anything (action and urgency default,
no bypass). Mute and Allow in Do Not Disturb set their field on the
app's existing rule instead of appending one that first-match policy
would shadow.

The menu now only reads and edits enabled, exact rules on the app name
or desktop entry. It used to match any rule whose pattern equaled the
app name, including summary, body, contains and regex rules, and could
delete disabled ones.

Also adds a Copy item to the notification menu on popups and center
cards, copying the summary and body as plain text.

* fix(notifications): let mute apply alongside other rule actions

Rule actions are first-match, so a mute rule behind an app's existing
rule (e.g. No history) never applied while the menu showed Unmute.
Mute is now evaluated like Bypass DND: any matching enabled mute rule
disables the popup, and the other actions stay first-match, so No
history and Mute both apply.

* fix(notifications): skip the sound for dropped and muted notifications

The notification sound played before the check that dismisses a
notification with neither a popup nor a center entry, so dropped
notifications (transient while muted, Popup only plus Mute) still made
a sound. Play it only for notifications that are kept, and not when a
mute rule applies, like Do Not Disturb.
2026-10-03 14:39:48 -04:00

262 lines
11 KiB
QML

import QtQuick
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
import qs.Modules.Notifications
import qs.Modules.Notifications as Notifications
Item {
id: root
property var notificationGroup
property bool expanded: NotificationService.expandedGroups[notificationGroup?.key] || false
property bool animateExpansion: true
property bool isGroupSelected: false
property int selectedNotificationIndex: -1
property bool keyboardNavigationActive: false
property var transientSurfaceTracker: null
property bool firstInList: true
property bool lastInList: true
property bool nested: false
property real topRoundness: 0
property real bottomRoundness: 0
property real contentOpacity: 1
property real swipeBleed: 0
readonly property color cardSurfaceColor: Theme.foregroundColor(nested ? Theme.chipSurface : Theme.cardSurface, Theme.isFloatingWindow(root))
readonly property color cardChipColor: nested ? Theme.withAlpha(Theme.onSurface, Theme.stateLayerFocus) : Theme.foregroundColor(Theme.chipSurface, Theme.isFloatingWindow(root))
readonly property real expandedTargetHeight: {
let total = groupHeader.height;
for (const child of expandedContent.children) {
if (child.targetHeight === undefined)
continue;
total += Theme.groupedListGap + child.targetHeight;
}
return total;
}
readonly property real targetHeight: expanded ? expandedTargetHeight : collapsedCard.targetHeight
readonly property bool isAnimating: heightAnimation.running
width: parent ? parent.width : NotificationMetrics.popupWidth
height: targetHeight
clip: isAnimating
function toggleGroup() {
NotificationService.toggleGroupExpansion(notificationGroup?.key || "");
}
function invokeAction(action) {
if (!action?.invoke)
return;
action.invoke();
PopoutService.closeNotificationCenter();
}
function openContextMenu(item, notification) {
contextActions.notification = notification;
notificationCardContextMenu.popupAnchorItem = item;
notificationCardContextMenu.showDropdownMenu();
}
Component.onDestruction: transientSurfaceTracker?.unregister(root)
NotificationSwipeGroup {
id: rowSwipe
}
Connections {
target: root.transientSurfaceTracker
ignoreUnknownSignals: true
function onCloseRequested() {
notificationCardContextMenu.closeDropdownMenu();
}
}
Behavior on height {
enabled: root.animateExpansion && NotificationMetrics.animationsEnabled
NumberAnimation {
id: heightAnimation
duration: root.expanded ? Theme.notificationExpandDuration : Theme.notificationCollapseDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: NotificationMetrics.expandCurve
}
}
Notifications.NotificationCard {
id: collapsedCard
width: parent.width
surfaceColor: root.cardSurfaceColor
chipColor: root.cardChipColor
visible: !root.expanded
notificationData: root.notificationGroup?.latestNotification ?? null
groupCount: root.notificationGroup?.count || 0
descriptionExpanded: NotificationService.expandedMessages[(notificationData?.notification?.id || "") + "_desc"] || false
firstInGroup: root.firstInList
lastInGroup: root.lastInList
topRoundness: root.topRoundness
bottomRoundness: root.bottomRoundness
contentOpacity: root.contentOpacity
keyboardSelected: root.keyboardNavigationActive && root.isGroupSelected
keyboardHints: keyboardSelected
animateHeight: false
onExpandRequested: NotificationService.toggleMessageExpansion((notificationData?.notification?.id || "") + "_desc")
onGroupToggleRequested: root.toggleGroup()
onDismissRequested: NotificationService.dismissGroup(root.notificationGroup?.key || "")
onActionRequested: action => root.invokeAction(action)
onBodyClicked: {
if (groupCount > 1) {
root.toggleGroup();
return;
}
root.invokeAction(contextActions.defaultAction(notificationData));
}
onContextMenuRequested: (x, y) => root.openContextMenu(collapsedCard, collapsedCard.notificationData)
}
Column {
id: expandedContent
objectName: "expandedContent"
width: parent.width
visible: root.expanded
opacity: root.contentOpacity
spacing: Theme.groupedListGap
Rectangle {
id: groupHeader
width: parent.width
height: NotificationMetrics.cardPadding * 2 + NotificationMetrics.controlSize
radius: Theme.groupedListOuterRadius
color: "transparent"
border.width: root.keyboardNavigationActive && root.isGroupSelected ? Theme.focusRingWidth : 0
border.color: Theme.focusRingColor
StyledText {
anchors.left: parent.left
anchors.leftMargin: NotificationMetrics.cardPadding
anchors.right: groupControls.left
anchors.verticalCenter: parent.verticalCenter
text: root.notificationGroup?.appName || ""
font.pixelSize: Theme.fontSizeMedium
font.weight: Theme.fontWeightMedium
color: Theme.primary
elide: Text.ElideRight
}
Row {
id: groupControls
anchors.right: parent.right
anchors.rightMargin: NotificationMetrics.cardPadding
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
DankButton {
anchors.verticalCenter: parent.verticalCenter
text: I18n.tr("Dismiss")
buttonHeight: NotificationMetrics.controlSize
horizontalPadding: NotificationMetrics.actionPadding
backgroundColor: "transparent"
textColor: Theme.primary
onClicked: NotificationService.dismissGroup(root.notificationGroup?.key || "")
}
DankButton {
anchors.verticalCenter: parent.verticalCenter
text: (root.notificationGroup?.count || 0).toString()
iconName: "expand_less"
buttonHeight: NotificationMetrics.controlSize
horizontalPadding: Theme.spacingS
backgroundColor: root.cardChipColor
textColor: Theme.onSurfaceVariant
Accessible.name: I18n.tr("Collapse")
onClicked: root.toggleGroup()
}
}
}
Repeater {
id: notificationRepeater
objectName: "notificationRepeater"
model: ScriptModel {
values: root.expanded ? (root.notificationGroup?.notifications?.slice(0, NotificationMetrics.expandedLimit) || []) : []
}
NotificationSwipeRow {
id: row
required property var modelData
property bool collapsing: false
property var notificationToDismiss: null
readonly property real targetHeight: collapsing ? 0 : message.targetHeight
group: rowSwipe
bleed: root.swipeBleed
width: expandedContent.width
height: message.height
clip: collapsing
onDismissed: {
notificationToDismiss = modelData;
collapseAnimation.start();
}
Notifications.NotificationCard {
id: message
surfaceColor: root.cardSurfaceColor
chipColor: root.cardChipColor
x: row.offset
width: parent.width
notificationData: row.modelData
firstInGroup: row.index === 0
lastInGroup: row.index === notificationRepeater.count - 1
topRoundness: row.topRoundness
bottomRoundness: row.bottomRoundness
contentOpacity: row.contentOpacity
keyboardSelected: root.keyboardNavigationActive && root.selectedNotificationIndex === row.index
keyboardHints: keyboardSelected
descriptionExpanded: NotificationService.expandedMessages[(notificationData?.notification?.id || "") + "_desc"] || false
onExpandRequested: NotificationService.toggleMessageExpansion((notificationData?.notification?.id || "") + "_desc")
onDismissRequested: NotificationService.dismissNotification(row.modelData)
onActionRequested: action => root.invokeAction(action)
onBodyClicked: root.invokeAction(contextActions.defaultAction(notificationData))
onContextMenuRequested: (x, y) => root.openContextMenu(message, row.modelData)
}
SequentialAnimation {
id: collapseAnimation
PropertyAction {
target: row
property: "collapsing"
value: true
}
NumberAnimation {
target: row
property: "height"
to: 0
duration: NotificationMetrics.animationsEnabled ? Theme.notificationExpandDuration : 0
easing.type: Easing.BezierSpline
easing.bezierCurve: NotificationMetrics.expandCurve
}
ScriptAction {
script: NotificationService.dismissNotification(row.notificationToDismiss)
}
}
}
}
}
NotificationActions {
id: contextActions
appName: root.notificationGroup?.appName || ""
desktopEntry: root.notificationGroup?.latestNotification?.desktopEntry || ""
onDismissRequested: NotificationService.dismissGroup(root.notificationGroup?.key || "")
onAppMuted: NotificationService.dismissGroup(root.notificationGroup?.key || "")
}
DankDropdown {
id: notificationCardContextMenu
showTrigger: false
popupWidth: NotificationMetrics.menuWidth
transientSurfaceTracker: root.transientSurfaceTracker
options: contextActions.items.map(item => item.label)
onValueChanged: value => {
const item = contextActions.items.find(item => item.label === value);
if (item)
contextActions.trigger(item.action);
}
}
}