From 8a57c6a6b1079e53576d4117518e12d01e04e22c Mon Sep 17 00:00:00 2001 From: purian23 Date: Thu, 17 Jul 2025 14:51:54 -0400 Subject: [PATCH] Notifications updates --- Services/NotificationService.qml | 124 +- Tests/NotificationNativeDemo.qml | 97 - Tests/Notifications.md | 2557 +++++++++++++++++++++++++++ Widgets/GroupedNotificationCard.qml | 796 --------- Widgets/NotificationCenter.qml | 640 ++++++- Widgets/NotificationInit.qml | 172 -- Widgets/NotificationItem.qml | 350 ---- Widgets/NotificationPopup.qml | 805 +++++++++ shell.qml | 2 +- 9 files changed, 4114 insertions(+), 1429 deletions(-) delete mode 100644 Tests/NotificationNativeDemo.qml create mode 100644 Tests/Notifications.md delete mode 100644 Widgets/GroupedNotificationCard.qml delete mode 100644 Widgets/NotificationInit.qml delete mode 100644 Widgets/NotificationItem.qml create mode 100644 Widgets/NotificationPopup.qml diff --git a/Services/NotificationService.qml b/Services/NotificationService.qml index 85a25e0d..7e35be4d 100644 --- a/Services/NotificationService.qml +++ b/Services/NotificationService.qml @@ -200,7 +200,91 @@ Singleton { function getGroupKey(wrapper) { const appName = wrapper.appName.toLowerCase(); - // Group by app only - one group per unique application + // Enhanced grouping for conversation apps + if (wrapper.isConversation) { + const summary = wrapper.summary.toLowerCase(); + const body = wrapper.body.toLowerCase(); + + // Discord: Group by channel or conversation + if (appName.includes("discord") || appName.includes("vesktop")) { + // Channel notifications: "#general", "#announcements" + if (summary.includes("#")) { + const channelMatch = summary.match(/#[\w-]+/); + if (channelMatch) { + return `${appName}:${channelMatch[0]}`; + } + } + // Direct messages: group by sender + if (summary && !summary.includes("new message") && !summary.includes("notification")) { + return `${appName}:dm:${summary}`; + } + // Server messages or general + return `${appName}:messages`; + } + + // Telegram: Group by chat/channel + if (appName.includes("telegram")) { + if (summary && !summary.includes("new message")) { + return `${appName}:${summary}`; + } + return `${appName}:messages`; + } + + // Signal: Group by conversation + if (appName.includes("signal")) { + if (summary && !summary.includes("new message")) { + return `${appName}:${summary}`; + } + return `${appName}:messages`; + } + + // WhatsApp: Group by contact/group + if (appName.includes("whatsapp")) { + if (summary && !summary.includes("new message")) { + return `${appName}:${summary}`; + } + return `${appName}:messages`; + } + + // Slack: Group by channel/DM + if (appName.includes("slack")) { + if (summary.includes("#")) { + const channelMatch = summary.match(/#[\w-]+/); + if (channelMatch) { + return `${appName}:${channelMatch[0]}`; + } + } + if (summary && !summary.includes("new message")) { + return `${appName}:dm:${summary}`; + } + return `${appName}:messages`; + } + + // Default conversation grouping + return `${appName}:conversation`; + } + + // Media: Replace previous media notification from same app + if (wrapper.isMedia) { + return `${appName}:media`; + } + + // System: Group by type + if (wrapper.isSystem) { + const summary = wrapper.summary.toLowerCase(); + if (summary.includes("update")) { + return "system:updates"; + } + if (summary.includes("battery")) { + return "system:battery"; + } + if (summary.includes("network")) { + return "system:network"; + } + return "system:general"; + } + + // Default: Group by app return appName; } @@ -295,6 +379,20 @@ Singleton { } if (group.isConversation) { + // Extract conversation/channel name from group key + const keyParts = group.key.split(":"); + if (keyParts.length > 1) { + const conversationKey = keyParts[keyParts.length - 1]; + if (conversationKey.startsWith("#")) { + return `${conversationKey}: ${group.count} messages`; + } + if (keyParts.includes("dm")) { + return `${conversationKey}: ${group.count} messages`; + } + if (conversationKey !== "messages" && conversationKey !== "conversation") { + return `${conversationKey}: ${group.count} messages`; + } + } return `${group.count} new messages`; } @@ -302,6 +400,20 @@ Singleton { return "Now playing"; } + if (group.isSystem) { + const keyParts = group.key.split(":"); + if (keyParts.length > 1) { + const systemType = keyParts[1]; + switch (systemType) { + case "updates": return `${group.count} system updates`; + case "battery": return `${group.count} battery notifications`; + case "network": return `${group.count} network notifications`; + default: return `${group.count} system notifications`; + } + } + return `${group.count} system notifications`; + } + return `${group.count} notifications`; } @@ -311,7 +423,15 @@ Singleton { } if (group.isConversation) { - return group.latestNotification.body || "Tap to view messages"; + const latest = group.latestNotification; + if (latest.body && latest.body.length > 0) { + return latest.body; + } + return "Tap to view conversation"; + } + + if (group.isMedia) { + return group.latestNotification.body || "Media playback"; } return `Latest: ${group.latestNotification.summary}`; diff --git a/Tests/NotificationNativeDemo.qml b/Tests/NotificationNativeDemo.qml deleted file mode 100644 index d6672124..00000000 --- a/Tests/NotificationNativeDemo.qml +++ /dev/null @@ -1,97 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import qs.Common -import qs.Services -import qs.Widgets - -ApplicationWindow { - id: demoWindow - width: 800 - height: 600 - visible: true - title: "Native Notification System Demo" - - color: Theme.background - - Column { - anchors.centerIn: parent - spacing: Theme.spacingL - - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: "Native Notification System Demo" - font.pixelSize: Theme.fontSizeXLarge - color: Theme.surfaceText - font.weight: Font.Bold - } - - Text { - anchors.horizontalCenter: parent.horizontalCenter - text: "This demo uses Quickshell's native NotificationServer" - font.pixelSize: Theme.fontSizeMedium - color: Theme.onSurfaceVariant - } - - Row { - anchors.horizontalCenter: parent.horizontalCenter - spacing: Theme.spacingL - - Button { - text: "Show Popups" - onClicked: notificationPopup.visible = true - } - - Button { - text: "Show History" - onClicked: notificationHistory.notificationHistoryVisible = true - } - - Button { - text: "Clear All" - onClicked: NotificationService.clearAllNotifications() - } - } - - Column { - anchors.horizontalCenter: parent.horizontalCenter - spacing: Theme.spacingM - - Text { - text: `Total Notifications: ${NotificationService.notifications.length}` - font.pixelSize: Theme.fontSizeMedium - color: Theme.surfaceText - } - - Text { - text: `Active Popups: ${NotificationService.popups.length}` - font.pixelSize: Theme.fontSizeMedium - color: Theme.surfaceText - } - } - - Text { - width: 600 - anchors.horizontalCenter: parent.horizontalCenter - text: "Instructions:\n" + - "• Send notifications from other applications (Discord, etc.)\n" + - "• Use 'notify-send' command to test\n" + - "• Notifications will appear automatically in the popup\n" + - "• Images from Discord/Vesktop will show as avatars\n" + - "• App icons are automatically detected" - font.pixelSize: Theme.fontSizeSmall - color: Theme.onSurfaceVariant - wrapMode: Text.WordWrap - } - } - - // Native notification popup - NotificationInit { - id: notificationPopup - } - - // Native notification history - NotificationCenter { - id: notificationHistory - } -} \ No newline at end of file diff --git a/Tests/Notifications.md b/Tests/Notifications.md new file mode 100644 index 00000000..3b5ed913 --- /dev/null +++ b/Tests/Notifications.md @@ -0,0 +1,2557 @@ +# 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 diff --git a/Widgets/GroupedNotificationCard.qml b/Widgets/GroupedNotificationCard.qml deleted file mode 100644 index eea872b4..00000000 --- a/Widgets/GroupedNotificationCard.qml +++ /dev/null @@ -1,796 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Effects -import Quickshell -import Quickshell.Widgets -import Quickshell.Services.Notifications -import qs.Common -import qs.Services - -Rectangle { - id: root - - required property var group - - // Context detection - set by parent if in popup - property bool isPopupContext: false - - // Bind directly to the service property for automatic updates - readonly property bool expanded: NotificationService.expandedGroups[group.key] || false - - // Height calculation with popup context adjustment - height: { - let baseHeight = expanded ? expandedContent.height + Theme.spacingL * 2 : collapsedContent.height + Theme.spacingL * 2; - // Add extra height for single notifications in popup context - if (isPopupContext && group.count === 1) { - return baseHeight + 12; - } - return baseHeight; - } - radius: Theme.cornerRadiusLarge - color: Theme.popupBackground() - border.color: group.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: group.latestNotification.urgency === 2 ? 2 : 1 - - // Stabilize layout during content changes - clip: true - - // Priority indicator for urgent notifications - Rectangle { - width: 4 - height: parent.height - 16 - anchors.left: parent.left - anchors.leftMargin: 2 - anchors.verticalCenter: parent.verticalCenter - radius: 2 - color: Theme.primary - visible: group.latestNotification.urgency === 2 - } - - Behavior on height { - enabled: !isPopupContext // Disable automatic height animation in popup to prevent glitches - SequentialAnimation { - // Small pause to let content settle - PauseAnimation { - duration: 25 - } - NumberAnimation { - duration: Theme.mediumDuration - easing.type: Theme.emphasizedEasing - } - } - } - - // Collapsed view - shows app header and latest notification - Column { - id: collapsedContent - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.margins: Theme.spacingL - spacing: Theme.spacingS - visible: !expanded - - // App header with group info - Item { - width: parent.width - height: 48 // Fixed height to prevent layout shifts - - // Round app icon with proper API usage - Item { - id: iconContainer - width: 48 - height: 48 - 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: 6 - source: { - console.log("Icon source for", group.appName, ":", group.latestNotification.appIcon) - if (group.latestNotification.appIcon && group.latestNotification.appIcon !== "") { - return Quickshell.iconPath(group.latestNotification.appIcon, "") - } - return "" - } - visible: status === Image.Ready - - onStatusChanged: { - console.log("Icon status changed for", group.appName, ":", status) - if (status === Image.Error || status === Image.Null || source === "") { - fallbackIcon.visible = true - } else if (status === Image.Ready) { - fallbackIcon.visible = false - } - } - } - - // Fallback icon - show by default, hide when real icon loads - Text { - id: fallbackIcon - anchors.centerIn: parent - visible: true // Start visible, hide when real icon loads - text: { - // Use first letter of app name as fallback - const appName = group.appName || "?" - return appName.charAt(0).toUpperCase() - } - font.pixelSize: 20 - font.weight: Font.Bold - color: Theme.primaryText - } - } - - // Count badge for multiple notifications - small circle - Rectangle { - width: 20 - height: 20 - radius: 10 - color: Theme.primary - anchors.top: parent.top - anchors.right: parent.right - anchors.topMargin: -2 - anchors.rightMargin: -2 - visible: group.count > 1 - - Text { - id: countText - anchors.centerIn: parent - text: group.count > 99 ? "99+" : group.count.toString() - color: Theme.primaryText - font.pixelSize: 10 - font.weight: Font.Bold - } - } - } - - // App info and latest notification content - Column { - id: contentColumn - anchors.left: iconContainer.right - anchors.leftMargin: Theme.spacingM - anchors.right: controlsContainer.left - anchors.rightMargin: Theme.spacingM - anchors.verticalCenter: parent.verticalCenter - spacing: Theme.spacingXS - - // App name and timestamp on same line - Text { - width: parent.width - text: { - if (group.latestNotification.timeStr.length > 0) { - return group.appName + " • " + group.latestNotification.timeStr - } else { - return group.appName - } - } - color: Theme.surfaceVariantText - font.pixelSize: Theme.fontSizeSmall - font.weight: Font.Medium - elide: Text.ElideRight - maximumLineCount: 1 - } - - // Latest notification title (emphasized) - Text { - text: group.latestNotification.summary - color: Theme.surfaceText - font.pixelSize: Theme.fontSizeMedium + 1 // Slightly larger for emphasis - font.weight: Font.Medium - width: parent.width - elide: Text.ElideRight - maximumLineCount: 1 - visible: text.length > 0 - } - - // Latest notification body (smaller, secondary) - Text { - text: group.latestNotification.body - color: Theme.surfaceVariantText - font.pixelSize: Theme.fontSizeSmall - width: parent.width - elide: Text.ElideRight - maximumLineCount: group.count > 1 ? 1 : 2 // More space for single notifications - wrapMode: Text.WordWrap - visible: text.length > 0 - } - } - - // Expand/dismiss controls - use anchored layout for stability - Item { - id: controlsContainer - width: 72 - height: 32 - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - - Rectangle { - id: expandButton - 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: group.count > 1 - - Text { - anchors.centerIn: parent - text: "expand_more" - font.family: Theme.iconFont - font.pixelSize: 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: { - console.log("Expand clicked for group:", group.key, "current state:", expanded) - NotificationService.toggleGroupExpansion(group.key) - } - } - } - - Rectangle { - id: dismissButton - 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" - - Text { - anchors.centerIn: parent - text: "close" - font.family: Theme.iconFont - font.pixelSize: 16 - color: Theme.surfaceText - } - - MouseArea { - id: dismissArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: NotificationService.dismissGroup(group.key) - } - } - } - } - - // Quick reply for conversations (only if latest notification supports it) - Row { - width: parent.width - spacing: Theme.spacingS - visible: group.latestNotification.notification.hasInlineReply && !expanded - - 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: group.latestNotification.notification.inlineReplyPlaceholder || "Quick reply..." - background: Item {} - color: Theme.surfaceText - font.pixelSize: Theme.fontSizeSmall - - onAccepted: { - if (text.length > 0) { - group.latestNotification.notification.sendInlineReply(text) - text = "" - } - } - } - } - - 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 - - Text { - anchors.centerIn: parent - text: "send" - font.family: Theme.iconFont - font.pixelSize: 16 - color: quickReplyField.text.length > 0 ? Theme.primaryText : Theme.surfaceVariantText - } - - MouseArea { - anchors.fill: parent - enabled: quickReplyField.text.length > 0 - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: { - group.latestNotification.notification.sendInlineReply(quickReplyField.text) - quickReplyField.text = "" - } - } - - Behavior on color { - ColorAnimation { - duration: Theme.shortDuration - easing.type: Theme.standardEasing - } - } - } - } - } - - // Expanded view - shows all notifications stacked - Column { - id: expandedContent - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.margins: Theme.spacingL - spacing: Theme.spacingM - visible: expanded - - // Group header with fixed anchored positioning - Item { - width: parent.width - height: 48 - - // Round app icon - fixed position on left - Rectangle { - id: expandedIconContainer - width: 40 - height: 40 - radius: 20 - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - 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: 4 - source: group.latestNotification.appIcon ? Quickshell.iconPath(group.latestNotification.appIcon, "") : "" - visible: status === Image.Ready - } - - // Fallback for expanded view - Text { - anchors.centerIn: parent - visible: !group.latestNotification.appIcon || group.latestNotification.appIcon === "" - text: { - const appName = group.appName || "?" - return appName.charAt(0).toUpperCase() - } - font.pixelSize: 16 - font.weight: Font.Bold - color: Theme.primaryText - } - } - - // App name and count badge - centered area - Item { - anchors.left: expandedIconContainer.right - anchors.leftMargin: Theme.spacingM - anchors.right: expandedControlsContainer.left - anchors.rightMargin: Theme.spacingM - anchors.verticalCenter: parent.verticalCenter - height: 32 - - Text { - id: expandedAppNameText - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: group.appName - color: Theme.surfaceText - font.pixelSize: Theme.fontSizeLarge - font.weight: Font.Bold - } - - // Count badge in expanded view - positioned next to app name - Rectangle { - width: 24 - height: 24 - radius: 12 - color: Theme.primary - anchors.left: expandedAppNameText.right - anchors.leftMargin: Theme.spacingS - anchors.verticalCenter: parent.verticalCenter - - Text { - anchors.centerIn: parent - text: group.count > 99 ? "99+" : group.count.toString() - color: Theme.primaryText - font.pixelSize: 11 - font.weight: Font.Bold - } - } - } - - // Controls container - fixed position on right - Item { - id: expandedControlsContainer - width: 72 - height: 32 - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - - Rectangle { - id: collapseButton - width: 32 - height: 32 - radius: 16 - anchors.left: parent.left - color: collapseArea.containsMouse ? - Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : - "transparent" - - Text { - anchors.centerIn: parent - text: "expand_less" - font.family: Theme.iconFont - font.pixelSize: 18 - color: Theme.surfaceText - } - - MouseArea { - id: collapseArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: NotificationService.toggleGroupExpansion(group.key) - } - } - - Rectangle { - id: dismissAllButton - width: 32 - height: 32 - radius: 16 - anchors.right: parent.right - color: dismissAllArea.containsMouse ? - Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : - "transparent" - - Text { - anchors.centerIn: parent - text: "close" - font.family: Theme.iconFont - font.pixelSize: 16 - color: Theme.surfaceText - } - - MouseArea { - id: dismissAllArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: NotificationService.dismissGroup(group.key) - } - } - } - } - - // Stacked individual notifications with smooth transitions - Column { - width: parent.width - spacing: Theme.spacingS - - Repeater { - model: group.notifications.slice(0, 10) // Show max 10 expanded - - delegate: Rectangle { - required property var modelData - - width: parent.width - height: notifContent.height + Theme.spacingM * 2 - radius: Theme.cornerRadius - color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.5) - border.color: modelData.urgency === 2 ? - Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2) : - "transparent" - border.width: modelData.urgency === 2 ? 1 : 0 - - // Stabilize layout during dismiss operations - clip: true - - // Smooth height transitions - Behavior on height { - NumberAnimation { - duration: Theme.shortDuration - easing.type: Theme.standardEasing - } - } - - Behavior on opacity { - NumberAnimation { - duration: Theme.shortDuration - easing.type: Theme.standardEasing - } - } - - Item { - id: notifContent - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - anchors.margins: Theme.spacingM - height: Math.max(individualIcon.height, contentColumn.height) - - // Small round notification icon/avatar - fixed position on left - Rectangle { - id: individualIcon - width: 32 - height: 32 - radius: 16 - anchors.left: parent.left - anchors.top: parent.top - 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.2) - border.width: 1 - clip: true - - IconImage { - anchors.fill: parent - anchors.margins: 3 - source: modelData.appIcon ? Quickshell.iconPath(modelData.appIcon, "") : "" - visible: status === Image.Ready - } - - // Fallback for individual notifications - Text { - anchors.centerIn: parent - visible: !modelData.appIcon || modelData.appIcon === "" - text: { - const appName = modelData.appName || "?" - return appName.charAt(0).toUpperCase() - } - font.pixelSize: 12 - font.weight: Font.Bold - color: Theme.primaryText - } - } - - // Individual dismiss button - fixed position on right - Rectangle { - id: individualDismissButton - width: 24 - height: 24 - radius: 12 - anchors.right: parent.right - anchors.top: parent.top - color: individualDismissArea.containsMouse ? - Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : - "transparent" - - Text { - anchors.centerIn: parent - text: "close" - font.family: Theme.iconFont - font.pixelSize: 12 - color: Theme.surfaceVariantText - } - - MouseArea { - id: individualDismissArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: NotificationService.dismissNotification(modelData) - } - } - - // Notification content - fills space between icon and dismiss button - Column { - id: contentColumn - anchors.left: individualIcon.right - anchors.leftMargin: Theme.spacingM - anchors.right: individualDismissButton.left - anchors.rightMargin: Theme.spacingM - anchors.top: parent.top - spacing: Theme.spacingXS - - // Title and timestamp - Item { - width: parent.width - height: Math.max(titleText.height, timeText.height) - - Text { - id: titleText - anchors.left: parent.left - anchors.right: timeText.left - anchors.rightMargin: Theme.spacingS - text: modelData.summary - color: Theme.surfaceText - font.pixelSize: Theme.fontSizeSmall - font.weight: Font.Medium - elide: Text.ElideRight - } - - Text { - id: timeText - anchors.right: parent.right - text: modelData.timeStr - color: Theme.surfaceVariantText - font.pixelSize: 10 - } - } - - // Body text - Text { - text: modelData.body - color: Theme.surfaceVariantText - font.pixelSize: Theme.fontSizeSmall - width: parent.width - wrapMode: Text.WordWrap - maximumLineCount: 3 - elide: Text.ElideRight - visible: text.length > 0 - } - - // Individual notification inline reply - Row { - width: parent.width - spacing: Theme.spacingS - visible: modelData.notification.hasInlineReply - - Rectangle { - width: parent.width - 50 - height: 28 - radius: 14 - color: Theme.surface - border.color: replyField.activeFocus ? Theme.primary : Theme.outline - border.width: 1 - - TextField { - id: replyField - anchors.fill: parent - anchors.margins: Theme.spacingXS - placeholderText: modelData.notification.inlineReplyPlaceholder || "Reply..." - background: Item {} - color: Theme.surfaceText - font.pixelSize: 11 - - onAccepted: { - if (text.length > 0) { - modelData.notification.sendInlineReply(text) - text = "" - } - } - } - } - - Rectangle { - width: 42 - height: 28 - radius: 14 - color: replyField.text.length > 0 ? Theme.primary : Theme.surfaceContainer - - Text { - anchors.centerIn: parent - text: "send" - font.family: Theme.iconFont - font.pixelSize: 12 - color: replyField.text.length > 0 ? Theme.primaryText : Theme.surfaceVariantText - } - - MouseArea { - anchors.fill: parent - enabled: replyField.text.length > 0 - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: { - modelData.notification.sendInlineReply(replyField.text) - replyField.text = "" - } - } - } - } - - // Actions - Row { - spacing: Theme.spacingS - visible: modelData.actions && modelData.actions.length > 0 - - Repeater { - model: modelData.actions || [] - delegate: Rectangle { - width: actionText.width + Theme.spacingS * 2 - height: 24 - radius: Theme.cornerRadius - color: actionArea.containsMouse ? Theme.primaryContainer : Theme.surfaceContainer - border.color: Theme.outline - border.width: 1 - - Text { - id: actionText - anchors.centerIn: parent - text: modelData.text - font.pixelSize: 11 - color: Theme.surfaceText - font.weight: Font.Medium - } - - MouseArea { - id: actionArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: modelData.invoke() - } - } - } - } - } - } - } - } - - // "Show more" if there are many notifications - Rectangle { - width: parent.width - height: 32 - radius: Theme.cornerRadius - color: Theme.surfaceContainer - visible: group.count > 10 - - Text { - anchors.centerIn: parent - text: `Show ${group.count - 10} more notifications...` - color: Theme.surfaceText - font.pixelSize: Theme.fontSizeSmall - font.weight: Font.Medium - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: { - // Implement pagination or full expansion - console.log("Show more notifications") - } - } - } - } - } - - // Tap to expand (only for collapsed state with multiple notifications) - MouseArea { - anchors.fill: parent - visible: !expanded && group.count > 1 - onClicked: NotificationService.toggleGroupExpansion(group.key) - z: -1 - } -} \ No newline at end of file diff --git a/Widgets/NotificationCenter.qml b/Widgets/NotificationCenter.qml index 00bd5f4e..a3c0cbaa 100644 --- a/Widgets/NotificationCenter.qml +++ b/Widgets/NotificationCenter.qml @@ -117,7 +117,7 @@ PanelWindow { spacing: Theme.spacingM // Header - Row { + Item { width: parent.width height: 32 @@ -126,19 +126,16 @@ PanelWindow { font.pixelSize: Theme.fontSizeLarge color: Theme.surfaceText font.weight: Font.Medium + anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter } - Item { - width: parent.width - 240 - Theme.spacingM - height: 1 - } - - // Clear All Button + // Clear All Button - fixed width aligned to right Rectangle { width: 120 height: 28 radius: Theme.cornerRadius + anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter visible: NotificationService.notifications.length > 0 @@ -273,11 +270,632 @@ PanelWindow { } } - delegate: GroupedNotificationCard { + delegate: Rectangle { required property var modelData - group: modelData - width: ListView.view.width - Theme.spacingM - // expanded property is now readonly and managed by NotificationService + + readonly property bool expanded: NotificationService.expandedGroups[modelData.key] || false + + 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 // Quick reply height + } + return collapsedHeight + 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 + + // Priority indicator for urgent notifications + 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 + } + + Behavior on height { + SequentialAnimation { + PauseAnimation { duration: 25 } + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.emphasizedEasing + } + } + } + + // Collapsed view - shows app header and latest notification + Column { + id: collapsedContent + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Theme.spacingL + spacing: Theme.spacingS + visible: !expanded + + // App header with group info + Item { + width: parent.width + height: 72 + + // App icon with proper fallback handling + Item { + id: iconContainer + width: 48 + height: 48 + 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: 6 + source: { + if (modelData.latestNotification.appIcon && modelData.latestNotification.appIcon !== "") { + return Quickshell.iconPath(modelData.latestNotification.appIcon, "") + } + return "" + } + visible: status === Image.Ready + + onStatusChanged: { + if (status === Image.Error || status === Image.Null || source === "") { + fallbackIcon.visible = true + } else if (status === Image.Ready) { + fallbackIcon.visible = false + } + } + } + + Text { + id: fallbackIcon + anchors.centerIn: parent + visible: true + text: { + const appName = modelData.appName || "?" + return appName.charAt(0).toUpperCase() + } + font.pixelSize: 20 + font.weight: Font.Bold + color: Theme.primaryText + } + } + + // 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 + } + } + } + + // Content area with proper spacing + Column { + anchors.left: iconContainer.right + anchors.leftMargin: Theme.spacingM + anchors.right: controlsContainer.left + anchors.rightMargin: Theme.spacingM + anchors.top: parent.top + anchors.topMargin: Theme.spacingS + spacing: Theme.spacingS + + // 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 with fixed positioning + Item { + id: controlsContainer + width: 72 + height: 32 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + 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 + + Text { + anchors.centerIn: parent + text: "expand_more" + font.family: Theme.iconFont + font.pixelSize: 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) + } + } + + 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" + + Text { + anchors.centerIn: parent + text: "close" + font.family: Theme.iconFont + font.pixelSize: 16 + color: Theme.surfaceText + } + + MouseArea { + id: dismissArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: NotificationService.dismissGroup(modelData.key) + } + } + } + } + + // Enhanced quick reply for conversations + Row { + width: parent.width + spacing: Theme.spacingS + visible: modelData.latestNotification.notification.hasInlineReply && !expanded + + 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..." + background: Item {} + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeSmall + + onAccepted: { + if (text.length > 0) { + modelData.latestNotification.notification.sendInlineReply(text) + text = "" + } + } + } + } + + 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 + + Text { + anchors.centerIn: parent + text: "send" + font.family: Theme.iconFont + font.pixelSize: 16 + color: quickReplyField.text.length > 0 ? Theme.primaryText : Theme.surfaceVariantText + } + + MouseArea { + anchors.fill: parent + enabled: quickReplyField.text.length > 0 + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + modelData.latestNotification.notification.sendInlineReply(quickReplyField.text) + quickReplyField.text = "" + } + } + + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } + } + } + + // Expanded view - shows all notifications stacked + Column { + id: expandedContent + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Theme.spacingL + spacing: Theme.spacingM + visible: expanded + + // Group header in expanded view + Item { + width: parent.width + height: 48 + + Rectangle { + width: 40 + height: 40 + radius: 20 + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + 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: 4 + source: modelData.latestNotification.appIcon ? Quickshell.iconPath(modelData.latestNotification.appIcon, "") : "" + visible: status === Image.Ready + } + + Text { + anchors.centerIn: parent + visible: !modelData.latestNotification.appIcon || modelData.latestNotification.appIcon === "" + text: { + const appName = modelData.appName || "?" + return appName.charAt(0).toUpperCase() + } + font.pixelSize: 16 + font.weight: Font.Bold + color: Theme.primaryText + } + } + + Text { + anchors.left: parent.left + anchors.leftMargin: 52 + anchors.verticalCenter: parent.verticalCenter + text: modelData.appName + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Bold + } + + Item { + width: 72 + height: 32 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + width: 32 + height: 32 + radius: 16 + anchors.left: parent.left + color: collapseArea.containsMouse ? + Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : + "transparent" + + Text { + anchors.centerIn: parent + text: "expand_less" + font.family: Theme.iconFont + font.pixelSize: 18 + color: Theme.surfaceText + } + + MouseArea { + id: collapseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: NotificationService.toggleGroupExpansion(modelData.key) + } + } + + Rectangle { + width: 32 + height: 32 + radius: 16 + anchors.right: parent.right + color: dismissAllArea.containsMouse ? + Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : + "transparent" + + Text { + anchors.centerIn: parent + text: "close" + font.family: Theme.iconFont + font.pixelSize: 16 + color: Theme.surfaceText + } + + MouseArea { + id: dismissAllArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: NotificationService.dismissGroup(modelData.key) + } + } + } + } + + // Individual notifications + Column { + width: parent.width + spacing: Theme.spacingS + + Repeater { + model: modelData.notifications.slice(0, 10) + + delegate: Rectangle { + required property var modelData + + width: parent.width + height: Math.max(48, 32 + contentColumn.height + Theme.spacingM * 2) // 32 for icon height, plus content + radius: Theme.cornerRadius + color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.5) + border.color: modelData.urgency === 2 ? + Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2) : + "transparent" + border.width: modelData.urgency === 2 ? 1 : 0 + clip: true + + Item { + id: notifContent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Theme.spacingM + height: Math.max(32, contentColumn.height) + + Rectangle { + width: 32 + height: 32 + radius: 16 + anchors.left: parent.left + anchors.top: parent.top + 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.2) + border.width: 1 + + Text { + anchors.centerIn: parent + text: { + const appName = modelData.appName || "?" + return appName.charAt(0).toUpperCase() + } + font.pixelSize: 12 + font.weight: Font.Bold + color: Theme.primaryText + } + } + + Rectangle { + width: 24 + height: 24 + radius: 12 + anchors.right: parent.right + anchors.top: parent.top + color: individualDismissArea.containsMouse ? + Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : + "transparent" + + Text { + anchors.centerIn: parent + text: "close" + font.family: Theme.iconFont + font.pixelSize: 12 + color: Theme.surfaceVariantText + } + + MouseArea { + id: individualDismissArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: NotificationService.dismissNotification(modelData) + } + } + + Column { + id: contentColumn + anchors.left: parent.left + anchors.leftMargin: 44 + anchors.right: parent.right + anchors.rightMargin: 36 + anchors.top: parent.top + spacing: Theme.spacingXS + + Text { + text: modelData.summary + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + width: parent.width + elide: Text.ElideRight + } + + Text { + text: modelData.body + color: Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeSmall + width: parent.width + wrapMode: Text.WordWrap + maximumLineCount: 3 + elide: Text.ElideRight + visible: text.length > 0 + } + + // Individual inline reply + Row { + width: parent.width + spacing: Theme.spacingS + visible: modelData.notification.hasInlineReply + + Rectangle { + width: parent.width - 50 + height: 28 + radius: 14 + color: Theme.surface + border.color: replyField.activeFocus ? Theme.primary : Theme.outline + border.width: 1 + + TextField { + id: replyField + anchors.fill: parent + anchors.margins: Theme.spacingXS + placeholderText: modelData.notification.inlineReplyPlaceholder || "Reply..." + background: Item {} + color: Theme.surfaceText + font.pixelSize: 11 + + onAccepted: { + if (text.length > 0) { + modelData.notification.sendInlineReply(text) + text = "" + } + } + } + } + + Rectangle { + width: 42 + height: 28 + radius: 14 + color: replyField.text.length > 0 ? Theme.primary : Theme.surfaceContainer + + Text { + anchors.centerIn: parent + text: "send" + font.family: Theme.iconFont + font.pixelSize: 12 + color: replyField.text.length > 0 ? Theme.primaryText : Theme.surfaceVariantText + } + + MouseArea { + anchors.fill: parent + enabled: replyField.text.length > 0 + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + modelData.notification.sendInlineReply(replyField.text) + replyField.text = "" + } + } + } + } + } + } + } + } + } + } + + // Tap to expand for collapsed groups + MouseArea { + anchors.fill: parent + visible: !expanded && modelData.count > 1 + onClicked: NotificationService.toggleGroupExpansion(modelData.key) + z: -1 + } } } diff --git a/Widgets/NotificationInit.qml b/Widgets/NotificationInit.qml deleted file mode 100644 index 34862bb8..00000000 --- a/Widgets/NotificationInit.qml +++ /dev/null @@ -1,172 +0,0 @@ -import QtQuick -import QtQuick.Controls -import Quickshell -import Quickshell.Widgets -import Quickshell.Wayland -import qs.Common -import qs.Services - -PanelWindow { - id: notificationPopup - objectName: "notificationPopup" // For context detection - - 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: 400 - implicitHeight: groupedNotificationsList.height + 32 - - Column { - id: groupedNotificationsList - anchors.top: parent.top - anchors.right: parent.right - anchors.topMargin: 16 - anchors.rightMargin: 16 - spacing: Theme.spacingM - width: 380 - - Repeater { - model: NotificationService.groupedPopups - - delegate: GroupedNotificationCard { - required property var modelData - group: modelData - width: parent.width - - // Popup-specific styling: Extra padding for single notifications - property bool isPopupContext: true - property int extraTopMargin: group.count === 1 ? 6 : 0 - property int extraBottomMargin: group.count === 1 ? 6 : 0 - - // Hover detection for preventing auto-dismiss - property bool isHovered: false - - MouseArea { - id: hoverDetection - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.NoButton // Don't intercept clicks - propagateComposedEvents: true - z: -1 // Behind other elements - - onEntered: { - parent.isHovered = true - console.log("Notification hovered - pausing auto-dismiss") - } - - onExited: { - parent.isHovered = false - console.log("Notification hover ended - resuming auto-dismiss") - } - } - - // Enhanced auto-dismiss timer with hover pause - Timer { - id: autoDismissTimer - running: group.count === 1 && group.latestNotification.popup && !group.latestNotification.notification.hasInlineReply && !parent.isHovered - interval: group.latestNotification.notification.expireTimeout > 0 ? - group.latestNotification.notification.expireTimeout * 1000 : 7000 // Increased to 7 seconds - onTriggered: { - if (!parent.isHovered) { - group.latestNotification.popup = false - } - } - - // Restart timer when hover ends - onRunningChanged: { - if (running && !parent.isHovered) { - restart() - } - } - } - - // Don't auto-dismiss conversation groups - let user interact - property bool isConversationGroup: group.isConversation && group.count > 1 - - // Stabilized entry animation for popup context - transform: [ - Translate { - id: slideTransform - x: notificationPopup.visible ? 0 : 400 - - Behavior on x { - NumberAnimation { - duration: Theme.mediumDuration - easing.type: Theme.emphasizedEasing - } - } - }, - Scale { - id: scaleTransform - origin.x: parent.width - origin.y: 0 - xScale: notificationPopup.visible ? 1.0 : 0.95 - yScale: notificationPopup.visible ? 1.0 : 0.8 - - Behavior on xScale { - NumberAnimation { - duration: Theme.mediumDuration - easing.type: Theme.emphasizedEasing - } - } - - Behavior on yScale { - NumberAnimation { - duration: Theme.mediumDuration - easing.type: Theme.emphasizedEasing - } - } - } - ] - - opacity: notificationPopup.visible ? 1.0 : 0.0 - - // Enhanced height transitions for popup stability - Behavior on height { - SequentialAnimation { - PauseAnimation { - duration: 10 // Shorter pause for popup responsiveness - } - NumberAnimation { - duration: Theme.shortDuration // Faster transitions in popup - easing.type: Theme.standardEasing - } - } - } - - Behavior on opacity { - NumberAnimation { - duration: Theme.mediumDuration - easing.type: Theme.emphasizedEasing - } - } - - // Popup-specific stability improvements - clip: true // Prevent content overflow during animations - } - } - } - - // Smooth height animation - Behavior on implicitHeight { - NumberAnimation { - duration: Theme.mediumDuration - easing.type: Theme.emphasizedEasing - } - } -} \ No newline at end of file diff --git a/Widgets/NotificationItem.qml b/Widgets/NotificationItem.qml deleted file mode 100644 index 50995d26..00000000 --- a/Widgets/NotificationItem.qml +++ /dev/null @@ -1,350 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Effects -import Quickshell -import Quickshell.Widgets -import Quickshell.Services.Notifications -import qs.Common -import qs.Services - -Rectangle { - id: root - - required property var notificationWrapper - readonly property bool hasImage: notificationWrapper.hasImage - readonly property bool hasAppIcon: notificationWrapper.hasAppIcon - readonly property bool isConversation: notificationWrapper.isConversation - readonly property bool isMedia: notificationWrapper.isMedia - readonly property bool isUrgent: notificationWrapper.urgency === 2 - readonly property bool isPopup: notificationWrapper.popup - - property bool expanded: false - - width: 380 - height: Math.max(contentColumn.height + Theme.spacingL * 2, 80) - radius: Theme.cornerRadiusLarge - color: isUrgent ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : Theme.popupBackground() - border.color: isUrgent ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2) : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08) - border.width: 1 - - // Priority indicator for urgent notifications - Rectangle { - width: 4 - height: parent.height - 16 - anchors.left: parent.left - anchors.leftMargin: 2 - anchors.verticalCenter: parent.verticalCenter - radius: 2 - color: Theme.primary - visible: isUrgent - } - - MouseArea { - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - acceptedButtons: Qt.LeftButton | Qt.MiddleButton - - onEntered: notificationWrapper.timer.stop() - onExited: notificationWrapper.timer.start() - - onClicked: (mouse) => { - if (mouse.button === Qt.MiddleButton) { - NotificationService.dismissNotification(notificationWrapper) - } else { - // Handle notification action - const actions = notificationWrapper.actions; - if (actions && actions.length === 1) { - actions[0].invoke(); - } - } - } - } - - Column { - id: contentColumn - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: Theme.spacingL - spacing: Theme.spacingS - - Row { - width: parent.width - spacing: Theme.spacingM - - // Image/Icon container - Item { - width: 48 - height: 48 - anchors.top: parent.top - - // Notification image (Discord avatars, media artwork, etc.) - Loader { - id: imageLoader - active: root.hasImage - anchors.fill: parent - - sourceComponent: Rectangle { - radius: 24 // Fully rounded - color: Theme.surfaceContainer - clip: true - - Image { - id: notifImage - anchors.fill: parent - source: root.notificationWrapper.image - fillMode: Image.PreserveAspectCrop - cache: false - antialiasing: true - asynchronous: true - smooth: true - - onStatusChanged: { - if (status === Image.Error) { - console.warn("Failed to load notification image:", source) - } - } - } - } - } - - // App icon (shown when no image, or as badge when image present) - Loader { - active: root.hasAppIcon || !root.hasImage - - // Position as overlay badge when image is present, center when no image - anchors.centerIn: root.hasImage ? undefined : parent - anchors.bottom: root.hasImage ? parent.bottom : undefined - anchors.right: root.hasImage ? parent.right : undefined - - sourceComponent: Rectangle { - width: root.hasImage ? 20 : 48 - height: root.hasImage ? 20 : 48 - radius: width / 2 - color: getIconBackgroundColor() - border.color: root.hasImage ? Theme.surface : "transparent" - border.width: root.hasImage ? 2 : 0 - - function getIconBackgroundColor() { - if (root.hasImage) { - return Theme.surface // Badge background - } else if (root.isConversation) { - return Theme.primaryContainer - } else if (root.isMedia) { - return Qt.rgba(1, 0.42, 0.21, 0.2) // Orange tint - } - return Theme.primaryContainer - } - - IconImage { - id: iconImage - width: root.hasImage ? 14 : 32 - height: root.hasImage ? 14 : 32 - anchors.centerIn: parent - asynchronous: true - visible: status === Image.Ready - source: { - if (root.hasAppIcon) { - return Quickshell.iconPath(root.notificationWrapper.appIcon, "") - } - // Special cases for specific apps - if (root.notificationWrapper.appName === "niri" && root.notificationWrapper.summary === "Screenshot captured") { - return Quickshell.iconPath("camera-photo", "") - } - // Fallback icons - if (root.isConversation) return Quickshell.iconPath("chat", "") - if (root.isMedia) return Quickshell.iconPath("music_note", "") - return Quickshell.iconPath("dialog-information", "") - } - - // Color overlay for symbolic icons when used as badge - layer.enabled: root.hasImage && root.notificationWrapper.appIcon.endsWith("symbolic") - layer.effect: MultiEffect { - colorization: 1.0 - colorizationColor: Theme.surfaceText - } - } - - // Elegant fallback when icon fails to load - Rectangle { - width: root.hasImage ? 14 : 32 - height: root.hasImage ? 14 : 32 - anchors.centerIn: parent - visible: iconImage.status === Image.Error || iconImage.status === Image.Null - radius: width / 2 - 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 - - Text { - anchors.centerIn: parent - text: { - if (root.isConversation) return "💬" - if (root.isMedia) return "🎵" - if (root.notificationWrapper.appName === "niri") return "📷" - return "📋" - } - font.pixelSize: root.hasImage ? 8 : 16 - color: Theme.primary - } - } - } - } - - // Fallback when no app icon and no image - Loader { - active: !root.hasAppIcon && !root.hasImage - anchors.centerIn: parent - - sourceComponent: Rectangle { - width: 48 - height: 48 - radius: 24 - color: Theme.primaryContainer - - Text { - anchors.centerIn: parent - text: getFallbackIconText() - font.family: Theme.iconFont - font.pixelSize: 20 - color: Theme.primaryText - - function getFallbackIconText() { - if (root.isConversation) return "chat" - if (root.isMedia) return "music_note" - return "apps" - } - } - } - } - } - - // Content area - Column { - width: parent.width - 48 - Theme.spacingM - 24 - Theme.spacingS - spacing: Theme.spacingXS - - // Header row: App name and timestamp combined - Text { - text: { - const appName = root.notificationWrapper.appName || "Unknown" - const timeStr = root.notificationWrapper.timeStr || "now" - return appName + " • " + timeStr - } - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceVariantText - font.weight: Font.Medium - width: parent.width - elide: Text.ElideRight - } - - // Summary (title) - Text { - text: root.notificationWrapper.summary - font.pixelSize: Theme.fontSizeMedium - color: Theme.surfaceText - font.weight: Font.Medium - width: parent.width - elide: Text.ElideRight - visible: text.length > 0 - } - - // Body text - use full available width - Text { - text: root.notificationWrapper.body - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceVariantText - width: parent.width - wrapMode: Text.WordWrap - maximumLineCount: root.expanded ? -1 : 2 - elide: Text.ElideRight - visible: text.length > 0 - textFormat: Text.MarkdownText - - onLinkActivated: (link) => { - Qt.openUrlExternally(link) - NotificationService.dismissNotification(root.notificationWrapper) - } - } - } - - // Close button - Rectangle { - width: 24 - height: 24 - radius: 12 - color: closeArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" - - Text { - anchors.centerIn: parent - text: "close" - font.family: Theme.iconFont - font.pixelSize: 14 - color: closeArea.containsMouse ? Theme.primary : Theme.surfaceVariantText - } - - MouseArea { - id: closeArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: NotificationService.dismissNotification(root.notificationWrapper) - } - } - - } - - // Actions (if present) - Row { - width: parent.width - spacing: Theme.spacingS - anchors.left: parent.left - anchors.right: parent.right - anchors.leftMargin: 48 + Theme.spacingM + Theme.spacingL - anchors.rightMargin: Theme.spacingL - visible: root.notificationWrapper.actions && root.notificationWrapper.actions.length > 0 - - Repeater { - model: root.notificationWrapper.actions || [] - - delegate: Rectangle { - required property NotificationAction modelData - - width: actionText.width + Theme.spacingM * 2 - height: 32 - radius: Theme.cornerRadius - color: actionArea.containsMouse ? Theme.primaryContainer : Theme.surfaceContainer - border.color: Theme.outline - border.width: 1 - - Text { - id: actionText - anchors.centerIn: parent - text: modelData.text - font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceVariantText - font.weight: Font.Medium - } - - MouseArea { - id: actionArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: modelData.invoke() - } - } - } - } - } - - - // Animations - Behavior on height { - NumberAnimation { - duration: Theme.mediumDuration - easing.type: Theme.emphasizedEasing - } - } -} \ No newline at end of file diff --git a/Widgets/NotificationPopup.qml b/Widgets/NotificationPopup.qml new file mode 100644 index 00000000..439261f3 --- /dev/null +++ b/Widgets/NotificationPopup.qml @@ -0,0 +1,805 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import qs.Common +import qs.Services + +PanelWindow { + id: notificationPopup + objectName: "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: 12 + } + + implicitWidth: 400 + implicitHeight: notificationsList.height + 32 + + // Expose key child objects for testing + // Expose the currently visible quickReplyField for testing + property TextField quickReplyField: null + // Expose the currently visible iconContainer for testing + property Item iconContainer: null + // Expose the currently visible expandedContent for testing + property Column expandedContent: null + // Expose the currently visible hoverArea for testing + property MouseArea hoverArea: null + + Column { + id: notificationsList + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: 16 + anchors.rightMargin: 16 + spacing: Theme.spacingM + width: 380 + + Repeater { + model: NotificationService.groupedPopups + + delegate: Rectangle { + required property var modelData + + // Context detection for popup + readonly property bool isPopupContext: true + readonly property bool expanded: NotificationService.expandedGroups[modelData.key] || false + + width: parent.width + height: { + let calculatedHeight; + if (expanded) { + // Calculate expanded height properly: header (48) + spacing + notifications + let headerHeight = 48 + Theme.spacingM + let maxNotificationsInPopup = Math.min(modelData.notifications.length, 5) + let notificationHeight = maxNotificationsInPopup * (60 + Theme.spacingS) + calculatedHeight = headerHeight + notificationHeight + Theme.spacingL * 2 + } else { + // Collapsed height: header (72) + quick reply if present + calculatedHeight = 72 + Theme.spacingS * 2 + if (modelData.latestNotification.notification.hasInlineReply) { + calculatedHeight += 36 + Theme.spacingS + } + calculatedHeight += Theme.spacingL * 2 + } + + // Add extra height for single notifications in popup context + if (isPopupContext && modelData.count === 1) { + calculatedHeight += 12; + } + return calculatedHeight; + } + 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 + + // Stabilize layout during content changes + clip: true + + // Smooth popup animations + transform: Translate { + x: notificationPopup.visible ? 0 : 400 + Behavior on x { + NumberAnimation { + duration: 350 + easing.type: Easing.OutCubic + } + } + } + + opacity: notificationPopup.visible ? 1.0 : 0.0 + Behavior on opacity { + NumberAnimation { + duration: 300 + easing.type: Easing.OutCubic + } + } + + scale: notificationPopup.visible ? 1.0 : 0.98 + Behavior on scale { + NumberAnimation { + duration: 350 + easing.type: Easing.OutCubic + } + } + + // Priority indicator for urgent notifications + 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 + } + + Behavior on height { + enabled: !isPopupContext // Disable automatic height animation in popup to prevent glitches + SequentialAnimation { + PauseAnimation { duration: 25 } + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.emphasizedEasing + } + } + } + + // Collapsed view - shows app header and latest notification + Column { + id: collapsedContent + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Theme.spacingL + spacing: Theme.spacingS + visible: !expanded + + // App header with group info + Item { + width: parent.width + height: 72 // Increased height for better text spacing + + // Round app icon with proper API usage + Item { + id: iconContainer + Component.onCompleted: { + // Expose this iconContainer to the root for testing if visible + notificationPopup.iconContainer = iconContainer + } + width: 48 + height: 48 + 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: 6 + source: { + if (modelData.latestNotification.appIcon && modelData.latestNotification.appIcon !== "") { + return Quickshell.iconPath(modelData.latestNotification.appIcon, "") + } + return "" + } + visible: status === Image.Ready + + onStatusChanged: { + if (status === Image.Error || status === Image.Null || source === "") { + fallbackIcon.visible = true + } else if (status === Image.Ready) { + fallbackIcon.visible = false + } + } + } + + // Fallback icon - show by default, hide when real icon loads + Text { + id: fallbackIcon + anchors.centerIn: parent + visible: true // Start visible, hide when real icon loads + text: { + // Use first letter of app name as fallback + const appName = modelData.appName || "?" + return appName.charAt(0).toUpperCase() + } + font.pixelSize: 20 + font.weight: Font.Bold + color: Theme.primaryText + } + } + + // Count badge for multiple notifications - smaller circle + 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 + } + } + } + + // App info and latest notification content + Column { + anchors.left: iconContainer.right + anchors.leftMargin: Theme.spacingM + anchors.right: controlsContainer.left + anchors.rightMargin: Theme.spacingM + anchors.top: parent.top + anchors.topMargin: Theme.spacingS + spacing: Theme.spacingS + + // App name and timestamp on same line + 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 // Slightly larger for emphasis + font.weight: Font.Medium + width: parent.width + elide: Text.ElideRight + maximumLineCount: 1 + visible: text.length > 0 + } + + // Latest notification body (smaller, secondary) + Text { + text: modelData.latestNotification.body + color: Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeSmall + width: parent.width + elide: Text.ElideRight + maximumLineCount: modelData.count > 1 ? 1 : 2 // More space for single notifications + wrapMode: Text.WordWrap + visible: text.length > 0 + } + } + + // Expand/dismiss controls - use anchored layout for stability + Item { + id: controlsContainer + width: 72 + height: 32 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + 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 + + Text { + anchors.centerIn: parent + text: "expand_more" + font.family: Theme.iconFont + font.pixelSize: 18 + color: Theme.surfaceText + rotation: expanded ? 180 : 0 + + Behavior on rotation { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } + + MouseArea { + // ...existing code... + id: expandArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + console.log("Expand clicked - pausing timer") + dismissTimer.stop() + NotificationService.toggleGroupExpansion(modelData.key) + } + } + } + + 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" + + Text { + anchors.centerIn: parent + text: "close" + font.family: Theme.iconFont + font.pixelSize: 16 + color: Theme.surfaceText + } + + MouseArea { + id: dismissArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: NotificationService.dismissGroup(modelData.key) + } + } + } + } + + // Quick reply for conversations (only if latest notification supports it) + Row { + width: parent.width + spacing: Theme.spacingS + visible: modelData.latestNotification.notification.hasInlineReply && !expanded + + 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..." + background: Item {} + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeSmall + + onAccepted: { + if (text.length > 0) { + modelData.latestNotification.notification.sendInlineReply(text) + text = "" + } + } + } + } + + 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 + + Text { + anchors.centerIn: parent + text: "send" + font.family: Theme.iconFont + font.pixelSize: 16 + color: quickReplyField.text.length > 0 ? Theme.primaryText : Theme.surfaceVariantText + } + + MouseArea { + anchors.fill: parent + enabled: quickReplyField.text.length > 0 + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + modelData.latestNotification.notification.sendInlineReply(quickReplyField.text) + quickReplyField.text = "" + } + } + + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } + } + } + + // Expanded view - shows all notifications stacked + Column { + id: expandedContent + Component.onCompleted: { + // Expose this expandedContent to the root for testing if visible + notificationPopup.expandedContent = expandedContent + } + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Theme.spacingL + spacing: Theme.spacingM + visible: expanded + + // Group header with fixed anchored positioning + Item { + width: parent.width + height: 48 + + // Round app icon - fixed position on left + Rectangle { + width: 40 + height: 40 + radius: 20 + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + 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: 4 + source: modelData.latestNotification.appIcon ? Quickshell.iconPath(modelData.latestNotification.appIcon, "") : "" + visible: status === Image.Ready + } + + // Fallback for expanded view + Text { + anchors.centerIn: parent + visible: !modelData.latestNotification.appIcon || modelData.latestNotification.appIcon === "" + text: { + const appName = modelData.appName || "?" + return appName.charAt(0).toUpperCase() + } + font.pixelSize: 16 + font.weight: Font.Bold + color: Theme.primaryText + } + } + + // App name and count badge - centered area + Text { + anchors.left: parent.left + anchors.leftMargin: 52 + anchors.verticalCenter: parent.verticalCenter + text: modelData.appName + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Bold + } + + // Controls container - fixed position on right + Item { + width: 72 + height: 32 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + width: 32 + height: 32 + radius: 16 + anchors.left: parent.left + color: collapseArea.containsMouse ? + Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : + "transparent" + + Text { + anchors.centerIn: parent + text: "expand_less" + font.family: Theme.iconFont + font.pixelSize: 18 + color: Theme.surfaceText + } + + MouseArea { + id: collapseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + console.log("Expand clicked - pausing timer") + dismissTimer.stop() + NotificationService.toggleGroupExpansion(modelData.key) + } + } + } + + Rectangle { + width: 32 + height: 32 + radius: 16 + anchors.right: parent.right + color: dismissAllArea.containsMouse ? + Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : + "transparent" + + Text { + anchors.centerIn: parent + text: "close" + font.family: Theme.iconFont + font.pixelSize: 16 + color: Theme.surfaceText + } + + MouseArea { + id: dismissAllArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: NotificationService.dismissGroup(modelData.key) + } + } + } + } + + // Stacked individual notifications with smooth transitions + Column { + width: parent.width + spacing: Theme.spacingS + + Repeater { + model: modelData.notifications.slice(0, 5) // Show max 5 in popup + + delegate: Rectangle { + required property var modelData + + width: parent.width + height: notifContent.height + Theme.spacingM * 2 + radius: Theme.cornerRadius + color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.5) + border.color: modelData.urgency === 2 ? + Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.2) : + "transparent" + border.width: modelData.urgency === 2 ? 1 : 0 + + // Stabilize layout during dismiss operations + clip: true + + Item { + id: notifContent + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Theme.spacingM + height: Math.max(32, contentColumn.height) + + // Small round notification icon/avatar - fixed position on left + Rectangle { + width: 32 + height: 32 + radius: 16 + anchors.left: parent.left + anchors.top: parent.top + 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.2) + border.width: 1 + clip: true + + IconImage { + anchors.fill: parent + anchors.margins: 3 + source: modelData.appIcon ? Quickshell.iconPath(modelData.appIcon, "") : "" + visible: status === Image.Ready + } + + // Fallback for individual notifications + Text { + anchors.centerIn: parent + visible: !modelData.appIcon || modelData.appIcon === "" + text: { + const appName = modelData.appName || "?" + return appName.charAt(0).toUpperCase() + } + font.pixelSize: 12 + font.weight: Font.Bold + color: Theme.primaryText + } + } + + // Individual dismiss button - fixed position on right + Rectangle { + width: 24 + height: 24 + radius: 12 + anchors.right: parent.right + anchors.top: parent.top + color: individualDismissArea.containsMouse ? + Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : + "transparent" + + Text { + anchors.centerIn: parent + text: "close" + font.family: Theme.iconFont + font.pixelSize: 12 + color: Theme.surfaceVariantText + } + + MouseArea { + id: individualDismissArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: NotificationService.dismissNotification(modelData) + } + } + + // Notification content - fills space between icon and dismiss button + Column { + id: contentColumn + anchors.left: parent.left + anchors.leftMargin: 44 + anchors.right: parent.right + anchors.rightMargin: 36 + anchors.top: parent.top + spacing: Theme.spacingXS + + // Title and timestamp + Text { + text: modelData.summary + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + width: parent.width + elide: Text.ElideRight + } + + // Body text + Text { + text: modelData.body + color: Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeSmall + width: parent.width + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + visible: text.length > 0 + } + + // Individual notification inline reply + Row { + width: parent.width + spacing: Theme.spacingS + visible: modelData.notification.hasInlineReply + + Rectangle { + width: parent.width - 50 + height: 28 + radius: 14 + color: Theme.surface + border.color: replyField.activeFocus ? Theme.primary : Theme.outline + border.width: 1 + + TextField { + id: replyField + anchors.fill: parent + anchors.margins: Theme.spacingXS + placeholderText: modelData.notification.inlineReplyPlaceholder || "Reply..." + background: Item {} + color: Theme.surfaceText + font.pixelSize: 11 + + onAccepted: { + if (text.length > 0) { + modelData.notification.sendInlineReply(text) + text = "" + } + } + } + } + + Rectangle { + width: 42 + height: 28 + radius: 14 + color: replyField.text.length > 0 ? Theme.primary : Theme.surfaceContainer + + Text { + anchors.centerIn: parent + text: "send" + font.family: Theme.iconFont + font.pixelSize: 12 + color: replyField.text.length > 0 ? Theme.primaryText : Theme.surfaceVariantText + } + + MouseArea { + anchors.fill: parent + enabled: replyField.text.length > 0 + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + modelData.notification.sendInlineReply(replyField.text) + replyField.text = "" + } + } + } + } + } + } + } + } + } + } + + // Hover to pause auto-dismiss - MUST be properly configured + MouseArea { + id: hoverArea + Component.onCompleted: { + // Expose this hoverArea to the root for testing if visible + notificationPopup.hoverArea = hoverArea + } + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + z: 10 // Higher z-order to ensure hover detection + propagateComposedEvents: true + + onEntered: { + console.log("Notification hover entered - pausing timer") + dismissTimer.stop() + } + + onExited: { + console.log("Notification hover exited - resuming timer") + if (modelData.latestNotification.popup && !expanded) { + dismissTimer.restart() + } + } + } + + // Auto-dismiss timer - properly pauses on hover + Timer { + id: dismissTimer + running: modelData.latestNotification.popup && !expanded + interval: modelData.latestNotification.notification.expireTimeout > 0 ? + modelData.latestNotification.notification.expireTimeout * 1000 : 5000 + onTriggered: { + console.log("Timer triggered - hover state:", hoverArea.containsMouse, "expanded:", expanded) + if (!hoverArea.containsMouse && !expanded) { + console.log("Dismissing notification") + modelData.latestNotification.popup = false + } else { + console.log("Conditions not met - not dismissing") + } + } + } + } + } + } + // Smooth height animation + Behavior on implicitHeight { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.emphasizedEasing + } + } +} \ No newline at end of file diff --git a/shell.qml b/shell.qml index 02df4937..a6e9d175 100644 --- a/shell.qml +++ b/shell.qml @@ -209,7 +209,7 @@ ShellRoot { // Global popup windows CenterCommandCenter {} TrayMenuPopup {} - NotificationInit {} + NotificationPopup {} NotificationCenter { notificationHistoryVisible: root.notificationHistoryVisible onCloseRequested: {