diff --git a/Modules/NotificationCenter.qml b/Modules/NotificationCenter.qml
index b8ceb51b..6f07e453 100644
--- a/Modules/NotificationCenter.qml
+++ b/Modules/NotificationCenter.qml
@@ -299,26 +299,19 @@ PanelWindow {
delegate: Rectangle {
required property var modelData
readonly property bool expanded: NotificationService.expandedGroups[modelData.key] || false
+ readonly property string groupKey: modelData.key
width: ListView.view.width
height: {
- if (expanded) {
- // Calculate expanded height: header (48) + spacing (16) + individual notifications
- let headerHeight = 48 + Theme.spacingM;
- let notificationHeight = modelData.notifications.length * (60 + Theme.spacingS); // Each notification ~60px + spacing
- let totalExpandedHeight = headerHeight + notificationHeight + Theme.spacingL * 2;
- return Math.max(totalExpandedHeight, 200); // Minimum expanded height
- } else {
- // Collapsed height: icon + content + quick reply (if any)
- let collapsedHeight = 72 + Theme.spacingS * 2;
- // Header height + spacing
- if (modelData.latestNotification.notification.hasInlineReply)
- collapsedHeight += 36 + Theme.spacingS;
-
- return collapsedHeight + Theme.spacingL * 2;
+ if (expanded && modelData.count >= 1) {
+ const baseHeight = (116 * modelData.count) + (12 * (modelData.count - 1));
+ // Add extra bottom margin for expanded groups
+ const bottomMargin = modelData.count === 1 ? 50 : (modelData.count < 3 ? 40 : 20);
+ return baseHeight + bottomMargin;
}
+ return 116;
}
- radius: Theme.cornerRadiusLarge
+ radius: 12
color: Theme.popupBackground()
border.color: modelData.latestNotification.urgency === 2 ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3) : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
border.width: modelData.latestNotification.urgency === 2 ? 2 : 1
@@ -336,356 +329,248 @@ PanelWindow {
visible: modelData.latestNotification.urgency === 2
}
- // Collapsed view - shows app header and latest notification
- Column {
+ // Collapsed view - show latest notification using popup style
+ Item {
id: collapsedContent
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
- anchors.topMargin: 14 // Reduced from Theme.spacingL (16px) by 10%
- anchors.bottomMargin: 14 // Reduced from Theme.spacingL (16px) by 10%
- anchors.leftMargin: Theme.spacingL
- anchors.rightMargin: Theme.spacingL
- spacing: Theme.spacingS
+ anchors.topMargin: 12
+ anchors.leftMargin: 16
+ anchors.rightMargin: 16
+ height: 92
visible: !expanded
- // App header with group info
- Item {
- width: parent.width
- height: 72
+ Rectangle {
+ id: iconContainer
- // App icon with proper fallback handling
- Item {
- id: iconContainer
+ readonly property bool hasNotificationImage: modelData.latestNotification.image && modelData.latestNotification.image !== ""
+ readonly property bool appIconIsImage: modelData.latestNotification.appIcon && (modelData.latestNotification.appIcon.startsWith("file://") || modelData.latestNotification.appIcon.startsWith("http://") || modelData.latestNotification.appIcon.startsWith("https://"))
- width: 48
- height: 48
- anchors.left: parent.left
- anchors.verticalCenter: parent.verticalCenter
+ width: 55
+ height: 55
+ radius: 27.5
+ color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
+ border.color: "transparent"
+ border.width: 0
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
- Rectangle {
- width: 48
- height: 48
- radius: 24
- color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
- border.color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3)
- border.width: 1
- clip: true
+ IconImage {
+ anchors.fill: parent
+ anchors.margins: 2
+ source: {
+ // Priority 1: Use notification image if available
+ if (parent.hasNotificationImage)
+ return modelData.latestNotification.cleanImage;
- readonly property bool hasNotificationImage: modelData.latestNotification.image && modelData.latestNotification.image !== ""
+ // Priority 2: Use appIcon - handle URLs directly, use iconPath for icon names
+ if (modelData.latestNotification.appIcon) {
+ const appIcon = modelData.latestNotification.appIcon;
+ if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://"))
+ return appIcon;
- IconImage {
- anchors.fill: parent
- anchors.margins: 2
- source: {
- // Priority 1: Use notification image if available
- if (parent.hasNotificationImage) {
- return modelData.latestNotification.cleanImage;
- }
-
- // Priority 2: Use appIcon - handle URLs directly, use iconPath for icon names
- if (modelData.latestNotification.appIcon) {
- const appIcon = modelData.latestNotification.appIcon;
- if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://")) {
- return appIcon;
- }
- return Quickshell.iconPath(appIcon, "");
- }
-
- return "";
- }
- visible: status === Image.Ready
+ return Quickshell.iconPath(appIcon, "");
}
-
- Text {
- anchors.centerIn: parent
- visible: !parent.hasNotificationImage && (!modelData.latestNotification.appIcon || modelData.latestNotification.appIcon === "")
- text: {
- const appName = modelData.appName || "?";
- return appName.charAt(0).toUpperCase();
- }
- font.pixelSize: 20
- font.weight: Font.Bold
- color: Theme.primaryText
- }
-
+ return "";
}
-
- // Count badge for multiple notifications
- Rectangle {
- width: 18
- height: 18
- radius: 9
- color: Theme.primary
- anchors.top: parent.top
- anchors.right: parent.right
- anchors.topMargin: -2
- anchors.rightMargin: -2
- visible: modelData.count > 1
-
- Text {
- anchors.centerIn: parent
- text: modelData.count > 99 ? "99+" : modelData.count.toString()
- color: Theme.primaryText
- font.pixelSize: 9
- font.weight: Font.Bold
- }
-
- }
-
+ visible: status === Image.Ready
}
- // Content area with proper spacing
- Column {
- anchors.left: iconContainer.right
- anchors.leftMargin: Theme.spacingM
- anchors.right: controlsContainer.left
- anchors.rightMargin: 8 // Reduced to align text with close button
+ Text {
+ anchors.centerIn: parent
+ visible: !parent.hasNotificationImage && (!modelData.latestNotification.appIcon || modelData.latestNotification.appIcon === "")
+ text: {
+ const appName = modelData.appName || "?";
+ return appName.charAt(0).toUpperCase();
+ }
+ font.pixelSize: 20
+ font.weight: Font.Bold
+ color: Theme.primaryText
+ }
+
+ Rectangle {
+ width: 18
+ height: 18
+ radius: 9
+ color: Theme.primary
anchors.top: parent.top
- anchors.topMargin: Theme.spacingS
- spacing: 6 // Reduced from Theme.spacingS (8px) by 2px
-
- // App name and timestamp
- Text {
- width: parent.width
- text: {
- if (modelData.latestNotification.timeStr.length > 0)
- return modelData.appName + " • " + modelData.latestNotification.timeStr;
- else
- return modelData.appName;
- }
- color: Theme.surfaceVariantText
- font.pixelSize: Theme.fontSizeSmall
- font.weight: Font.Medium
- elide: Text.ElideRight
- maximumLineCount: 1
- }
-
- // Latest notification title (emphasized)
- Text {
- text: modelData.latestNotification.summary
- color: Theme.surfaceText
- font.pixelSize: Theme.fontSizeMedium + 1
- font.weight: Font.Medium
- width: parent.width
- elide: Text.ElideRight
- maximumLineCount: 1
- visible: text.length > 0
- }
-
- // Latest notification body
- Text {
- text: modelData.latestNotification.body
- color: Theme.surfaceVariantText
- font.pixelSize: Theme.fontSizeSmall
- width: parent.width
- elide: Text.ElideRight
- maximumLineCount: modelData.count > 1 ? 1 : 2
- wrapMode: Text.WordWrap
- visible: text.length > 0
- }
-
- }
-
- // Controls aligned with app name and timestamp row
- Item {
- id: controlsContainer
-
- width: 72
- height: 32
anchors.right: parent.right
- anchors.top: parent.top
- anchors.topMargin: 8
-
- Rectangle {
- width: 32
- height: 32
- radius: 16
- anchors.left: parent.left
- color: expandArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
- visible: modelData.count > 1
-
- DankIcon {
- anchors.centerIn: parent
- name: "expand_more"
- size: 18
- color: Theme.surfaceText
- rotation: expanded ? 180 : 0
-
- Behavior on rotation {
- NumberAnimation {
- duration: Theme.shortDuration
- easing.type: Theme.standardEasing
- }
-
- }
-
- }
-
- MouseArea {
- id: expandArea
-
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- onClicked: NotificationService.toggleGroupExpansion(modelData.key)
- }
+ anchors.topMargin: -2
+ anchors.rightMargin: -2
+ visible: modelData.count > 1
+ Text {
+ anchors.centerIn: parent
+ text: modelData.count > 99 ? "99+" : modelData.count.toString()
+ color: Theme.primaryText
+ font.pixelSize: 9
+ font.weight: Font.Bold
}
-
- Rectangle {
- width: 32
- height: 32
- radius: 16
- anchors.right: parent.right
- color: dismissArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
-
- DankIcon {
- anchors.centerIn: parent
- name: "close"
- size: 16
- color: Theme.surfaceText
- }
-
- MouseArea {
- id: dismissArea
-
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- onClicked: NotificationService.dismissGroup(modelData.key)
- }
-
- }
-
}
-
}
- // Action buttons for collapsed view
- Row {
- width: parent.width
- spacing: Theme.spacingS
- visible: modelData.latestNotification.actions && modelData.latestNotification.actions.length > 0 && !modelData.latestNotification.notification.hasInlineReply && !expanded
+ Rectangle {
+ id: textContainer
- Repeater {
- model: modelData.latestNotification.actions ? modelData.latestNotification.actions.slice(0, 2) : []
- delegate: Rectangle {
- width: Math.min((parent.width - (parent.spacing * (parent.children.length - 1))) / parent.children.length, 120)
- height: 32
- radius: 16
- color: collapsedActionArea.containsMouse ? Theme.primary : Theme.surfaceContainer
- border.color: collapsedActionArea.containsMouse ? "transparent" : Theme.outline
- border.width: 1
+ anchors.left: iconContainer.right
+ anchors.leftMargin: 12
+ anchors.right: controlsContainer.left
+ anchors.rightMargin: 0
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 8
+ color: "transparent"
+ opacity: 1
+ border.color: "transparent"
+ border.width: 0
+
+ Item {
+ width: parent.width
+ height: parent.height
+ anchors.top: parent.top
+ anchors.topMargin: 2
+
+ Column {
+ id: textContent
+
+ width: parent.width
+ spacing: 2
Text {
- anchors.centerIn: parent
- text: modelData.text || ""
- color: collapsedActionArea.containsMouse ? Theme.primaryText : Theme.surfaceText
+ width: parent.width
+ text: {
+ if (modelData.latestNotification.timeStr.length > 0)
+ return modelData.appName + " • " + modelData.latestNotification.timeStr;
+ else
+ return modelData.appName;
+ }
+ color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
elide: Text.ElideRight
maximumLineCount: 1
- width: parent.width - 16
- horizontalAlignment: Text.AlignHCenter
}
- MouseArea {
- id: collapsedActionArea
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- onClicked: {
- if (modelData.invoke) {
- modelData.invoke();
- }
+ Text {
+ text: modelData.latestNotification.summary
+ color: Theme.surfaceText
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ width: parent.width
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ visible: text.length > 0
+ }
+
+ Text {
+ property bool hasUrls: {
+ const urlRegex = /(https?:\/\/[^\s]+)/g;
+ return urlRegex.test(modelData.latestNotification.body);
}
- }
- Behavior on color {
- ColorAnimation {
- duration: Theme.shortDuration
- easing.type: Theme.standardEasing
+ text: {
+ // Auto-detect and make URLs clickable, with truncation for center notifications
+ let bodyText = modelData.latestNotification.body;
+ // No truncation for notification center - show full text
+
+ const urlRegex = /(https?:\/\/[^\s]+)/g;
+ return bodyText.replace(urlRegex, '$1');
+ }
+ color: Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ width: parent.width
+ elide: Text.ElideRight
+ maximumLineCount: modelData.count > 1 ? 2 : 3
+ wrapMode: Text.WordWrap
+ visible: text.length > 0
+ textFormat: Text.RichText
+ onLinkActivated: function(link) {
+ Qt.openUrlExternally(link);
}
}
}
}
}
- // Enhanced quick reply for conversations
- Row {
- width: parent.width
- spacing: Theme.spacingS
- visible: modelData.latestNotification.notification.hasInlineReply && !expanded
+ Item {
+ id: controlsContainer
+ anchors.right: parent.right
+ anchors.rightMargin: 0
+ anchors.top: parent.top
+ anchors.topMargin: 0
+ width: modelData.count > 1 ? 40 : 20 // Dynamic width: 40px for expand+close, 20px for close only
+ height: 24
+
+ // Expand button - always takes up space but only visible when needed
Rectangle {
- width: parent.width - 60
- height: 36
- radius: 18
- color: Theme.surfaceContainer
- border.color: quickReplyField.activeFocus ? Theme.primary : Theme.outline
- border.width: 1
-
- TextField {
- id: quickReplyField
-
- anchors.fill: parent
- anchors.margins: Theme.spacingS
- placeholderText: modelData.latestNotification.notification.inlineReplyPlaceholder || "Quick reply..."
- color: Theme.surfaceText
- font.pixelSize: Theme.fontSizeSmall
- onAccepted: {
- if (text.length > 0) {
- modelData.latestNotification.notification.sendInlineReply(text);
- text = "";
- }
- }
-
- background: Item {
- }
-
- }
-
- }
-
- Rectangle {
- width: 52
- height: 36
- radius: 18
- color: quickReplyField.text.length > 0 ? Theme.primary : Theme.surfaceContainer
- border.color: quickReplyField.text.length > 0 ? "transparent" : Theme.outline
- border.width: quickReplyField.text.length > 0 ? 0 : 1
+ id: collapsedExpandButton
+ anchors.left: parent.left
+ anchors.top: parent.top
+ width: 20
+ height: 20
+ radius: 10
+ color: expandArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
+ visible: modelData.count > 1
DankIcon {
anchors.centerIn: parent
- name: "send"
- size: 16
- color: quickReplyField.text.length > 0 ? Theme.primaryText : Theme.surfaceVariantText
+ name: expanded ? "expand_less" : "expand_more"
+ size: 14
+ color: Theme.surfaceText
}
MouseArea {
+ id: expandArea
anchors.fill: parent
- enabled: quickReplyField.text.length > 0
- cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
- onClicked: {
- modelData.latestNotification.notification.sendInlineReply(quickReplyField.text);
- quickReplyField.text = "";
- }
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: NotificationService.toggleGroupExpansion(modelData.key)
}
-
- Behavior on color {
- ColorAnimation {
- duration: Theme.shortDuration
- easing.type: Theme.standardEasing
- }
-
- }
-
}
- }
+ // Close button - always positioned at the right edge
+ Rectangle {
+ id: closeButton
+ property bool isHovered: false
+
+ anchors.right: parent.right
+ anchors.top: parent.top
+ width: 20
+ height: 20
+ radius: 10
+ color: isHovered ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
+ z: 10
+
+ DankIcon {
+ id: closeIcon
+
+ name: "close"
+ size: 14
+ color: closeButton.isHovered ? Theme.primary : Theme.surfaceText
+ anchors.centerIn: parent
+ }
+
+ MouseArea {
+ id: dismissArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ z: 11
+ onEntered: {
+ closeButton.isHovered = true;
+ }
+ onExited: {
+ closeButton.isHovered = false;
+ }
+ onClicked: NotificationService.dismissGroup(modelData.key)
+ }
+ }
+ }
}
// Expanded view - shows all notifications stacked
@@ -702,7 +587,7 @@ PanelWindow {
spacing: 9 // Reduced from Theme.spacingM (12px) by 1/4
visible: expanded
- // 1st tier controls with app name - optimized spacing
+ // 1st tier controls with app name - optimized spacing
Item {
width: parent.width
height: 40
@@ -737,27 +622,29 @@ PanelWindow {
font.pixelSize: 10
font.weight: Font.Bold
}
+
}
+
}
// Controls container - fixed position on right
Item {
- width: 72
- height: 32
+ width: 48
+ height: 24
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
Rectangle {
- width: 32
- height: 32
- radius: 16
+ width: 20
+ height: 20
+ radius: 10
anchors.left: parent.left
color: collapseAreaTop.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
DankIcon {
anchors.centerIn: parent
name: "expand_less"
- size: 18
+ size: 14
color: Theme.surfaceText
}
@@ -773,16 +660,16 @@ PanelWindow {
}
Rectangle {
- width: 32
- height: 32
- radius: 16
+ width: 20
+ height: 20
+ radius: 10
anchors.right: parent.right
color: dismissAllAreaTop.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
DankIcon {
anchors.centerIn: parent
name: "close"
- size: 16
+ size: 14
color: Theme.surfaceText
}
@@ -798,8 +685,8 @@ PanelWindow {
}
}
- }
+ }
// Individual notifications
Column {
@@ -830,6 +717,8 @@ PanelWindow {
height: Math.max(32, contentColumn.height)
Rectangle {
+ readonly property bool hasNotificationImage: modelData.image && modelData.image !== ""
+
width: 32
height: 32
radius: 16
@@ -839,26 +728,22 @@ PanelWindow {
border.color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2)
border.width: 1
- readonly property bool hasNotificationImage: modelData.image && modelData.image !== ""
-
IconImage {
anchors.fill: parent
anchors.margins: 1
source: {
// Priority 1: Use notification image if available
- if (parent.hasNotificationImage) {
+ if (parent.hasNotificationImage)
return modelData.cleanImage;
- }
-
+
// Priority 2: Use appIcon - handle URLs directly, use iconPath for icon names
if (modelData.appIcon) {
const appIcon = modelData.appIcon;
- if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://")) {
+ if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://"))
return appIcon;
- }
+
return Quickshell.iconPath(appIcon, "");
}
-
return "";
}
visible: status === Image.Ready
@@ -889,19 +774,19 @@ PanelWindow {
// Expand/collapse button for 2nd tier
Rectangle {
- width: 24
- height: 24
- radius: 12
- color: individualExpandArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
- visible: modelData.body && modelData.body.length > 50 // Only show if body text is long enough
-
property bool isExpanded: NotificationService.expandedMessages[modelData.notification.id] || false
+ width: 20
+ height: 20
+ radius: 10
+ color: individualExpandArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
+ visible: (modelData.body || "").length > 80 // Only show if body text is long enough
+
DankIcon {
anchors.centerIn: parent
name: parent.isExpanded ? "expand_less" : "expand_more"
size: 12
- color: Theme.surfaceVariantText
+ color: Theme.surfaceText
}
MouseArea {
@@ -912,20 +797,21 @@ PanelWindow {
cursorShape: Qt.PointingHandCursor
onClicked: NotificationService.toggleMessageExpansion(modelData.notification.id)
}
+
}
// Individual dismiss button
Rectangle {
- width: 24
- height: 24
- radius: 12
+ width: 20
+ height: 20
+ radius: 10
color: individualDismissArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
DankIcon {
anchors.centerIn: parent
name: "close"
size: 12
- color: Theme.surfaceVariantText
+ color: individualDismissArea.containsMouse ? Theme.primary : Theme.surfaceText
}
MouseArea {
@@ -936,33 +822,38 @@ PanelWindow {
cursorShape: Qt.PointingHandCursor
onClicked: NotificationService.dismissNotification(modelData)
}
+
}
+
}
- Column {
- id: contentColumn
-
+ Item {
anchors.left: parent.left
anchors.leftMargin: 44
anchors.right: parent.right
anchors.rightMargin: 24 // Align text with close button
anchors.top: parent.top
- spacing: 2 // Reduced from Theme.spacingXS (4px) by 2px
+ height: contentColumn.height
- property bool isMessageExpanded: NotificationService.expandedMessages[modelData.notification.id] || false
+ Column {
+ id: contentColumn
+
+ property bool isMessageExpanded: NotificationService.expandedMessages[modelData.notification.id] || false
+
+ width: parent.width
+ spacing: 2 // Reduced from Theme.spacingXS (4px) by 2px
// Title • timestamp format
Text {
text: {
const summary = modelData.summary || "";
const timeStr = modelData.timeStr || "";
- if (summary && timeStr) {
+ if (summary && timeStr)
return summary + " • " + timeStr;
- } else if (summary) {
+ else if (summary)
return summary;
- } else {
+ else
return "Message • " + timeStr;
- }
}
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeSmall
@@ -984,57 +875,28 @@ PanelWindow {
visible: text.length > 0
}
- // Individual action buttons
- Row {
- width: parent.width
- spacing: Theme.spacingXS
- visible: modelData.actions && modelData.actions.length > 0 && !modelData.notification.hasInlineReply
+ // Clickable area for View action on individual message
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: {
+ // Find and invoke the View action
+ if (modelData.actions) {
+ for (const action of modelData.actions) {
+ if (action.text && action.text.toLowerCase() === "view") {
+ if (action.invoke)
+ action.invoke();
- Repeater {
- model: modelData.actions ? modelData.actions.slice(0, 2) : []
- delegate: Rectangle {
- width: Math.min((parent.width - (parent.spacing * (parent.children.length - 1))) / parent.children.length, 80)
- height: 24
- radius: 12
- color: expandedActionArea.containsMouse ? Theme.primary : Theme.surfaceContainer
- border.color: expandedActionArea.containsMouse ? "transparent" : Theme.outline
- border.width: 1
-
- Text {
- anchors.centerIn: parent
- text: modelData.text || ""
- color: expandedActionArea.containsMouse ? Theme.primaryText : Theme.surfaceText
- font.pixelSize: 10
- font.weight: Font.Medium
- elide: Text.ElideRight
- maximumLineCount: 1
- width: parent.width - 8
- horizontalAlignment: Text.AlignHCenter
- }
-
- MouseArea {
- id: expandedActionArea
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- onClicked: {
- if (modelData.invoke) {
- modelData.invoke();
- }
- }
- }
-
- Behavior on color {
- ColorAnimation {
- duration: Theme.shortDuration
- easing.type: Theme.standardEasing
+ break;
}
}
}
}
}
- // Individual inline reply
+ // COMMENTED OUT: Individual inline reply
+ /*
Row {
width: parent.width
spacing: Theme.spacingS
@@ -1096,7 +958,9 @@ PanelWindow {
}
}
+ */
+ }
}
}
@@ -1140,7 +1004,6 @@ PanelWindow {
Item {
width: parent.width
height: 200
- anchors.centerIn: parent
visible: NotificationService.notifications.length === 0
Column {
diff --git a/Modules/NotificationPopup.qml b/Modules/NotificationPopup.qml
index 4f7d5303..54cac356 100644
--- a/Modules/NotificationPopup.qml
+++ b/Modules/NotificationPopup.qml
@@ -16,7 +16,7 @@ PanelWindow {
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
color: "transparent"
implicitWidth: 400
- implicitHeight: notificationsList.height + 32
+ implicitHeight: Math.min(500, notificationsList.height + 32)
anchors {
top: true
@@ -28,374 +28,826 @@ PanelWindow {
right: 12
}
- Column {
- id: notificationsList
-
+ Rectangle {
anchors.top: parent.top
anchors.right: parent.right
anchors.topMargin: 16
anchors.rightMargin: 16
- spacing: Theme.spacingM
+ anchors.bottomMargin: 16
width: 380
+ height: Math.min(500 - 32, notificationsList.height + 32) // Match notification center height minus margins
+ color: "transparent"
+ radius: 12
+ clip: true
+
+ ScrollView {
+ anchors.fill: parent
+ clip: true
- Repeater {
- model: NotificationService.groupedPopups
+ Column {
+ id: notificationsList
- delegate: Rectangle {
- required property var modelData
+ width: parent.width
+ spacing: 12
- width: parent.width
- height: content.height + Theme.spacingL * 2
- radius: Theme.cornerRadiusLarge
- color: Theme.popupBackground()
- border.color: modelData.latestNotification.urgency === 2 ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3) : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
- border.width: modelData.latestNotification.urgency === 2 ? 2 : 1
- clip: true
+ Repeater {
+ model: NotificationService.groupedPopups
- Rectangle {
- width: 4
- height: parent.height - 16
- anchors.left: parent.left
- anchors.leftMargin: 2
- anchors.verticalCenter: parent.verticalCenter
- radius: 2
- color: Theme.primary
- visible: modelData.latestNotification.urgency === 2
- }
+ delegate: Rectangle {
+ required property var modelData
+ readonly property bool expanded: NotificationService.expandedGroups[modelData.key] || false
+ readonly property string groupKey: modelData.key
+ readonly property bool isPopup: modelData.latestNotification.popup
+ readonly property int expireTimeout: modelData.latestNotification.notification.expireTimeout
+
+ property string stableGroupKey: ""
+
+ Component.onCompleted: {
+ stableGroupKey = modelData.key;
+ }
+
- Row {
- id: content
- anchors.top: parent.top
- anchors.left: parent.left
- anchors.right: parent.right
- anchors.margins: Theme.spacingL
- spacing: Theme.spacingM
- height: Math.max(48, textContent.height)
+ width: parent.width
+ height: {
+ if (expanded && modelData.count >= 1) {
+ const baseHeight = (116 * modelData.count) + (12 * (modelData.count - 1));
+ // Add extra bottom margin for View/Dismiss buttons when there are fewer than 3 messages
+ const bottomMargin = modelData.count === 1 ? 70 : (modelData.count < 3 ? 50 : 0);
+ return baseHeight + bottomMargin;
+ }
+ return 116;
+ }
+ radius: 12
+ color: Theme.popupBackground()
+ border.color: modelData.latestNotification.urgency === 2 ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3) : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
+ border.width: modelData.latestNotification.urgency === 2 ? 2 : 1
+ clip: true
Rectangle {
- width: 48
- height: 48
- radius: 24
- color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
- border.color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.3)
- border.width: 1
+ width: 4
+ height: parent.height - 16
+ anchors.left: parent.left
+ anchors.leftMargin: 2
anchors.verticalCenter: parent.verticalCenter
+ radius: 2
+ color: Theme.primary
+ visible: modelData.latestNotification.urgency === 2
+ }
- readonly property bool hasNotificationImage: modelData.latestNotification.image && modelData.latestNotification.image !== ""
- readonly property bool appIconIsImage: modelData.latestNotification.appIcon &&
- (modelData.latestNotification.appIcon.startsWith("file://") ||
- modelData.latestNotification.appIcon.startsWith("http://") ||
- modelData.latestNotification.appIcon.startsWith("https://"))
+ // Collapsed view - show only latest notification
+ Item {
+ id: collapsedContent
- IconImage {
- anchors.fill: parent
- anchors.margins: 2
- source: {
- // Priority 1: Use notification image if available
- if (parent.hasNotificationImage) {
- return modelData.latestNotification.cleanImage;
- }
-
- // Priority 2: Use appIcon - handle URLs directly, use iconPath for icon names
- if (modelData.latestNotification.appIcon) {
- const appIcon = modelData.latestNotification.appIcon;
- if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://")) {
- return appIcon;
- }
- return Quickshell.iconPath(appIcon, "");
- }
-
- return "";
- }
- visible: status === Image.Ready
- }
-
- Text {
- anchors.centerIn: parent
- visible: !parent.hasNotificationImage && (!modelData.latestNotification.appIcon || modelData.latestNotification.appIcon === "")
- text: {
- const appName = modelData.appName || "?";
- return appName.charAt(0).toUpperCase();
- }
- font.pixelSize: 20
- font.weight: Font.Bold
- color: Theme.primaryText
- }
+ anchors.top: parent.top
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.topMargin: 12
+ anchors.leftMargin: 16
+ anchors.rightMargin: 16
+ height: 86
+ visible: !expanded
Rectangle {
- width: 18
- height: 18
- radius: 9
- color: Theme.primary
- anchors.top: parent.top
- anchors.right: parent.right
- anchors.topMargin: -2
- anchors.rightMargin: -2
- visible: modelData.count > 1
+ id: iconContainer
+
+ readonly property bool hasNotificationImage: modelData.latestNotification.image && modelData.latestNotification.image !== ""
+ readonly property bool appIconIsImage: modelData.latestNotification.appIcon && (modelData.latestNotification.appIcon.startsWith("file://") || modelData.latestNotification.appIcon.startsWith("http://") || modelData.latestNotification.appIcon.startsWith("https://"))
+
+ width: 55
+ height: 55
+ radius: 27.5
+ color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
+ border.color: "transparent"
+ border.width: 0
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+
+ IconImage {
+ anchors.fill: parent
+ anchors.margins: 2
+ source: {
+ // Priority 1: Use notification image if available
+ if (parent.hasNotificationImage)
+ return modelData.latestNotification.cleanImage;
+
+ // Priority 2: Use appIcon - handle URLs directly, use iconPath for icon names
+ if (modelData.latestNotification.appIcon) {
+ const appIcon = modelData.latestNotification.appIcon;
+ if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://"))
+ return appIcon;
+
+ return Quickshell.iconPath(appIcon, "");
+ }
+ return "";
+ }
+ visible: status === Image.Ready
+ }
Text {
anchors.centerIn: parent
- text: modelData.count > 99 ? "99+" : modelData.count.toString()
- color: Theme.primaryText
- font.pixelSize: 9
+ visible: !parent.hasNotificationImage && (!modelData.latestNotification.appIcon || modelData.latestNotification.appIcon === "")
+ text: {
+ const appName = modelData.appName || "?";
+ return appName.charAt(0).toUpperCase();
+ }
+ font.pixelSize: 20
font.weight: Font.Bold
+ color: Theme.primaryText
+ }
+
+ Rectangle {
+ width: 18
+ height: 18
+ radius: 9
+ color: Theme.primary
+ anchors.top: parent.top
+ anchors.right: parent.right
+ anchors.topMargin: -2
+ anchors.rightMargin: -2
+ visible: modelData.count > 1
+
+ Text {
+ anchors.centerIn: parent
+ text: modelData.count > 99 ? "99+" : modelData.count.toString()
+ color: Theme.primaryText
+ font.pixelSize: 9
+ font.weight: Font.Bold
+ }
}
}
- }
- Column {
- id: textContent
- anchors.verticalCenter: parent.verticalCenter
- spacing: 4
- width: parent.width - 48 - Theme.spacingM - controls.width - Theme.spacingS
+ Rectangle {
+ id: textContainer
- Text {
- width: parent.width
- text: {
- if (modelData.latestNotification.timeStr.length > 0)
- return modelData.appName + " • " + modelData.latestNotification.timeStr;
- else
- return modelData.appName;
- }
- color: Theme.surfaceVariantText
- font.pixelSize: Theme.fontSizeSmall
- font.weight: Font.Medium
- elide: Text.ElideRight
- maximumLineCount: 1
- }
+ anchors.left: iconContainer.right
+ anchors.leftMargin: 12
+ anchors.right: controlsContainer.left
+ anchors.rightMargin: 0
+ anchors.top: parent.top
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 8
+ color: "transparent"
+ opacity: 1
+ border.color: "transparent"
+ border.width: 0
- Text {
- text: modelData.latestNotification.summary
- color: Theme.surfaceText
- font.pixelSize: Theme.fontSizeMedium
- font.weight: Font.Medium
- width: parent.width
- elide: Text.ElideRight
- maximumLineCount: 1
- visible: text.length > 0
- }
+ Item {
+ width: parent.width
+ height: parent.height
+ anchors.top: parent.top
+ anchors.topMargin: 2
- Text {
- text: modelData.latestNotification.body
- color: Theme.surfaceVariantText
- font.pixelSize: Theme.fontSizeSmall
- width: parent.width
- elide: Text.ElideRight
- maximumLineCount: modelData.count > 1 ? 1 : 2
- wrapMode: Text.WordWrap
- visible: text.length > 0
- }
+ Column {
+ id: textContent
- // Action buttons
- Row {
- width: parent.width
- spacing: Theme.spacingS
- visible: modelData.latestNotification.actions && modelData.latestNotification.actions.length > 0 && !modelData.latestNotification.notification.hasInlineReply
-
- Repeater {
- model: modelData.latestNotification.actions ? modelData.latestNotification.actions.slice(0, 3) : []
- delegate: Rectangle {
- width: Math.min((parent.width - (parent.spacing * (parent.children.length - 1))) / parent.children.length, 120)
- height: 32
- radius: 16
- color: actionArea.containsMouse ? Theme.primary : Theme.surfaceContainer
- border.color: actionArea.containsMouse ? "transparent" : Theme.outline
- border.width: 1
+ width: parent.width
+ spacing: 2
Text {
- anchors.centerIn: parent
- text: modelData.text || ""
- color: actionArea.containsMouse ? Theme.primaryText : Theme.surfaceText
+ width: parent.width
+ text: {
+ if (modelData.latestNotification.timeStr.length > 0)
+ return modelData.appName + " • " + modelData.latestNotification.timeStr;
+ else
+ return modelData.appName;
+ }
+ color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
elide: Text.ElideRight
maximumLineCount: 1
- width: parent.width - 16
- horizontalAlignment: Text.AlignHCenter
}
- MouseArea {
- id: actionArea
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- onClicked: {
- if (modelData.invoke) {
- modelData.invoke();
- }
+ Text {
+ text: modelData.latestNotification.summary
+ color: Theme.surfaceText
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ width: parent.width
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ visible: text.length > 0
+ }
+
+ Text {
+ property bool hasUrls: {
+ const urlRegex = /(https?:\/\/[^\s]+)/g;
+ return urlRegex.test(modelData.latestNotification.body);
}
- }
- Behavior on color {
- ColorAnimation {
- duration: Theme.shortDuration
- easing.type: Theme.standardEasing
+ text: {
+ // Auto-detect and make URLs clickable, with truncation for popups
+ let bodyText = modelData.latestNotification.body;
+ // Truncate to 108 characters max for popup notifications
+ if (bodyText.length > 105)
+ bodyText = bodyText.substring(0, 102) + "...";
+
+ const urlRegex = /(https?:\/\/[^\s]+)/g;
+ return bodyText.replace(urlRegex, '$1');
+ }
+ color: Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ width: parent.width
+ elide: Text.ElideRight
+ maximumLineCount: modelData.count > 1 ? 1 : 2
+ wrapMode: Text.WordWrap
+ visible: text.length > 0
+ textFormat: Text.RichText
+ onLinkActivated: function(link) {
+ Qt.openUrlExternally(link);
}
}
}
}
}
- // Inline reply
- Row {
- width: parent.width
- spacing: Theme.spacingS
- visible: modelData.latestNotification.notification.hasInlineReply
+ Item {
+ id: controlsContainer
+ anchors.right: parent.right
+ anchors.rightMargin: 0
+ anchors.top: parent.top
+ anchors.topMargin: 0
+ width: modelData.count > 1 ? 40 : 20 // Dynamic width: 40px for expand+close, 20px for close only
+ height: 24
+
+ // Expand button - always takes up space but only visible when needed
Rectangle {
- width: parent.width - 60
- height: 36
- radius: 18
- color: Theme.surfaceContainer
- border.color: quickReplyField.activeFocus ? Theme.primary : Theme.outline
- border.width: 1
-
- TextField {
- id: quickReplyField
- anchors.fill: parent
- anchors.margins: Theme.spacingS
- placeholderText: modelData.latestNotification.notification.inlineReplyPlaceholder || "Quick reply..."
- color: Theme.surfaceText
- font.pixelSize: Theme.fontSizeSmall
- onAccepted: {
- if (text.length > 0) {
- modelData.latestNotification.notification.sendInlineReply(text);
- text = "";
- }
- }
- background: Item {}
- }
- }
-
- Rectangle {
- width: 52
- height: 36
- radius: 18
- color: quickReplyField.text.length > 0 ? Theme.primary : Theme.surfaceContainer
- border.color: quickReplyField.text.length > 0 ? "transparent" : Theme.outline
- border.width: quickReplyField.text.length > 0 ? 0 : 1
+ id: collapsedExpandButton
+ anchors.left: parent.left
+ anchors.top: parent.top
+ width: 20
+ height: 20
+ radius: 10
+ color: expandArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
+ visible: modelData.count > 1
DankIcon {
anchors.centerIn: parent
- name: "send"
- size: 16
- color: quickReplyField.text.length > 0 ? Theme.primaryText : Theme.surfaceVariantText
+ name: expanded ? "expand_less" : "expand_more"
+ size: 14
+ color: Theme.surfaceText
}
MouseArea {
+ id: expandArea
anchors.fill: parent
- enabled: quickReplyField.text.length > 0
- cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
- onClicked: {
- modelData.latestNotification.notification.sendInlineReply(quickReplyField.text);
- quickReplyField.text = "";
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: NotificationService.toggleGroupExpansion(modelData.key)
+ }
+ }
+
+ // Close button - always positioned at the right edge
+ Rectangle {
+ id: closeButton
+
+ property bool isHovered: false
+
+ anchors.right: parent.right
+ anchors.top: parent.top
+ width: 20
+ height: 20
+ radius: 10
+ color: isHovered ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
+ z: 10
+
+ DankIcon {
+ id: closeIcon
+
+ name: "close"
+ size: 14
+ color: closeButton.isHovered ? Theme.primary : Theme.surfaceText
+ anchors.centerIn: parent
+ }
+
+ MouseArea {
+ id: dismissArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ z: 11
+ onEntered: {
+ closeButton.isHovered = true;
+ dismissTimer.stop();
+ }
+ onExited: {
+ closeButton.isHovered = false;
+ if (modelData.latestNotification.popup && !cardHoverArea.containsMouse) {
+ dismissTimer.restart();
+ }
+ }
+ onClicked: NotificationService.dismissGroup(modelData.key)
+ }
+ }
+ }
+ }
+
+ // Expanded view - show all notifications in group
+ Item {
+ anchors.fill: parent
+ anchors.margins: 16
+ visible: expanded
+
+ Column {
+ id: expandedColumn
+ width: parent.width
+ spacing: 8
+
+ // Header with app name and count
+ Item {
+ width: parent.width
+ height: 32
+
+ Row {
+ anchors.left: parent.left
+ anchors.verticalCenter: parent.verticalCenter
+ spacing: 8
+
+ Text {
+ text: modelData.appName
+ color: Theme.surfaceText
+ font.pixelSize: Theme.fontSizeLarge
+ font.weight: Font.Bold
+ }
+
+ Rectangle {
+ width: 20
+ height: 20
+ radius: 10
+ color: Theme.primary
+ visible: modelData.count > 1
+
+ Text {
+ anchors.centerIn: parent
+ text: modelData.count.toString()
+ color: Theme.primaryText
+ font.pixelSize: 10
+ font.weight: Font.Bold
+ }
}
}
- Behavior on color {
- ColorAnimation {
- duration: Theme.shortDuration
- easing.type: Theme.standardEasing
+ Item {
+ id: expandedControls
+ anchors.right: parent.right
+ anchors.verticalCenter: parent.verticalCenter
+ width: 56
+ height: 24
+
+ Row {
+ anchors.fill: parent
+ spacing: 8
+
+ Rectangle {
+ width: 20
+ height: 20
+ radius: 10
+ color: expandedExpandArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "expand_less"
+ size: 14
+ color: Theme.surfaceText
+ }
+
+ MouseArea {
+ id: expandedExpandArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: NotificationService.toggleGroupExpansion(modelData.key)
+ }
+ }
+
+ Rectangle {
+ width: 20
+ height: 20
+ radius: 10
+ color: expandedCloseArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "close"
+ size: 14
+ color: expandedCloseArea.containsMouse ? Theme.primary : Theme.surfaceText
+ }
+
+ MouseArea {
+ id: expandedCloseArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: NotificationService.dismissGroup(modelData.key)
+ }
+ }
+ }
+ }
+ }
+
+ // Scrollable list of individual notifications
+ Rectangle {
+ width: parent.width
+ height: Math.min(400, modelData.notifications.length * 90) // Fixed height constraint for inner scroll
+ radius: 8
+ color: "transparent"
+ border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.1)
+ border.width: 1
+ clip: true
+
+ ScrollView {
+ anchors.fill: parent
+ clip: true
+
+ Column {
+ width: parent.width
+ spacing: 8
+
+ Repeater {
+ model: modelData.notifications
+
+ delegate: Rectangle {
+ required property var modelData
+ readonly property bool messageExpanded: NotificationService.expandedMessages[modelData.notification.id] || false
+
+ width: parent.width
+ height: messageExpanded ? Math.min(120, 50 + (bodyText.contentHeight || 0)) : 80
+ radius: 8
+ color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.3)
+ border.color: "transparent"
+ border.width: 0
+
+ Item {
+ anchors.fill: parent
+ anchors.margins: 12
+
+ // Small icon for individual message
+ Rectangle {
+ id: messageIcon
+ width: 32
+ height: 32
+ radius: 16
+ color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1)
+ anchors.left: parent.left
+ anchors.top: parent.top
+
+ readonly property bool hasNotificationImage: modelData.image && modelData.image !== ""
+ readonly property bool appIconIsImage: modelData.appIcon && (modelData.appIcon.startsWith("file://") || modelData.appIcon.startsWith("http://") || modelData.appIcon.startsWith("https://"))
+
+ IconImage {
+ anchors.fill: parent
+ anchors.margins: 2
+ source: {
+ // Priority 1: Use notification image if available
+ if (parent.hasNotificationImage)
+ return modelData.cleanImage;
+
+ // Priority 2: Use appIcon - handle URLs directly, use iconPath for icon names
+ if (modelData.appIcon) {
+ const appIcon = modelData.appIcon;
+ if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://"))
+ return appIcon;
+
+ return Quickshell.iconPath(appIcon, "");
+ }
+ return "";
+ }
+ visible: status === Image.Ready
+ }
+
+ Text {
+ anchors.centerIn: parent
+ visible: !parent.hasNotificationImage && (!modelData.appIcon || modelData.appIcon === "")
+ text: {
+ const appName = modelData.appName || "?";
+ return appName.charAt(0).toUpperCase();
+ }
+ font.pixelSize: 14
+ font.weight: Font.Bold
+ color: Theme.primaryText
+ }
+ }
+
+ // Message content
+ Column {
+ anchors.left: messageIcon.right
+ anchors.leftMargin: 12
+ anchors.right: messageControls.left
+ anchors.rightMargin: 0
+ anchors.top: parent.top
+ spacing: 4
+
+ // App Title • Timestamp line
+ Text {
+ width: parent.width
+ text: {
+ const appName = modelData.appName || "";
+ const timeStr = modelData.timeStr || "";
+ if (timeStr.length > 0) {
+ return appName + " • " + timeStr;
+ } else {
+ return appName;
+ }
+ }
+ color: Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ }
+
+ // Summary line (if exists)
+ Text {
+ width: parent.width
+ text: modelData.summary || ""
+ color: Theme.surfaceText
+ font.pixelSize: Theme.fontSizeMedium
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ maximumLineCount: 1
+ visible: text.length > 0
+ }
+
+ // Body text with expand capability
+ Text {
+ id: bodyText
+ width: parent.width
+ text: {
+ const body = modelData.body || "";
+ if (messageExpanded) {
+ // Show up to 500 characters when expanded
+ return body.length > 500 ? body.substring(0, 497) + "..." : body;
+ } else {
+ // Show truncated version when collapsed
+ return body.length > 80 ? body.substring(0, 77) + "..." : body;
+ }
+ }
+ color: Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ elide: messageExpanded ? Text.ElideNone : Text.ElideRight
+ maximumLineCount: messageExpanded ? -1 : 2
+ wrapMode: Text.WordWrap
+ visible: text.length > 0
+ }
+ }
+
+ // Message controls (expand and close buttons)
+ Row {
+ id: messageControls
+ anchors.right: parent.right
+ anchors.top: parent.top
+ spacing: 4
+
+ // Expand/collapse button for individual message
+ Rectangle {
+ id: expandButton
+ width: 20
+ height: 20
+ radius: 10
+ color: expandMessageArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
+ visible: (modelData.body || "").length > 80
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: messageExpanded ? "expand_less" : "expand_more"
+ size: 12
+ color: Theme.surfaceText
+ }
+
+ MouseArea {
+ id: expandMessageArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: NotificationService.toggleMessageExpansion(modelData.notification.id)
+ }
+ }
+
+ // Close button for individual message
+ Rectangle {
+ width: 20
+ height: 20
+ radius: 10
+ color: closeMessageArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
+
+ DankIcon {
+ anchors.centerIn: parent
+ name: "close"
+ size: 12
+ color: closeMessageArea.containsMouse ? Theme.primary : Theme.surfaceText
+ }
+
+ MouseArea {
+ id: closeMessageArea
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: NotificationService.dismissNotification(modelData)
+ }
+ }
+ }
+ }
+
+ Behavior on height {
+ NumberAnimation {
+ duration: Theme.shortDuration
+ easing.type: Theme.standardEasing
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Main hover area for persistence
+ MouseArea {
+ id: cardHoverArea
+ anchors.fill: parent
+ hoverEnabled: true
+ acceptedButtons: Qt.NoButton
+ propagateComposedEvents: true
+ z: 0
+
+ onEntered: {
+ dismissTimer.stop();
+ }
+ onExited: {
+ if (modelData.latestNotification.popup) {
+ dismissTimer.restart();
+ }
+ }
+ }
+
+ // View button positioned at bottom-right of notification card
+ Rectangle {
+ id: viewButton
+
+ property bool isHovered: false
+
+ anchors.right: dismissButton.left
+ anchors.rightMargin: 4
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 8
+ width: viewText.width + 16
+ height: viewText.height + 8
+ radius: 6
+ color: isHovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) : "transparent"
+ z: 10
+
+ Text {
+ id: viewText
+
+ text: "View"
+ color: viewButton.isHovered ? Theme.primary : Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ anchors.centerIn: parent
+ }
+
+ MouseArea {
+ id: viewArea
+
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ z: 11
+ onEntered: {
+ viewButton.isHovered = true;
+ dismissTimer.stop();
+ }
+ onExited: {
+ viewButton.isHovered = false;
+ if (modelData.latestNotification.popup && !cardHoverArea.containsMouse) {
+ dismissTimer.restart();
+ }
+ }
+ onClicked: {
+ // Handle navigation to source message
+ if (modelData.latestNotification.actions) {
+ for (const action of modelData.latestNotification.actions) {
+ if (action.text && action.text.toLowerCase() === "view") {
+ if (action.invoke) {
+ action.invoke();
+ return;
+ }
+ }
+ }
+ // If no View action, try the first available action
+ if (modelData.latestNotification.actions.length > 0) {
+ const firstAction = modelData.latestNotification.actions[0];
+ if (firstAction.invoke) {
+ firstAction.invoke();
+ }
}
}
}
}
}
- Row {
- id: controls
- anchors.verticalCenter: parent.verticalCenter
- spacing: 8
+ // Dismiss button positioned at bottom-right of notification card
+ Rectangle {
+ id: dismissButton
- Rectangle {
- width: 32
- height: 32
- radius: 16
- color: expandArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
- visible: modelData.count > 1
+ property bool isHovered: false
- DankIcon {
- anchors.centerIn: parent
- name: "expand_more"
- size: 18
- color: Theme.surfaceText
- }
+ anchors.right: parent.right
+ anchors.rightMargin: 16
+ anchors.bottom: parent.bottom
+ anchors.bottomMargin: 8
+ width: dismissText.width + 16
+ height: dismissText.height + 8
+ radius: 6
+ color: isHovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) : "transparent"
+ z: 10
- MouseArea {
- id: expandArea
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- onClicked: NotificationService.toggleGroupExpansion(modelData.key)
- }
+ Text {
+ id: dismissText
+
+ text: "Dismiss"
+ color: dismissButton.isHovered ? Theme.primary : Theme.surfaceVariantText
+ font.pixelSize: Theme.fontSizeSmall
+ font.weight: Font.Medium
+ anchors.centerIn: parent
}
- Rectangle {
- width: 32
- height: 32
- radius: 16
- color: dismissArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent"
+ MouseArea {
+ id: popupDismissArea
- DankIcon {
- anchors.centerIn: parent
- name: "close"
- size: 16
- color: Theme.surfaceText
+ anchors.fill: parent
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ z: 11
+ onEntered: {
+ dismissButton.isHovered = true;
+ dismissTimer.stop();
}
-
- MouseArea {
- id: dismissArea
- anchors.fill: parent
- hoverEnabled: true
- cursorShape: Qt.PointingHandCursor
- onClicked: NotificationService.dismissGroup(modelData.key)
+ onExited: {
+ dismissButton.isHovered = false;
+ if (modelData.latestNotification.popup && !cardHoverArea.containsMouse) {
+ dismissTimer.restart();
+ }
+ }
+ onClicked: {
+ // Move to notification center (don't close)
+ const groupKey = stableGroupKey || modelData.key;
+ console.log("Manually hiding notification group from popup:", groupKey);
+ modelData.latestNotification.popup = false;
+ // Clear expansion state when manually hiding from popup
+ NotificationService.clearGroupExpansionState(groupKey);
}
}
}
- }
- MouseArea {
- anchors.fill: parent
- hoverEnabled: true
- acceptedButtons: Qt.NoButton
- propagateComposedEvents: true
- onEntered: dismissTimer.stop()
- onExited: {
- if (modelData.latestNotification.popup)
- dismissTimer.restart();
- }
- }
+ Timer {
+ id: dismissTimer
- Timer {
- id: dismissTimer
- running: modelData.latestNotification.popup
- interval: modelData.latestNotification.notification.expireTimeout > 0 ? modelData.latestNotification.notification.expireTimeout * 1000 : 5000
- onTriggered: {
- if (!parent.children[parent.children.length - 2].containsMouse) {
+ running: isPopup
+ interval: 5000 // Fixed 5-second timer for all notifications
+ onTriggered: {
+ // Move to notification center (don't dismiss completely)
+ const groupKey = stableGroupKey || modelData.key;
+ console.log("Auto-hiding notification group from popup:", groupKey);
modelData.latestNotification.popup = false;
+ // Clear expansion state when hiding from popup
+ NotificationService.clearGroupExpansionState(groupKey);
}
}
- }
- transform: Translate {
- x: root.visible ? 0 : 400
- Behavior on x {
+ // Watch for changes to latest notification (new message joins group)
+ property var currentLatestNotification: modelData.latestNotification
+ onCurrentLatestNotificationChanged: {
+ if (isPopup && !cardHoverArea.containsMouse) {
+ dismissTimer.restart();
+ }
+ }
+
+ transform: Translate {
+ x: root.visible ? 0 : 400
+
+ Behavior on x {
+ NumberAnimation {
+ duration: 350
+ easing.type: Easing.OutCubic
+ }
+ }
+ }
+
+ Behavior on opacity {
NumberAnimation {
- duration: 350
+ duration: 300
easing.type: Easing.OutCubic
}
}
}
-
- Behavior on opacity {
- NumberAnimation {
- duration: 300
- easing.type: Easing.OutCubic
- }
- }
}
}
}
+ }
Behavior on implicitHeight {
NumberAnimation {
diff --git a/Services/NotificationService.qml b/Services/NotificationService.qml
index 21507726..49f751bf 100644
--- a/Services/NotificationService.qml
+++ b/Services/NotificationService.qml
@@ -60,6 +60,8 @@ Singleton {
});
if (wrapper) {
+ const groupKey = getGroupKey(wrapper);
+ console.log("New notification added to group:", groupKey, "Expansion state:", expandedGroups[groupKey] || false);
// Handle media notification replacement
if (wrapper.isMedia) {
handleMediaNotification(wrapper);
@@ -67,19 +69,7 @@ Singleton {
root.notifications.push(wrapper);
}
- // Auto-expand conversation groups with new messages
- if (wrapper.isConversation && notifications.length > 1) {
- const groupKey = getGroupKey(wrapper);
- const existingGroup = groupedNotifications.find(group => group.key === groupKey);
- if (existingGroup && existingGroup.count > 1) {
- let newExpandedGroups = {};
- for (const key in expandedGroups) {
- newExpandedGroups[key] = expandedGroups[key];
- }
- newExpandedGroups[groupKey] = true;
- expandedGroups = newExpandedGroups;
- }
- }
+ // Don't auto-expand groups - let user control expansion state
// Add to persistent storage (only for non-transient notifications)
if (!notif.transient) {
@@ -216,18 +206,7 @@ Singleton {
- readonly property Timer timer: Timer {
- running: wrapper.popup
- interval: {
- if (wrapper.notification.expireTimeout > 0) {
- return wrapper.notification.expireTimeout * 1000;
- }
- return NotificationSettings.getAppTimeout(wrapper.appName);
- }
- onTriggered: {
- wrapper.popup = false;
- }
- }
+
readonly property Connections conn: Connections {
target: wrapper.notification.Retainable
@@ -235,7 +214,19 @@ Singleton {
function onDropped(): void {
const index = root.notifications.indexOf(wrapper);
if (index !== -1) {
+ // Get the group key before removing the notification
+ const groupKey = getGroupKey(wrapper);
root.notifications.splice(index, 1);
+
+ // Check if this group now has no notifications left
+ const remainingInGroup = root.notifications.filter(n => getGroupKey(n) === groupKey);
+ if (remainingInGroup.length === 0) {
+ // Immediately clear expansion state for empty group
+ clearGroupExpansionState(groupKey);
+ }
+
+ // Clean up all expansion states
+ cleanupExpansionStates();
}
}
@@ -257,6 +248,7 @@ Singleton {
for (const notif of notificationsCopy) {
notif.notification.dismiss();
}
+ // Note: Expansion states will be cleaned up by onDropped as notifications are removed
}
function dismissNotification(wrapper) {
@@ -411,13 +403,57 @@ Singleton {
}
function dismissGroup(groupKey) {
- // Use array iteration to avoid spread operator issues
- for (let i = notifications.length - 1; i >= 0; i--) {
- const notif = notifications[i];
- if (getGroupKey(notif) === groupKey) {
+ console.log("Completely dismissing group:", groupKey);
+ const group = groupedNotifications.find(g => g.key === groupKey);
+ if (group) {
+ for (const notif of group.notifications) {
notif.notification.dismiss();
}
}
+ // Note: Expansion state will be cleaned up by onDropped when notifications are removed
+ }
+
+ function clearGroupExpansionState(groupKey) {
+ // Immediately remove expansion state for a specific group
+ let newExpandedGroups = {};
+ for (const key in expandedGroups) {
+ if (key !== groupKey && expandedGroups[key]) {
+ newExpandedGroups[key] = true;
+ }
+ }
+ expandedGroups = newExpandedGroups;
+
+ console.log("Cleared expansion state for group:", groupKey);
+ }
+
+ function cleanupExpansionStates() {
+ // Get all current group keys and message IDs
+ const currentGroupKeys = new Set(groupedNotifications.map(g => g.key));
+ const currentMessageIds = new Set();
+
+ for (const group of groupedNotifications) {
+ for (const notif of group.notifications) {
+ currentMessageIds.add(notif.notification.id);
+ }
+ }
+
+ // Clean up expanded groups that no longer exist
+ let newExpandedGroups = {};
+ for (const key in expandedGroups) {
+ if (currentGroupKeys.has(key) && expandedGroups[key]) {
+ newExpandedGroups[key] = true;
+ }
+ }
+ expandedGroups = newExpandedGroups;
+
+ // Clean up expanded messages that no longer exist
+ let newExpandedMessages = {};
+ for (const messageId in expandedMessages) {
+ if (currentMessageIds.has(messageId) && expandedMessages[messageId]) {
+ newExpandedMessages[messageId] = true;
+ }
+ }
+ expandedMessages = newExpandedMessages;
}
function toggleMessageExpansion(messageId) {
diff --git a/Tests/Notifications.md b/Tests/Notifications.md
deleted file mode 100644
index 3b5ed913..00000000
--- a/Tests/Notifications.md
+++ /dev/null
@@ -1,2557 +0,0 @@
-# Desktop Notifications API Documentation
-
-This document describes the Desktop Notifications API available in Quickshell QML for implementing a complete notification daemon that complies with the [Desktop Notifications Specification](https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html).
-
-## Import Statement
-
-```qml
-import Quickshell.Services.Notifications
-```
-
-## Prerequisites
-
-- D-Bus service must be available
-- Notifications feature must be enabled during build (`-DSERVICE_NOTIFICATIONS=ON`, default)
-- Your shell must register as a notification daemon to receive notifications
-
-## Core Concepts
-
-### Desktop Notifications Protocol
-The notifications service implements the complete `org.freedesktop.Notifications` D-Bus interface, allowing your shell to receive notifications from any application that follows the Desktop Notifications Specification. This includes web browsers, email clients, media players, system services, and more.
-
-### Capability-Based Architecture
-The notification server operates on an opt-in basis. Most capabilities are disabled by default and must be explicitly enabled based on what your notification UI can support. This ensures applications receive accurate information about what features are available.
-
-### Notification Lifecycle
-1. **Reception** - Applications send notifications via D-Bus
-2. **Tracking** - You must explicitly track notifications you want to keep
-3. **Display** - Show notification UI based on properties and capabilities
-4. **Interaction** - Handle user actions like clicking or dismissing
-5. **Closure** - Notifications are closed via expiration, dismissal, or application request
-
-## Main Components
-
-### 1. NotificationServer
-
-The main server that receives and manages notifications from external applications.
-
-```qml
-NotificationServer {
- id: notificationServer
-
- // Enable capabilities your UI supports
- actionsSupported: true
- imageSupported: true
- bodyMarkupSupported: true
-
- onNotification: function(notification) {
- // Must set tracked to true to keep the notification
- notification.tracked = true
-
- // Handle the notification in your UI
- showNotification(notification)
- }
-}
-```
-
-**Server Capabilities (Properties):**
-- `keepOnReload: bool` - Whether notifications persist across quickshell reloads (default: true)
-- `persistenceSupported: bool` - Whether server advertises persistence capability (default: false)
-- `bodySupported: bool` - Whether body text is supported (default: true)
-- `bodyMarkupSupported: bool` - Whether body markup is supported (default: false)
-- `bodyHyperlinksSupported: bool` - Whether body hyperlinks are supported (default: false)
-- `bodyImagesSupported: bool` - Whether body images are supported (default: false)
-- `actionsSupported: bool` - Whether notification actions are supported (default: false)
-- `actionIconsSupported: bool` - Whether action icons are supported (default: false)
-- `imageSupported: bool` - Whether notification images are supported (default: false)
-- `inlineReplySupported: bool` - Whether inline reply is supported (default: false)
-- `trackedNotifications: ObjectModel` - All currently tracked notifications
-- `extraHints: QVector` - Additional hints to expose to clients
-
-**Signals:**
-- `notification(Notification* notification)` - Emitted when a new notification is received
-
-**Example:**
-```qml
-NotificationServer {
- // Enable features your notification UI supports
- actionsSupported: true
- imageSupported: true
- bodyMarkupSupported: true
-
- onNotification: function(notification) {
- // Track the notification to prevent automatic cleanup
- notification.tracked = true
-
- // Connect to closure signal for cleanup
- notification.closed.connect(function(reason) {
- console.log("Notification closed:", NotificationCloseReason.toString(reason))
- })
-
- // Show notification popup
- showNotificationPopup(notification)
- }
-}
-```
-
-### 2. Notification
-
-Represents a single notification with all its properties and available actions.
-
-**Properties:**
-- `id: quint32` - Unique notification ID (read-only)
-- `tracked: bool` - Whether notification is tracked by the server
-- `lastGeneration: bool` - Whether notification was carried over from previous quickshell generation (read-only)
-- `expireTimeout: qreal` - Timeout in seconds for the notification
-- `appName: QString` - Name of the sending application
-- `appIcon: QString` - Application icon (fallback to desktop entry icon if not provided)
-- `summary: QString` - Main notification text (title)
-- `body: QString` - Detailed notification body
-- `urgency: NotificationUrgency.Enum` - Urgency level (Low, Normal, Critical)
-- `actions: QList` - Available actions
-- `hasActionIcons: bool` - Whether actions have icons
-- `resident: bool` - Whether notification persists after action invocation
-- `transient: bool` - Whether notification should skip persistence
-- `desktopEntry: QString` - Associated desktop entry name
-- `image: QString` - Associated image
-- `hints: QVariantMap` - All raw hints from the client
-- `hasInlineReply: bool` - Whether notification supports inline reply (read-only)
-- `inlineReplyPlaceholder: QString` - Placeholder text for inline reply input (read-only)
-
-**Methods:**
-- `expire()` - Close notification as expired
-- `dismiss()` - Close notification as dismissed by user
-- `sendInlineReply(QString replyText)` - Send an inline reply (only if hasInlineReply is true)
-
-**Signals:**
-- `closed(NotificationCloseReason.Enum reason)` - Emitted when notification is closed
-
-**Example:**
-```qml
-// In your notification UI component
-Rectangle {
- property Notification notification
-
- Column {
- Text {
- text: notification.appName
- font.bold: true
- }
-
- Text {
- text: notification.summary
- font.pixelSize: 16
- }
-
- Text {
- text: notification.body
- wrapMode: Text.WordWrap
- visible: notification.body.length > 0
- }
-
- // Show notification image if available
- Image {
- source: notification.image
- visible: notification.image.length > 0
- }
-
- // Show actions if available
- Row {
- Repeater {
- model: notification.actions
- delegate: Button {
- text: modelData.text
- onClicked: {
- modelData.invoke()
- }
- }
- }
- }
- }
-
- // Auto-expire after timeout
- Timer {
- running: notification.expireTimeout > 0
- interval: notification.expireTimeout * 1000
- onTriggered: notification.expire()
- }
-
- // Handle user dismissal
- MouseArea {
- anchors.fill: parent
- onClicked: notification.dismiss()
- }
-}
-```
-
-### 3. NotificationAction
-
-Represents an action that can be taken on a notification.
-
-**Properties:**
-- `identifier: QString` - Action identifier (icon name when hasActionIcons is true)
-- `text: QString` - Localized display text for the action
-
-**Methods:**
-- `invoke()` - Invoke the action (automatically dismisses non-resident notifications)
-
-**Example:**
-```qml
-// Action button in notification
-Button {
- property NotificationAction action
-
- text: action.text
-
- // Show icon if actions support icons
- icon.name: notificationServer.actionIconsSupported ? action.identifier : ""
-
- onClicked: {
- action.invoke()
- // Action automatically handles notification dismissal for non-resident notifications
- }
-}
-```
-
-## Enum Types
-
-### NotificationUrgency
-
-Urgency levels for notifications.
-
-**Values:**
-- `NotificationUrgency.Low` - Low priority (value: 0)
-- `NotificationUrgency.Normal` - Normal priority (value: 1)
-- `NotificationUrgency.Critical` - High priority (value: 2)
-
-**Methods:**
-- `NotificationUrgency.toString(urgency)` - Convert urgency to string
-
-### NotificationCloseReason
-
-Reasons why a notification was closed.
-
-**Values:**
-- `NotificationCloseReason.Expired` - Notification timed out (value: 1)
-- `NotificationCloseReason.Dismissed` - User explicitly dismissed (value: 2)
-- `NotificationCloseReason.CloseRequested` - Application requested closure (value: 3)
-
-**Methods:**
-- `NotificationCloseReason.toString(reason)` - Convert reason to string
-
-## Usage Examples
-
-### Basic Notification Daemon
-
-```qml
-import QtQuick
-import QtQuick.Controls
-import Quickshell.Services.Notifications
-
-ApplicationWindow {
- visible: true
-
- NotificationServer {
- id: notificationServer
-
- // Enable capabilities based on your UI
- actionsSupported: true
- imageSupported: true
- bodyMarkupSupported: false
-
- onNotification: function(notification) {
- // Track notification to prevent cleanup
- notification.tracked = true
-
- // Add to notification list
- notificationList.append(notification)
-
- // Show popup for urgent notifications
- if (notification.urgency === NotificationUrgency.Critical) {
- showUrgentPopup(notification)
- }
- }
- }
-
- ListView {
- id: notificationListView
- anchors.fill: parent
-
- model: notificationServer.trackedNotifications
-
- delegate: Rectangle {
- width: parent.width
- height: 100
- border.color: getUrgencyColor(modelData.urgency)
-
- function getUrgencyColor(urgency) {
- switch (urgency) {
- case NotificationUrgency.Low: return "gray"
- case NotificationUrgency.Normal: return "blue"
- case NotificationUrgency.Critical: return "red"
- default: return "black"
- }
- }
-
- Column {
- anchors.margins: 10
- anchors.fill: parent
-
- Text {
- text: modelData.appName
- font.bold: true
- }
-
- Text {
- text: modelData.summary
- font.pixelSize: 14
- }
-
- Text {
- text: modelData.body
- wrapMode: Text.WordWrap
- visible: modelData.body.length > 0
- }
-
- Row {
- spacing: 10
-
- Button {
- text: "Dismiss"
- onClicked: modelData.dismiss()
- }
-
- Repeater {
- model: modelData.actions
- delegate: Button {
- text: modelData.text
- onClicked: modelData.invoke()
- }
- }
- }
- }
- }
- }
-}
-```
-
-### Notification Popup with Auto-Dismiss
-
-```qml
-import QtQuick
-import QtQuick.Controls
-import Quickshell.Services.Notifications
-
-Popup {
- id: notificationPopup
-
- property Notification notification
-
- width: 300
- height: contentColumn.height + 20
-
- // Position in top-right corner
- x: parent.width - width - 20
- y: 20
-
- Column {
- id: contentColumn
- anchors.margins: 10
- anchors.left: parent.left
- anchors.right: parent.right
- spacing: 10
-
- Row {
- spacing: 10
-
- Image {
- width: 48
- height: 48
- source: notification.image || notification.appIcon
- fillMode: Image.PreserveAspectFit
- }
-
- Column {
- Text {
- text: notification.appName
- font.bold: true
- }
-
- Text {
- text: notification.summary
- font.pixelSize: 16
- }
- }
- }
-
- Text {
- text: notification.body
- wrapMode: Text.WordWrap
- visible: notification.body.length > 0
- width: parent.width
- }
-
- Row {
- spacing: 10
-
- Repeater {
- model: notification.actions
- delegate: Button {
- text: modelData.text
- onClicked: {
- modelData.invoke()
- notificationPopup.close()
- }
- }
- }
- }
- }
-
- // Auto-close timer
- Timer {
- running: notificationPopup.visible
- interval: notification.expireTimeout > 0 ? notification.expireTimeout * 1000 : 5000
- onTriggered: {
- notification.expire()
- notificationPopup.close()
- }
- }
-
- // Dismiss on click
- MouseArea {
- anchors.fill: parent
- onClicked: {
- notification.dismiss()
- notificationPopup.close()
- }
- }
-}
-```
-
-### Notification History Manager
-
-```qml
-import QtQuick
-import QtQuick.Controls
-import Quickshell.Services.Notifications
-
-QtObject {
- id: notificationHistory
-
- property var notifications: []
- property int maxNotifications: 100
-
- Component.onCompleted: {
- // Connect to notification server
- notificationServer.notification.connect(handleNotification)
- }
-
- function handleNotification(notification) {
- // Track notification
- notification.tracked = true
-
- // Add to history
- notifications.unshift(notification)
-
- // Limit history size
- if (notifications.length > maxNotifications) {
- notifications.pop()
- }
-
- // Connect to closure signal
- notification.closed.connect(function(reason) {
- console.log("Notification closed:",
- NotificationCloseReason.toString(reason))
- })
-
- // Show notification popup
- showNotificationPopup(notification)
- }
-
- function clearHistory() {
- notifications.forEach(function(notification) {
- if (notification.tracked) {
- notification.dismiss()
- }
- })
- notifications = []
- }
-
- function getNotificationsByApp(appName) {
- return notifications.filter(function(notification) {
- return notification.appName === appName
- })
- }
-
- function getUrgentNotifications() {
- return notifications.filter(function(notification) {
- return notification.urgency === NotificationUrgency.Critical
- })
- }
-}
-```
-
-### Android 16-Style Grouped Notifications with Inline Reply
-
-This example demonstrates how to implement modern Android 16-style notification grouping with expandable groups, inline reply support, and smart conversation handling.
-
-```qml
-import QtQuick
-import QtQuick.Controls
-import Quickshell.Services.Notifications
-
-ApplicationWindow {
- visible: true
- width: 420
- height: 700
-
- NotificationServer {
- id: notificationServer
-
- // Enable all modern capabilities for Android 16-style notifications
- actionsSupported: true
- imageSupported: true
- bodyMarkupSupported: true
- inlineReplySupported: true
- bodyHyperlinksSupported: true
-
- onNotification: function(notification) {
- notification.tracked = true
- notificationManager.addNotification(notification)
- }
- }
-
- QtObject {
- id: notificationManager
-
- property var groupedNotifications: ({})
- property var expandedGroups: ({})
-
- function addNotification(notification) {
- let groupKey = getGroupKey(notification)
-
- if (!groupedNotifications[groupKey]) {
- groupedNotifications[groupKey] = {
- key: groupKey,
- appName: notification.appName,
- notifications: [],
- latestNotification: null,
- count: 0,
- hasInlineReply: false,
- isConversation: isConversationApp(notification),
- isMedia: isMediaApp(notification)
- }
- }
-
- let group = groupedNotifications[groupKey]
- group.notifications.unshift(notification)
- group.latestNotification = notification
- group.count = group.notifications.length
-
- // Check if any notification in group supports inline reply
- if (notification.hasInlineReply) {
- group.hasInlineReply = true
- }
-
- // Auto-expand conversation groups with new messages
- if (group.isConversation && group.count > 1) {
- expandedGroups[groupKey] = true
- }
-
- // Limit notifications per group
- if (group.notifications.length > 20) {
- let oldNotification = group.notifications.pop()
- oldNotification.dismiss()
- }
-
- // Trigger UI update
- updateGroupModel()
- }
-
- function getGroupKey(notification) {
- let appName = notification.appName.toLowerCase()
-
- // For messaging apps, group by conversation/channel
- if (isConversationApp(notification)) {
- let summary = notification.summary.toLowerCase()
- // Discord channels: "#channel-name"
- if (summary.startsWith("#")) {
- return appName + ":" + summary
- }
- // Direct messages: group by sender name
- if (summary && !summary.includes("new message")) {
- return appName + ":" + summary
- }
- return appName + ":conversation"
- }
-
- // Media apps: group all together
- if (isMediaApp(notification)) {
- return appName + ":media"
- }
-
- // System notifications: group by type
- if (appName.includes("system") || appName.includes("update")) {
- return "system"
- }
-
- // Default: group by app
- return appName
- }
-
- function isConversationApp(notification) {
- let appName = notification.appName.toLowerCase()
- return appName.includes("discord") ||
- appName.includes("telegram") ||
- appName.includes("signal") ||
- appName.includes("whatsapp") ||
- appName.includes("slack") ||
- appName.includes("message")
- }
-
- function isMediaApp(notification) {
- let appName = notification.appName.toLowerCase()
- return appName.includes("spotify") ||
- appName.includes("music") ||
- appName.includes("player") ||
- appName.includes("vlc")
- }
-
- function toggleGroupExpansion(groupKey) {
- expandedGroups[groupKey] = !expandedGroups[groupKey]
- updateGroupModel()
- }
-
- function updateGroupModel() {
- let sortedGroups = Object.values(groupedNotifications)
- .sort((a, b) => b.latestNotification.timestamp - a.latestNotification.timestamp)
- notificationRepeater.model = sortedGroups
- }
-
- function dismissGroup(groupKey) {
- let group = groupedNotifications[groupKey]
- if (group) {
- group.notifications.forEach(notif => notif.dismiss())
- delete groupedNotifications[groupKey]
- delete expandedGroups[groupKey]
- updateGroupModel()
- }
- }
-
- function getGroupSummary(group) {
- if (group.count === 1) {
- return group.latestNotification.summary
- }
-
- if (group.isConversation) {
- return `${group.count} new messages`
- } else if (group.isMedia) {
- return "Now playing"
- } else {
- return `${group.count} notifications`
- }
- }
-
- function getGroupBody(group) {
- if (group.count === 1) {
- return group.latestNotification.body
- }
-
- // For conversations, show latest message preview
- if (group.isConversation) {
- return group.latestNotification.body || "Tap to view messages"
- }
-
- return `Latest: ${group.latestNotification.summary}`
- }
- }
-
- ScrollView {
- anchors.fill: parent
- anchors.margins: 8
-
- Column {
- width: parent.width - 16
- spacing: 8
-
- Repeater {
- id: notificationRepeater
-
- delegate: GroupedNotificationCard {
- width: parent.width
- group: modelData
- expanded: notificationManager.expandedGroups[modelData.key] || false
-
- onToggleExpansion: notificationManager.toggleGroupExpansion(group.key)
- onDismissGroup: notificationManager.dismissGroup(group.key)
- onReplyToLatest: function(replyText) {
- if (group.latestNotification.hasInlineReply) {
- group.latestNotification.sendInlineReply(replyText)
- }
- }
- }
- }
- }
- }
-}
-
-// Android 16-style grouped notification card component
-component GroupedNotificationCard: Rectangle {
- id: root
-
- property var group
- property bool expanded: false
-
- signal toggleExpansion()
- signal dismissGroup()
- signal replyToLatest(string replyText)
-
- height: expanded ? expandedContent.height + 32 : collapsedContent.height + 32
- radius: 16
- color: "#1a1a1a"
- border.color: group && group.latestNotification.urgency === NotificationUrgency.Critical ?
- "#ff4444" : "#333333"
- border.width: 1
-
- Behavior on height {
- NumberAnimation {
- duration: 200
- easing.type: Easing.OutCubic
- }
- }
-
- // Collapsed view - shows summary of the group
- Column {
- id: collapsedContent
- anchors.top: parent.top
- anchors.left: parent.left
- anchors.right: parent.right
- anchors.margins: 16
- spacing: 8
- visible: !expanded
-
- Row {
- width: parent.width
- spacing: 12
-
- // App icon or conversation avatar
- Rectangle {
- width: 48
- height: 48
- radius: group && group.isConversation ? 24 : 8
- color: "#333333"
-
- Image {
- anchors.fill: parent
- anchors.margins: group && group.isConversation ? 0 : 8
- source: group ? (group.latestNotification.image || group.latestNotification.appIcon) : ""
- fillMode: Image.PreserveAspectCrop
- radius: parent.radius
- }
- }
-
- Column {
- width: parent.width - 48 - 12 - 60
- spacing: 4
-
- Row {
- width: parent.width
-
- Text {
- text: group ? group.appName : ""
- color: "#888888"
- font.pixelSize: 12
- font.weight: Font.Medium
- }
-
- Item { width: 8; height: 1 }
-
- // Count badge for grouped notifications
- Rectangle {
- width: countText.width + 12
- height: 20
- radius: 10
- color: "#444444"
- visible: group && group.count > 1
-
- Text {
- id: countText
- anchors.centerIn: parent
- text: group ? group.count : "0"
- color: "#ffffff"
- font.pixelSize: 11
- font.weight: Font.Bold
- }
- }
- }
-
- Text {
- text: group ? notificationManager.getGroupSummary(group) : ""
- color: "#ffffff"
- font.pixelSize: 15
- font.weight: Font.Medium
- width: parent.width
- elide: Text.ElideRight
- }
-
- Text {
- text: group ? notificationManager.getGroupBody(group) : ""
- color: "#cccccc"
- font.pixelSize: 13
- width: parent.width
- elide: Text.ElideRight
- maximumLineCount: 1
- }
- }
-
- // Expand/dismiss controls
- Column {
- width: 60
- spacing: 4
-
- Button {
- width: 32
- height: 32
- text: expanded ? "↑" : "↓"
- visible: group && group.count > 1
- onClicked: toggleExpansion()
- }
-
- Button {
- width: 32
- height: 32
- text: "✕"
- onClicked: dismissGroup()
- }
- }
- }
-
- // Quick reply for conversations
- Row {
- width: parent.width
- spacing: 8
- visible: group && group.hasInlineReply && !expanded
-
- TextField {
- id: quickReplyField
- width: parent.width - 60
- height: 36
- placeholderText: "Reply..."
- background: Rectangle {
- color: "#2a2a2a"
- radius: 18
- border.color: parent.activeFocus ? "#4a9eff" : "#444444"
- }
- color: "#ffffff"
-
- onAccepted: {
- if (text.length > 0) {
- replyToLatest(text)
- text = ""
- }
- }
- }
-
- Button {
- width: 52
- height: 36
- text: "Send"
- enabled: quickReplyField.text.length > 0
- onClicked: {
- replyToLatest(quickReplyField.text)
- quickReplyField.text = ""
- }
- }
- }
- }
-
- // Expanded view - shows all notifications in group
- Column {
- id: expandedContent
- anchors.top: parent.top
- anchors.left: parent.left
- anchors.right: parent.right
- anchors.margins: 16
- spacing: 8
- visible: expanded
-
- // Group header
- Row {
- width: parent.width
- spacing: 12
-
- Rectangle {
- width: 32
- height: 32
- radius: group && group.isConversation ? 16 : 4
- color: "#333333"
-
- Image {
- anchors.fill: parent
- anchors.margins: group && group.isConversation ? 0 : 4
- source: group ? group.latestNotification.appIcon : ""
- fillMode: Image.PreserveAspectCrop
- radius: parent.radius
- }
- }
-
- Text {
- text: group ? `${group.appName} (${group.count})` : ""
- color: "#ffffff"
- font.pixelSize: 16
- font.weight: Font.Bold
- anchors.verticalCenter: parent.verticalCenter
- }
-
- Item { Layout.fillWidth: true }
-
- Button {
- text: "↑"
- width: 32
- height: 32
- onClicked: toggleExpansion()
- }
-
- Button {
- text: "✕"
- width: 32
- height: 32
- onClicked: dismissGroup()
- }
- }
-
- // Individual notifications
- Repeater {
- model: group ? group.notifications.slice(0, 10) : [] // Show max 10 expanded
-
- delegate: Rectangle {
- width: parent.width
- height: notifContent.height + 16
- radius: 8
- color: "#2a2a2a"
- border.color: modelData.urgency === NotificationUrgency.Critical ?
- "#ff4444" : "transparent"
-
- Column {
- id: notifContent
- anchors.top: parent.top
- anchors.left: parent.left
- anchors.right: parent.right
- anchors.margins: 12
- spacing: 6
-
- Row {
- width: parent.width
- spacing: 8
-
- Image {
- width: 24
- height: 24
- source: modelData.image || modelData.appIcon
- fillMode: Image.PreserveAspectCrop
- radius: group && group.isConversation ? 12 : 4
- }
-
- Column {
- width: parent.width - 32
- spacing: 2
-
- Text {
- text: modelData.summary
- color: "#ffffff"
- font.pixelSize: 14
- font.weight: Font.Medium
- width: parent.width
- elide: Text.ElideRight
- }
-
- Text {
- text: modelData.body
- color: "#cccccc"
- font.pixelSize: 13
- width: parent.width
- wrapMode: Text.WordWrap
- maximumLineCount: 3
- elide: Text.ElideRight
- }
- }
- }
-
- // Individual notification inline reply
- Row {
- width: parent.width
- spacing: 8
- visible: modelData.hasInlineReply
-
- TextField {
- id: replyField
- width: parent.width - 60
- height: 32
- placeholderText: modelData.inlineReplyPlaceholder || "Reply..."
- background: Rectangle {
- color: "#1a1a1a"
- radius: 16
- border.color: parent.activeFocus ? "#4a9eff" : "#444444"
- }
- color: "#ffffff"
- font.pixelSize: 12
-
- onAccepted: {
- if (text.length > 0) {
- modelData.sendInlineReply(text)
- text = ""
- }
- }
- }
-
- Button {
- width: 52
- height: 32
- text: "Send"
- enabled: replyField.text.length > 0
- onClicked: {
- modelData.sendInlineReply(replyField.text)
- replyField.text = ""
- }
- }
- }
-
- // Actions
- Row {
- spacing: 8
- visible: modelData.actions && modelData.actions.length > 0
-
- Repeater {
- model: modelData.actions
- delegate: Button {
- text: modelData.text
- height: 28
- onClicked: modelData.invoke()
- }
- }
- }
- }
- }
- }
-
- // "Show more" if there are many notifications
- Button {
- text: `Show ${group.count - 10} more notifications...`
- visible: group && group.count > 10
- onClicked: {
- // Implement pagination or full expansion
- }
- }
- }
-
- // Tap to expand (only for collapsed state)
- MouseArea {
- anchors.fill: parent
- visible: !expanded && group && group.count > 1
- onClicked: toggleExpansion()
- }
-}
-```
-
-### Media Notification Handler
-
-```qml
-import QtQuick
-import QtQuick.Controls
-import Quickshell.Services.Notifications
-
-QtObject {
- id: mediaNotificationHandler
-
- property var currentMediaNotification: null
-
- Component.onCompleted: {
- notificationServer.notification.connect(handleNotification)
- }
-
- function handleNotification(notification) {
- notification.tracked = true
-
- // Check if this is a media notification
- if (isMediaNotification(notification)) {
- // Replace current media notification
- if (currentMediaNotification) {
- currentMediaNotification.dismiss()
- }
-
- currentMediaNotification = notification
- showMediaControls(notification)
- } else {
- // Handle as regular notification
- showRegularNotification(notification)
- }
- }
-
- function isMediaNotification(notification) {
- // Check for media-related hints or app names
- return notification.appName.toLowerCase().includes("music") ||
- notification.appName.toLowerCase().includes("player") ||
- notification.hints.hasOwnProperty("x-kde-media-notification") ||
- notification.actions.some(function(action) {
- return action.identifier.includes("media-")
- })
- }
-
- function showMediaControls(notification) {
- // Create persistent media control UI
- mediaControlsPopup.notification = notification
- mediaControlsPopup.open()
- }
-
- function showRegularNotification(notification) {
- // Show regular notification popup
- regularNotificationPopup.notification = notification
- regularNotificationPopup.open()
- }
-}
-```
-
-### Inline Reply Support
-
-The notification system now supports inline replies, allowing users to quickly respond to messages directly from the notification without opening the source application.
-
-```qml
-import QtQuick
-import QtQuick.Controls
-import Quickshell.Services.Notifications
-
-Popup {
- id: replyableNotificationPopup
-
- property Notification notification
-
- width: 400
- height: contentColumn.height + 20
-
- Column {
- id: contentColumn
- anchors.margins: 10
- anchors.left: parent.left
- anchors.right: parent.right
- spacing: 10
-
- // Notification header
- Row {
- spacing: 10
-
- Image {
- width: 48
- height: 48
- source: notification.appIcon
- fillMode: Image.PreserveAspectFit
- }
-
- Column {
- Text {
- text: notification.appName
- font.bold: true
- }
-
- Text {
- text: notification.summary
- font.pixelSize: 16
- }
- }
- }
-
- // Notification body
- Text {
- text: notification.body
- wrapMode: Text.WordWrap
- width: parent.width
- visible: notification.body.length > 0
- }
-
- // Inline reply input (only shown if supported)
- Row {
- width: parent.width
- spacing: 10
- visible: notification.hasInlineReply
-
- TextField {
- id: replyField
- width: parent.width - sendButton.width - 10
- placeholderText: notification.inlineReplyPlaceholder || "Type a reply..."
-
- onAccepted: sendReply()
- }
-
- Button {
- id: sendButton
- text: "Send"
- enabled: replyField.text.length > 0
-
- onClicked: sendReply()
- }
- }
-
- // Regular actions
- Row {
- spacing: 10
- visible: notification.actions.length > 0 && !notification.hasInlineReply
-
- Repeater {
- model: notification.actions
- delegate: Button {
- text: modelData.text
- onClicked: {
- modelData.invoke()
- replyableNotificationPopup.close()
- }
- }
- }
- }
- }
-
- function sendReply() {
- if (replyField.text.length > 0) {
- notification.sendInlineReply(replyField.text)
- replyableNotificationPopup.close()
- }
- }
-}
-```
-
-### Advanced Inline Reply Implementation
-
-```qml
-import QtQuick
-import QtQuick.Controls
-import Quickshell.Services.Notifications
-
-ApplicationWindow {
- visible: true
-
- NotificationServer {
- id: notificationServer
-
- // Enable inline reply support
- inlineReplySupported: true
- actionsSupported: true
- imageSupported: true
-
- onNotification: function(notification) {
- notification.tracked = true
-
- // Create appropriate UI based on notification capabilities
- if (notification.hasInlineReply) {
- createReplyableNotification(notification)
- } else {
- createStandardNotification(notification)
- }
- }
- }
-
- Component {
- id: replyableNotificationComponent
-
- Rectangle {
- property Notification notification
-
- width: 350
- height: contentColumn.implicitHeight + 20
- radius: 10
- color: "#2a2a2a"
- border.color: notification.urgency === NotificationUrgency.Critical ?
- "#ff4444" : "#444444"
-
- Column {
- id: contentColumn
- anchors.margins: 15
- anchors.fill: parent
- spacing: 12
-
- // Header with app info
- Row {
- width: parent.width
- spacing: 10
-
- Image {
- width: 40
- height: 40
- source: notification.appIcon
- fillMode: Image.PreserveAspectFit
- }
-
- Column {
- width: parent.width - 50
-
- Text {
- text: notification.appName
- color: "#888888"
- font.pixelSize: 12
- }
-
- Text {
- text: notification.summary
- color: "#ffffff"
- font.pixelSize: 14
- font.bold: true
- wrapMode: Text.WordWrap
- width: parent.width
- }
- }
- }
-
- // Message body
- Text {
- text: notification.body
- color: "#cccccc"
- wrapMode: Text.WordWrap
- width: parent.width
- visible: notification.body.length > 0
- }
-
- // Inline reply section
- Rectangle {
- width: parent.width
- height: 40
- radius: 5
- color: "#1a1a1a"
- border.color: replyField.activeFocus ? "#4488ff" : "#333333"
-
- Row {
- anchors.fill: parent
- anchors.margins: 5
- spacing: 5
-
- TextField {
- id: replyField
- width: parent.width - 60
- height: parent.height
- placeholderText: notification.inlineReplyPlaceholder
- color: "#ffffff"
- background: Rectangle { color: "transparent" }
-
- onAccepted: {
- if (text.length > 0) {
- notification.sendInlineReply(text)
- notificationItem.destroy()
- }
- }
- }
-
- Button {
- width: 50
- height: parent.height
- text: "↵"
- enabled: replyField.text.length > 0
-
- onClicked: {
- notification.sendInlineReply(replyField.text)
- notificationItem.destroy()
- }
- }
- }
- }
-
- // Dismiss button
- Text {
- text: "✕"
- color: "#666666"
- font.pixelSize: 16
- anchors.right: parent.right
-
- MouseArea {
- anchors.fill: parent
- cursorShape: Qt.PointingHandCursor
- onClicked: {
- notification.dismiss()
- notificationItem.destroy()
- }
- }
- }
- }
-
- // Auto-dismiss timer
- Timer {
- running: notification.expireTimeout > 0 && !replyField.activeFocus
- interval: notification.expireTimeout * 1000
- onTriggered: {
- notification.expire()
- notificationItem.destroy()
- }
- }
- }
- }
-
- function createReplyableNotification(notification) {
- let notificationItem = replyableNotificationComponent.createObject(
- notificationContainer,
- { notification: notification }
- )
- }
-}
-```
-
-## Common Patterns
-
-### Android 16-Style Notification Grouping
-
-```qml
-// Smart grouping by conversation and app
-function getSmartGroupKey(notification) {
- const appName = notification.appName.toLowerCase()
-
- // Messaging apps: group by conversation/channel
- if (isMessagingApp(appName)) {
- const summary = notification.summary.toLowerCase()
-
- // Discord channels: "#general", "#announcements"
- if (summary.startsWith("#")) {
- return `${appName}:${summary}`
- }
-
- // Direct messages: group by sender name
- if (summary && !summary.includes("new message")) {
- return `${appName}:dm:${summary}`
- }
-
- // Fallback to app-level grouping
- return `${appName}:messages`
- }
-
- // Media: replace previous media notification
- if (isMediaApp(appName)) {
- return `${appName}:nowplaying`
- }
-
- // System notifications: group by category
- if (appName.includes("system")) {
- if (notification.summary.toLowerCase().includes("update")) {
- return "system:updates"
- }
- if (notification.summary.toLowerCase().includes("battery")) {
- return "system:battery"
- }
- return "system:general"
- }
-
- // Default: group by app
- return appName
-}
-
-function isMessagingApp(appName) {
- return ["discord", "telegram", "signal", "whatsapp", "slack", "vesktop"].some(
- app => appName.includes(app)
- )
-}
-
-function isMediaApp(appName) {
- return ["spotify", "vlc", "mpv", "music", "player"].some(
- app => appName.includes(app)
- )
-}
-```
-
-### Collapsible Notification Groups with Inline Reply
-
-```qml
-component AndroidStyleNotificationGroup: Rectangle {
- id: root
-
- property var notificationGroup
- property bool expanded: false
- property bool hasUnread: notificationGroup.notifications.some(n => !n.read)
-
- height: expanded ? expandedHeight : collapsedHeight
- radius: 16
- color: "#1e1e1e"
- border.color: hasUnread ? "#4a9eff" : "#333333"
- border.width: hasUnread ? 2 : 1
-
- readonly property int collapsedHeight: 80
- readonly property int expandedHeight: Math.min(400, 80 + (notificationGroup.notifications.length * 60))
-
- Behavior on height {
- NumberAnimation {
- duration: 250
- easing.type: Easing.OutCubic
- }
- }
-
- // Collapsed view - shows latest notification + count
- Item {
- anchors.fill: parent
- anchors.margins: 16
- visible: !expanded
-
- Row {
- anchors.fill: parent
- spacing: 12
-
- // Avatar/Icon
- Rectangle {
- width: 48
- height: 48
- radius: notificationGroup.isConversation ? 24 : 8
- color: "#333333"
-
- Image {
- anchors.fill: parent
- anchors.margins: notificationGroup.isConversation ? 0 : 8
- source: notificationGroup.latestNotification.image ||
- notificationGroup.latestNotification.appIcon
- fillMode: Image.PreserveAspectCrop
- radius: parent.radius
- }
-
- // Unread indicator
- Rectangle {
- width: 12
- height: 12
- radius: 6
- color: "#4a9eff"
- anchors.top: parent.top
- anchors.right: parent.right
- anchors.margins: -2
- visible: hasUnread
- }
- }
-
- // Content
- Column {
- width: parent.width - 48 - 12 - 80
- anchors.verticalCenter: parent.verticalCenter
- spacing: 4
-
- Row {
- width: parent.width
- spacing: 8
-
- Text {
- text: notificationGroup.appName
- color: "#888888"
- font.pixelSize: 12
- font.weight: Font.Medium
- }
-
- // Count badge
- Rectangle {
- width: Math.max(20, countText.width + 8)
- height: 16
- radius: 8
- color: "#555555"
- visible: notificationGroup.count > 1
-
- Text {
- id: countText
- anchors.centerIn: parent
- text: notificationGroup.count
- color: "#ffffff"
- font.pixelSize: 10
- font.weight: Font.Bold
- }
- }
- }
-
- Text {
- text: getGroupTitle(notificationGroup)
- color: "#ffffff"
- font.pixelSize: 15
- font.weight: Font.Medium
- width: parent.width
- elide: Text.ElideRight
- }
-
- Text {
- text: notificationGroup.latestNotification.body
- color: "#cccccc"
- font.pixelSize: 13
- width: parent.width
- elide: Text.ElideRight
- maximumLineCount: 1
- }
- }
-
- // Controls
- Column {
- width: 80
- anchors.verticalCenter: parent.verticalCenter
- spacing: 4
-
- Button {
- width: 36
- height: 36
- text: "↓"
- visible: notificationGroup.count > 1
- onClicked: expanded = true
- }
-
- Button {
- width: 36
- height: 36
- text: "✕"
- onClicked: dismissGroup()
- }
- }
- }
-
- // Quick reply for conversations
- Rectangle {
- anchors.bottom: parent.bottom
- anchors.left: parent.left
- anchors.right: parent.right
- anchors.margins: 8
- height: 40
- radius: 20
- color: "#2a2a2a"
- border.color: "#444444"
- visible: notificationGroup.hasInlineReply
-
- Row {
- anchors.fill: parent
- anchors.margins: 8
- spacing: 8
-
- TextField {
- id: quickReply
- width: parent.width - 50
- height: parent.height
- placeholderText: "Quick reply..."
- background: Item {}
- color: "#ffffff"
- font.pixelSize: 14
-
- onAccepted: sendQuickReply()
- }
-
- Button {
- width: 42
- height: parent.height
- text: "→"
- enabled: quickReply.text.length > 0
- onClicked: sendQuickReply()
- }
- }
- }
- }
-
- // Expanded view - shows all notifications
- ScrollView {
- anchors.fill: parent
- anchors.margins: 16
- visible: expanded
-
- Column {
- width: parent.width
- spacing: 8
-
- // Group header
- Row {
- width: parent.width
- spacing: 12
-
- Rectangle {
- width: 32
- height: 32
- radius: notificationGroup.isConversation ? 16 : 4
- color: "#333333"
-
- Image {
- anchors.fill: parent
- anchors.margins: notificationGroup.isConversation ? 0 : 4
- source: notificationGroup.latestNotification.appIcon
- fillMode: Image.PreserveAspectCrop
- radius: parent.radius
- }
- }
-
- Text {
- text: `${notificationGroup.appName} (${notificationGroup.count})`
- color: "#ffffff"
- font.pixelSize: 16
- font.weight: Font.Bold
- anchors.verticalCenter: parent.verticalCenter
- }
-
- Item { Layout.fillWidth: true }
-
- Button {
- text: "↑"
- width: 32
- height: 32
- onClicked: expanded = false
- }
-
- Button {
- text: "✕"
- width: 32
- height: 32
- onClicked: dismissGroup()
- }
- }
-
- // Individual notifications in conversation style
- Repeater {
- model: notificationGroup.notifications.slice(0, 15) // Show recent 15
-
- delegate: Rectangle {
- width: parent.width
- height: messageContent.height + 16
- radius: 8
- color: "#2a2a2a"
-
- Column {
- id: messageContent
- anchors.left: parent.left
- anchors.right: parent.right
- anchors.top: parent.top
- anchors.margins: 12
- spacing: 6
-
- Row {
- width: parent.width
- spacing: 8
-
- Rectangle {
- width: 24
- height: 24
- radius: notificationGroup.isConversation ? 12 : 4
- color: "#444444"
-
- Image {
- anchors.fill: parent
- source: modelData.image || modelData.appIcon
- fillMode: Image.PreserveAspectCrop
- radius: parent.radius
- }
- }
-
- Column {
- width: parent.width - 32
- spacing: 2
-
- Row {
- width: parent.width
-
- Text {
- text: modelData.summary
- color: "#ffffff"
- font.pixelSize: 14
- font.weight: Font.Medium
- elide: Text.ElideRight
- Layout.fillWidth: true
- }
-
- Text {
- text: formatTime(modelData.timestamp)
- color: "#888888"
- font.pixelSize: 11
- }
- }
-
- Text {
- text: modelData.body
- color: "#cccccc"
- font.pixelSize: 13
- width: parent.width
- wrapMode: Text.WordWrap
- maximumLineCount: 4
- elide: Text.ElideRight
- }
- }
- }
-
- // Individual inline reply
- Rectangle {
- width: parent.width
- height: 36
- radius: 18
- color: "#1a1a1a"
- border.color: "#444444"
- visible: modelData.hasInlineReply
-
- Row {
- anchors.fill: parent
- anchors.margins: 6
- spacing: 6
-
- TextField {
- id: replyField
- width: parent.width - 40
- height: parent.height
- placeholderText: modelData.inlineReplyPlaceholder || "Reply..."
- background: Item {}
- color: "#ffffff"
- font.pixelSize: 12
-
- onAccepted: {
- if (text.length > 0) {
- modelData.sendInlineReply(text)
- text = ""
- }
- }
- }
-
- Button {
- width: 34
- height: parent.height
- text: "→"
- enabled: replyField.text.length > 0
- onClicked: {
- modelData.sendInlineReply(replyField.text)
- replyField.text = ""
- }
- }
- }
- }
- }
- }
- }
- }
- }
-
- // Functions
- function getGroupTitle(group) {
- if (group.count === 1) {
- return group.latestNotification.summary
- }
-
- if (group.isConversation) {
- return `${group.count} new messages`
- }
-
- return `${group.count} notifications`
- }
-
- function sendQuickReply() {
- if (quickReply.text.length > 0 && notificationGroup.hasInlineReply) {
- notificationGroup.latestNotification.sendInlineReply(quickReply.text)
- quickReply.text = ""
- }
- }
-
- function dismissGroup() {
- notificationGroup.notifications.forEach(notification => {
- notification.dismiss()
- })
- }
-
- function formatTime(timestamp) {
- const now = new Date()
- const diff = now.getTime() - timestamp.getTime()
- const minutes = Math.floor(diff / 60000)
- const hours = Math.floor(minutes / 60)
-
- if (hours > 0) return `${hours}h`
- if (minutes > 0) return `${minutes}m`
- return "now"
- }
-
- // Tap to expand
- MouseArea {
- anchors.fill: parent
- visible: !expanded && notificationGroup.count > 1
- onClicked: expanded = true
- }
-}
-```
-
-### Filtering Notifications by Urgency
-
-```qml
-// High priority notifications only
-model: notificationServer.trackedNotifications.filter(function(notification) {
- return notification.urgency === NotificationUrgency.Critical
-})
-```
-
-### Auto-dismiss Timer
-
-```qml
-Timer {
- property Notification notification
-
- running: notification && notification.expireTimeout > 0
- interval: notification.expireTimeout * 1000
-
- onTriggered: {
- if (notification) {
- notification.expire()
- }
- }
-}
-```
-
-### Persistent Notification Storage
-
-```qml
-QtObject {
- property var persistentNotifications: []
-
- function addPersistentNotification(notification) {
- if (!notification.transient) {
- persistentNotifications.push({
- appName: notification.appName,
- summary: notification.summary,
- body: notification.body,
- timestamp: new Date()
- })
- }
- }
-}
-```
-
-## Best Practices
-
-### Capability Management
-- Only enable capabilities your UI can properly handle
-- Test with different notification sources to ensure compatibility
-- Consider performance implications of advanced features
-
-### Memory Management
-- Always set `tracked: true` for notifications you want to keep
-- Clean up notification references when no longer needed
-- Use object pools for frequent notification creation/destruction
-
-### User Experience for Android 16-Style Notifications
-- **Progressive Disclosure**: Show summary first, expand for details
-- **Smart Grouping**: Group conversations by channel/sender, media by app
-- **Quick Actions**: Provide inline reply for conversations, media controls for audio
-- **Visual Hierarchy**: Use conversation avatars vs app icons appropriately
-- **Count Badges**: Show notification count for groups clearly
-- **Auto-Expansion**: Expand conversation groups when new messages arrive
-- **Smooth Animations**: Use easing transitions for expand/collapse
-- **Contextual UI**: Adapt interface based on notification type (conversation, media, system)
-
-### Performance
-- Use efficient data structures for notification storage
-- Implement proper cleanup for dismissed notifications
-- Consider virtualization for large notification lists
-
-## Notes
-
-- **D-Bus Integration** - The service automatically handles D-Bus registration and interface implementation
-- **Hot Reloading** - Notifications can optionally persist across quickshell reloads
-- **Thread Safety** - All operations are thread-safe and properly synchronized
-- **Specification Compliance** - Fully implements the Desktop Notifications Specification
-- **Image Support** - Handles both file paths and embedded D-Bus image data
-- **Action Icons** - Supports action icons when `actionIconsSupported` is enabled
-- **Markup Support** - Can handle HTML-like markup in notification body when enabled
-- **Inline Reply** - Supports quick replies for messaging applications when enabled
-- You must explicitly track notifications by setting `tracked: true`
-- The server doesn't advertise capabilities by default - you must enable them
-- Actions automatically dismiss non-resident notifications when invoked
-- Notification IDs are unique within the current session
-- Image paths can be local files or embedded D-Bus image data
-
-## Migration Strategy
-
-### Overview
-This migration strategy helps you transition from other notification systems to Quickshell's native notification implementation, including support for the new inline reply feature.
-
-### Phase 1: Assessment
-1. **Inventory Current Features**
- - List all notification features your current setup uses
- - Document custom behaviors and UI elements
- - Note any application-specific handling
-
-2. **Capability Mapping**
- - Map your features to Quickshell capabilities:
- - Basic text → `bodySupported` (enabled by default)
- - HTML/Markup → `bodyMarkupSupported`
- - Clickable links → `bodyHyperlinksSupported`
- - Images → `imageSupported`
- - Action buttons → `actionsSupported`
- - Icon buttons → `actionIconsSupported`
- - **Quick replies → `inlineReplySupported`** (NEW)
- - Persistence → `persistenceSupported`
-
-### Phase 2: Basic Implementation
-1. **Create Notification Server**
- ```qml
- NotificationServer {
- id: notificationServer
-
- // Start with minimal capabilities
- actionsSupported: false
- imageSupported: false
- inlineReplySupported: false
-
- onNotification: function(notification) {
- notification.tracked = true
- // Basic notification display
- }
- }
- ```
-
-2. **Test Core Functionality**
- - Send test notifications: `notify-send "Test" "Basic notification"`
- - Verify reception and display
- - Check notification lifecycle
-
-### Phase 3: Progressive Enhancement
-1. **Enable Features Incrementally**
- ```qml
- NotificationServer {
- // Phase 3.1: Add images
- imageSupported: true
-
- // Phase 3.2: Add actions
- actionsSupported: true
-
- // Phase 3.3: Add inline replies
- inlineReplySupported: true
-
- // Phase 3.4: Add markup
- bodyMarkupSupported: true
- }
- ```
-
-2. **Implement UI for Each Feature**
- - Images: Add Image component with fallback
- - Actions: Create button row with action handling
- - **Inline Reply: Add TextField with send button** (NEW)
- - Markup: Use Text component with textFormat
-
-### Phase 4: Inline Reply Implementation (NEW)
-
-1. **Detection and UI Creation**
- ```qml
- onNotification: function(notification) {
- notification.tracked = true
-
- if (notification.hasInlineReply) {
- // Create UI with reply field
- createReplyableNotification(notification)
- } else {
- // Standard notification UI
- createStandardNotification(notification)
- }
- }
- ```
-
-2. **Reply UI Component**
- ```qml
- // Minimal inline reply UI
- Row {
- visible: notification.hasInlineReply
-
- TextField {
- id: replyInput
- placeholderText: notification.inlineReplyPlaceholder
- onAccepted: {
- if (text) notification.sendInlineReply(text)
- }
- }
-
- Button {
- text: "Send"
- enabled: replyInput.text.length > 0
- onClicked: {
- notification.sendInlineReply(replyInput.text)
- }
- }
- }
- ```
-
-3. **Testing Inline Reply**
- - Test with messaging apps (Telegram, Discord, etc.)
- - Verify reply delivery
- - Check notification dismissal behavior
-
-### Phase 5: Advanced Android 16-Style Features
-
-1. **Smart Notification Grouping**
- - Group by application and conversation
- - Implement automatic conversation detection
- - Handle channel-based grouping (Discord, Slack)
- - Smart media notification replacement
-
-2. **Interactive Inline Reply**
- - Implement conversation threading for inline replies
- - Auto-expand conversation groups with new messages
- - Quick reply from collapsed notifications
- - Reply persistence and history
-
-3. **Android 16-Style UI Elements**
- - Collapsible notification cards with smooth animations
- - Count badges for grouped notifications
- - Conversation avatars vs app icons
- - Progressive disclosure (show latest, expand for more)
-
-4. **Advanced Behaviors**
- - Auto-expand conversations with new messages
- - Smart notification replacement for media
- - Context-aware grouping algorithms
- - Adaptive UI based on notification type
-
-### Phase 6: Migration Completion
-
-1. **Feature Parity Checklist**
- - [ ] All notifications display correctly
- - [ ] Actions work as expected
- - [ ] Images render properly
- - [ ] **Inline replies function correctly** (NEW)
- - [ ] Performance is acceptable
- - [ ] No missing notifications
-
-2. **Cleanup**
- - Remove old notification daemon
- - Update system configuration
- - Document any custom behaviors
-
-### Common Migration Issues
-
-1. **Missing Notifications**
- - Ensure D-Bus service is registered
- - Check that old daemon is stopped
- - Verify no other notification handlers
-
-2. **Inline Reply Not Working**
- - Confirm `inlineReplySupported: true`
- - Check application supports inline reply
- - Verify D-Bus communication
-
-3. **Performance Issues**
- - Limit tracked notifications
- - Implement notification cleanup
- - Use efficient data structures
-
-### Testing Applications
-
-Test with various applications to ensure compatibility:
-- **Basic**: `notify-send`, system notifications
-- **Media**: Spotify, VLC, music players
-- **Messaging**: Telegram, Discord, Signal (inline reply)
-- **Email**: Thunderbird, Evolution
-- **Development**: IDE notifications, build status
-
-### Rollback Plan
-
-Keep your old configuration available:
-1. Document old notification daemon setup
-2. Keep configuration files backed up
-3. Test rollback procedure
-4. Have quick switch mechanism ready
-
-## Android 16-Style Implementation Demos
-
-### Demo 1: Basic Grouped Popup Notifications
-
-```qml
-// Replace your existing NotificationInit.qml content
-import QtQuick
-import QtQuick.Controls
-import Quickshell
-import Quickshell.Services.Notifications
-
-PanelWindow {
- id: notificationPopup
-
- visible: NotificationService.groupedPopups.length > 0
-
- WlrLayershell.layer: WlrLayershell.Overlay
- WlrLayershell.exclusiveZone: -1
- WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
-
- color: "transparent"
-
- anchors {
- top: true
- right: true
- }
-
- margins {
- top: Theme.barHeight
- right: 16
- }
-
- implicitWidth: 420
- implicitHeight: groupedNotificationsList.height + 32
-
- Column {
- id: groupedNotificationsList
- anchors.top: parent.top
- anchors.right: parent.right
- anchors.topMargin: 16
- anchors.rightMargin: 16
- spacing: 12
- width: 400
-
- Repeater {
- model: NotificationService.groupedPopups
-
- delegate: AndroidStyleGroupedNotificationCard {
- required property var modelData
- group: modelData
- width: parent.width
-
- // Auto-dismiss single notifications
- Timer {
- running: group.count === 1 && group.latestNotification.popup
- interval: group.latestNotification.notification.expireTimeout > 0 ?
- group.latestNotification.notification.expireTimeout : 5000
- onTriggered: {
- group.latestNotification.popup = false
- }
- }
-
- // Don't auto-dismiss conversation groups - let user interact
- property bool isConversationGroup: group.isConversation && group.count > 1
- }
- }
- }
-}
-
-component AndroidStyleGroupedNotificationCard: Rectangle {
- id: root
-
- property var group
- property bool autoExpanded: group.isConversation && group.count > 1
-
- height: contentColumn.height + 24
- radius: 16
- color: "#1a1a1a"
- border.color: group.latestNotification.urgency === 2 ? "#ff4444" : "#333333"
- border.width: 1
-
- Column {
- id: contentColumn
- anchors.top: parent.top
- anchors.left: parent.left
- anchors.right: parent.right
- anchors.margins: 16
- spacing: 12
-
- // Header row
- Row {
- width: parent.width
- spacing: 12
-
- Rectangle {
- width: 48
- height: 48
- radius: group.isConversation ? 24 : 8
- color: "#333333"
-
- Image {
- anchors.fill: parent
- anchors.margins: group.isConversation ? 0 : 8
- source: group.latestNotification.image || group.latestNotification.appIcon
- fillMode: Image.PreserveAspectCrop
- radius: parent.radius
- }
- }
-
- Column {
- width: parent.width - 60 - 60
- spacing: 4
-
- Row {
- width: parent.width
- spacing: 8
-
- Text {
- text: group.appName
- color: "#888888"
- font.pixelSize: 12
- font.weight: Font.Medium
- }
-
- Rectangle {
- width: Math.max(20, countText.width + 8)
- height: 16
- radius: 8
- color: "#4a9eff"
- visible: group.count > 1
-
- Text {
- id: countText
- anchors.centerIn: parent
- text: group.count
- color: "#ffffff"
- font.pixelSize: 10
- font.weight: Font.Bold
- }
- }
- }
-
- Text {
- text: getGroupTitle()
- color: "#ffffff"
- font.pixelSize: 15
- font.weight: Font.Medium
- width: parent.width
- elide: Text.ElideRight
- }
-
- Text {
- text: group.latestNotification.body
- color: "#cccccc"
- font.pixelSize: 13
- width: parent.width
- wrapMode: Text.WordWrap
- maximumLineCount: autoExpanded ? -1 : 2
- elide: Text.ElideRight
- }
- }
-
- Button {
- width: 32
- height: 32
- text: "✕"
- onClicked: NotificationService.dismissGroup(group.key)
- }
- }
-
- // Inline reply for conversations
- Row {
- width: parent.width
- spacing: 8
- visible: group.hasInlineReply
-
- TextField {
- id: replyField
- width: parent.width - 60
- height: 36
- placeholderText: "Reply..."
- background: Rectangle {
- color: "#2a2a2a"
- radius: 18
- border.color: parent.activeFocus ? "#4a9eff" : "#444444"
- }
- color: "#ffffff"
-
- onAccepted: {
- if (text.length > 0) {
- group.latestNotification.notification.sendInlineReply(text)
- text = ""
- }
- }
- }
-
- Button {
- width: 52
- height: 36
- text: "Send"
- enabled: replyField.text.length > 0
- onClicked: {
- group.latestNotification.notification.sendInlineReply(replyField.text)
- replyField.text = ""
- }
- }
- }
-
- // Actions row
- Row {
- spacing: 8
- visible: group.latestNotification.actions && group.latestNotification.actions.length > 0
-
- Repeater {
- model: group.latestNotification.actions || []
- delegate: Button {
- text: modelData.text
- height: 32
- onClicked: modelData.invoke()
- }
- }
- }
- }
-
- function getGroupTitle() {
- if (group.count === 1) {
- return group.latestNotification.summary
- }
-
- if (group.isConversation) {
- return `${group.count} new messages`
- }
-
- if (group.isMedia) {
- return "Now playing"
- }
-
- return `${group.count} notifications`
- }
-}
-```
-
-### Demo 2: Notification History with Grouping
-
-```qml
-// Update your NotificationCenter.qml to use grouped notifications
-ListView {
- model: NotificationService.groupedNotifications
- spacing: 12
-
- delegate: AndroidStyleGroupedNotificationCard {
- width: ListView.view.width
- group: modelData
-
- // History mode - always show expanded view for better browsing
- autoExpanded: true
- showAllNotifications: true
-
- property bool showAllNotifications: false
-
- // Override content to show more notifications
- // ... (extend the component to show paginated history)
- }
-}
-```
-
-### Demo 3: Service Integration
-
-```qml
-// Update your NotificationService.qml to add grouping capabilities
-pragma Singleton
-import QtQuick
-import Quickshell.Services.Notifications
-
-Singleton {
- id: root
-
- readonly property list notifications: []
- readonly property list popups: notifications.filter(n => n.popup)
-
- // New grouped properties
- readonly property var groupedNotifications: getGroupedNotifications()
- readonly property var groupedPopups: getGroupedPopups()
-
- NotificationServer {
- id: server
-
- keepOnReload: false
- actionsSupported: true
- bodyHyperlinksSupported: true
- bodyImagesSupported: true
- bodyMarkupSupported: true
- imageSupported: true
- inlineReplySupported: true // Enable inline reply
-
- onNotification: notif => {
- notif.tracked = true;
- const wrapper = notifComponent.createObject(root, {
- popup: true,
- notification: notif
- });
- root.notifications.push(wrapper);
- }
- }
-
- // ... (rest of your existing NotifWrapper and helper functions)
-
- // New grouping functions
- function getGroupKey(wrapper) {
- const appName = wrapper.appName || "Unknown";
-
- if (wrapper.isConversation) {
- const summary = wrapper.summary.toLowerCase();
- if (summary.match(/^[#@]?[\w\s]+$/)) {
- return appName + ":" + wrapper.summary;
- }
- return appName + ":conversation";
- }
-
- if (wrapper.isMedia) {
- return appName + ":media";
- }
-
- if (wrapper.isSystem) {
- return appName + ":system";
- }
-
- return appName;
- }
-
- function getGroupedNotifications() {
- const groups = {};
-
- for (const notif of notifications) {
- const groupKey = getGroupKey(notif);
- if (!groups[groupKey]) {
- groups[groupKey] = {
- key: groupKey,
- appName: notif.appName,
- notifications: [],
- latestNotification: null,
- count: 0,
- hasInlineReply: false,
- isConversation: notif.isConversation,
- isMedia: notif.isMedia,
- isSystem: notif.isSystem
- };
- }
-
- groups[groupKey].notifications.unshift(notif);
- groups[groupKey].latestNotification = groups[groupKey].notifications[0];
- groups[groupKey].count = groups[groupKey].notifications.length;
-
- if (notif.notification.hasInlineReply) {
- groups[groupKey].hasInlineReply = true;
- }
- }
-
- return Object.values(groups).sort((a, b) => {
- return b.latestNotification.time.getTime() - a.latestNotification.time.getTime();
- });
- }
-
- function getGroupedPopups() {
- const groups = {};
-
- for (const notif of popups) {
- const groupKey = getGroupKey(notif);
- if (!groups[groupKey]) {
- groups[groupKey] = {
- key: groupKey,
- appName: notif.appName,
- notifications: [],
- latestNotification: null,
- count: 0,
- hasInlineReply: false,
- isConversation: notif.isConversation,
- isMedia: notif.isMedia,
- isSystem: notif.isSystem
- };
- }
-
- groups[groupKey].notifications.unshift(notif);
- groups[groupKey].latestNotification = groups[groupKey].notifications[0];
- groups[groupKey].count = groups[groupKey].notifications.length;
-
- if (notif.notification.hasInlineReply) {
- groups[groupKey].hasInlineReply = true;
- }
- }
-
- return Object.values(groups).sort((a, b) => {
- return b.latestNotification.time.getTime() - a.latestNotification.time.getTime();
- });
- }
-
- function dismissGroup(groupKey) {
- const notificationsCopy = [...notifications];
- for (const notif of notificationsCopy) {
- if (getGroupKey(notif) === groupKey) {
- notif.notification.dismiss();
- }
- }
- }
-}
-```
-
-### Demo 4: Testing Your Implementation
-
-```bash
-# Test basic notifications
-notify-send "Test App" "Single notification"
-
-# Test conversation grouping (Discord simulation)
-notify-send "Discord" "#general" -i discord
-notify-send "Discord" "#general" -i discord
-notify-send "Discord" "john_doe" -i discord
-
-# Test media notifications
-notify-send "Spotify" "Now Playing" "Song Title - Artist" -i spotify
-
-# Test inline reply (requires supporting app)
-# This would come from messaging apps that support inline reply
-```
\ No newline at end of file