diff --git a/Modules/NotificationCenter.qml b/Modules/NotificationCenter.qml index f592f972..b8ceb51b 100644 --- a/Modules/NotificationCenter.qml +++ b/Modules/NotificationCenter.qml @@ -563,6 +563,56 @@ PanelWindow { } + // Action buttons for collapsed view + Row { + width: parent.width + spacing: Theme.spacingS + visible: modelData.latestNotification.actions && modelData.latestNotification.actions.length > 0 && !modelData.latestNotification.notification.hasInlineReply && !expanded + + Repeater { + model: modelData.latestNotification.actions ? modelData.latestNotification.actions.slice(0, 2) : [] + delegate: Rectangle { + width: Math.min((parent.width - (parent.spacing * (parent.children.length - 1))) / parent.children.length, 120) + height: 32 + radius: 16 + color: collapsedActionArea.containsMouse ? Theme.primary : Theme.surfaceContainer + border.color: collapsedActionArea.containsMouse ? "transparent" : Theme.outline + border.width: 1 + + Text { + anchors.centerIn: parent + text: modelData.text || "" + color: collapsedActionArea.containsMouse ? Theme.primaryText : Theme.surfaceText + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + elide: Text.ElideRight + maximumLineCount: 1 + width: parent.width - 16 + horizontalAlignment: Text.AlignHCenter + } + + MouseArea { + id: collapsedActionArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (modelData.invoke) { + modelData.invoke(); + } + } + } + + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } + } + } + // Enhanced quick reply for conversations Row { width: parent.width @@ -934,6 +984,56 @@ PanelWindow { visible: text.length > 0 } + // Individual action buttons + Row { + width: parent.width + spacing: Theme.spacingXS + visible: modelData.actions && modelData.actions.length > 0 && !modelData.notification.hasInlineReply + + Repeater { + model: modelData.actions ? modelData.actions.slice(0, 2) : [] + delegate: Rectangle { + width: Math.min((parent.width - (parent.spacing * (parent.children.length - 1))) / parent.children.length, 80) + height: 24 + radius: 12 + color: expandedActionArea.containsMouse ? Theme.primary : Theme.surfaceContainer + border.color: expandedActionArea.containsMouse ? "transparent" : Theme.outline + border.width: 1 + + Text { + anchors.centerIn: parent + text: modelData.text || "" + color: expandedActionArea.containsMouse ? Theme.primaryText : Theme.surfaceText + font.pixelSize: 10 + font.weight: Font.Medium + elide: Text.ElideRight + maximumLineCount: 1 + width: parent.width - 8 + horizontalAlignment: Text.AlignHCenter + } + + MouseArea { + id: expandedActionArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (modelData.invoke) { + modelData.invoke(); + } + } + } + + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } + } + } + // Individual inline reply Row { width: parent.width diff --git a/Modules/NotificationPopup.qml b/Modules/NotificationPopup.qml index 3ac7d87e..4f7d5303 100644 --- a/Modules/NotificationPopup.qml +++ b/Modules/NotificationPopup.qml @@ -186,6 +186,57 @@ PanelWindow { visible: text.length > 0 } + // Action buttons + Row { + width: parent.width + spacing: Theme.spacingS + visible: modelData.latestNotification.actions && modelData.latestNotification.actions.length > 0 && !modelData.latestNotification.notification.hasInlineReply + + Repeater { + model: modelData.latestNotification.actions ? modelData.latestNotification.actions.slice(0, 3) : [] + delegate: Rectangle { + width: Math.min((parent.width - (parent.spacing * (parent.children.length - 1))) / parent.children.length, 120) + height: 32 + radius: 16 + color: actionArea.containsMouse ? Theme.primary : Theme.surfaceContainer + border.color: actionArea.containsMouse ? "transparent" : Theme.outline + border.width: 1 + + Text { + anchors.centerIn: parent + text: modelData.text || "" + color: actionArea.containsMouse ? Theme.primaryText : Theme.surfaceText + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + elide: Text.ElideRight + maximumLineCount: 1 + width: parent.width - 16 + horizontalAlignment: Text.AlignHCenter + } + + MouseArea { + id: actionArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (modelData.invoke) { + modelData.invoke(); + } + } + } + + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } + } + } + + // Inline reply Row { width: parent.width spacing: Theme.spacingS diff --git a/Services/NotificationService.qml b/Services/NotificationService.qml index 2b7dd459..21507726 100644 --- a/Services/NotificationService.qml +++ b/Services/NotificationService.qml @@ -4,6 +4,7 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell import Quickshell.Services.Notifications +import qs.Services Singleton { id: root @@ -17,6 +18,11 @@ Singleton { property var expandedGroups: ({}) // Track which groups are expanded property var expandedMessages: ({}) // Track which individual messages are expanded + + // Notification persistence settings + property int maxStoredNotifications: 100 + property int maxNotificationAge: 7 * 24 * 60 * 60 * 1000 // 7 days in milliseconds + property var persistedNotifications: ([]) // Stored notification history NotificationServer { id: server @@ -39,6 +45,13 @@ Singleton { console.log("urgency:", notif.urgency); console.log("hasInlineReply:", notif.hasInlineReply); console.log("============================="); + + // Check if notification should be shown based on settings + if (!NotificationSettings.shouldShowNotification(notif)) { + console.log("Notification blocked by settings for app:", notif.appName); + return; + } + notif.tracked = true; const wrapper = notifComponent.createObject(root, { @@ -47,7 +60,32 @@ Singleton { }); if (wrapper) { - root.notifications.push(wrapper); + // Handle media notification replacement + if (wrapper.isMedia) { + handleMediaNotification(wrapper); + } else { + root.notifications.push(wrapper); + } + + // Auto-expand conversation groups with new messages + if (wrapper.isConversation && notifications.length > 1) { + const groupKey = getGroupKey(wrapper); + const existingGroup = groupedNotifications.find(group => group.key === groupKey); + if (existingGroup && existingGroup.count > 1) { + let newExpandedGroups = {}; + for (const key in expandedGroups) { + newExpandedGroups[key] = expandedGroups[key]; + } + newExpandedGroups[groupKey] = true; + expandedGroups = newExpandedGroups; + } + } + + // Add to persistent storage (only for non-transient notifications) + if (!notif.transient) { + addToPersistentStorage(wrapper); + } + console.log("Notification added. Total notifications:", root.notifications.length); console.log("Grouped notifications:", root.groupedNotifications.length); } else { @@ -101,13 +139,91 @@ Singleton { readonly property bool hasImage: image && image.length > 0 readonly property bool hasAppIcon: appIcon && appIcon.length > 0 readonly property bool isConversation: notification.hasInlineReply + readonly property bool isMedia: isMediaNotification() + readonly property bool isSystem: isSystemNotification() + + function isMediaNotification() { + const appNameLower = appName.toLowerCase(); + const summaryLower = summary.toLowerCase(); + + // Check for media apps + if (appNameLower.includes("spotify") || + appNameLower.includes("vlc") || + appNameLower.includes("mpv") || + appNameLower.includes("music") || + appNameLower.includes("player") || + appNameLower.includes("youtube") || + appNameLower.includes("media")) { + return true; + } + + // Check for media-related summary text + if (summaryLower.includes("now playing") || + summaryLower.includes("playing") || + summaryLower.includes("paused") || + summaryLower.includes("track")) { + return true; + } + + // Check for media actions + for (const action of actions) { + const actionId = action.identifier.toLowerCase(); + if (actionId.includes("play") || + actionId.includes("pause") || + actionId.includes("next") || + actionId.includes("previous") || + actionId.includes("media")) { + return true; + } + } + + return false; + } + + function isSystemNotification() { + const appNameLower = appName.toLowerCase(); + const summaryLower = summary.toLowerCase(); + + // Check for system apps + if (appNameLower.includes("system") || + appNameLower.includes("networkmanager") || + appNameLower.includes("upower") || + appNameLower.includes("notification-daemon") || + appNameLower.includes("systemd") || + appNameLower.includes("update") || + appNameLower.includes("battery") || + appNameLower.includes("network") || + appNameLower.includes("wifi") || + appNameLower.includes("bluetooth")) { + return true; + } + + // Check for system-related summary text + if (summaryLower.includes("battery") || + summaryLower.includes("power") || + summaryLower.includes("update") || + summaryLower.includes("connected") || + summaryLower.includes("disconnected") || + summaryLower.includes("network") || + summaryLower.includes("wifi") || + summaryLower.includes("bluetooth")) { + return true; + } + + return false; + } readonly property Timer timer: Timer { running: wrapper.popup - interval: wrapper.notification.expireTimeout > 0 ? wrapper.notification.expireTimeout : 5000 // 5 second default + interval: { + if (wrapper.notification.expireTimeout > 0) { + return wrapper.notification.expireTimeout * 1000; + } + return NotificationSettings.getAppTimeout(wrapper.appName); + } onTriggered: { wrapper.popup = false; } @@ -147,11 +263,53 @@ Singleton { wrapper.notification.dismiss(); } + function handleMediaNotification(newMediaWrapper) { + const groupKey = getGroupKey(newMediaWrapper); + + // Find and replace any existing media notification from the same app + for (let i = notifications.length - 1; i >= 0; i--) { + const existing = notifications[i]; + if (existing.isMedia && getGroupKey(existing) === groupKey) { + // Replace the existing media notification + existing.notification.dismiss(); + break; + } + } + + // Add the new media notification + root.notifications.push(newMediaWrapper); + } // Android 16-style notification grouping functions function getGroupKey(wrapper) { const appName = wrapper.appName.toLowerCase(); + // Media notifications: replace previous media notification from same app + if (wrapper.isMedia) { + return `${appName}:media`; + } + + // System notifications: group by category + if (wrapper.isSystem) { + const summary = wrapper.summary.toLowerCase(); + + if (summary.includes("battery") || summary.includes("power")) { + return "system:battery"; + } + if (summary.includes("network") || summary.includes("wifi") || summary.includes("connected") || summary.includes("disconnected")) { + return "system:network"; + } + if (summary.includes("update") || summary.includes("upgrade")) { + return "system:updates"; + } + if (summary.includes("bluetooth")) { + return "system:bluetooth"; + } + + // Default system grouping + return "system:general"; + } + // Conversation apps with inline reply if (wrapper.isConversation) { const summary = wrapper.summary.toLowerCase(); @@ -173,8 +331,6 @@ Singleton { return `${appName}:conversation`; } - - // Default: Group by app return appName; } @@ -192,7 +348,9 @@ Singleton { latestNotification: null, count: 0, hasInlineReply: false, - isConversation: notif.isConversation + isConversation: notif.isConversation, + isMedia: notif.isMedia, + isSystem: notif.isSystem }; } @@ -223,7 +381,9 @@ Singleton { latestNotification: null, count: 0, hasInlineReply: false, - isConversation: notif.isConversation + isConversation: notif.isConversation, + isMedia: notif.isMedia, + isSystem: notif.isSystem }; } @@ -274,6 +434,25 @@ Singleton { return group.latestNotification.summary; } + if (group.isMedia) { + return "Now Playing"; + } + + if (group.isSystem) { + const keyParts = group.key.split(":"); + if (keyParts.length > 1) { + const systemCategory = keyParts[1]; + switch (systemCategory) { + case "battery": return `${group.count} Battery alerts`; + case "network": return `${group.count} Network updates`; + case "updates": return `${group.count} System updates`; + case "bluetooth": return `${group.count} Bluetooth updates`; + default: return `${group.count} System notifications`; + } + } + return `${group.count} System notifications`; + } + if (group.isConversation) { const keyParts = group.key.split(":"); if (keyParts.length > 1) { @@ -285,7 +464,6 @@ Singleton { return `${group.count} new messages`; } - return `${group.count} notifications`; } @@ -294,6 +472,18 @@ Singleton { return group.latestNotification.body; } + if (group.isMedia) { + const latest = group.latestNotification; + if (latest.body && latest.body.length > 0) { + return latest.body; + } + return latest.summary; + } + + if (group.isSystem) { + return `Latest: ${group.latestNotification.summary}`; + } + if (group.isConversation) { const latest = group.latestNotification; if (latest.body && latest.body.length > 0) { @@ -302,8 +492,70 @@ Singleton { return "Tap to view conversation"; } - - return `Latest: ${group.latestNotification.summary}`; } + + // Notification persistence functions + function addToPersistentStorage(wrapper) { + const persistedNotif = { + id: wrapper.notification.id, + appName: wrapper.appName, + summary: wrapper.summary, + body: wrapper.body, + appIcon: wrapper.appIcon, + image: wrapper.image, + urgency: wrapper.urgency, + timestamp: wrapper.time.getTime(), + isConversation: wrapper.isConversation, + isMedia: wrapper.isMedia, + isSystem: wrapper.isSystem + }; + + // Add to beginning of array + persistedNotifications.unshift(persistedNotif); + + // Clean up old notifications + cleanupPersistentStorage(); + } + + function cleanupPersistentStorage() { + const now = new Date().getTime(); + let newPersisted = []; + + for (let i = 0; i < persistedNotifications.length && i < maxStoredNotifications; i++) { + const notif = persistedNotifications[i]; + if (now - notif.timestamp < maxNotificationAge) { + newPersisted.push(notif); + } + } + + persistedNotifications = newPersisted; + } + + function getPersistentNotificationsByApp(appName) { + return persistedNotifications.filter(notif => notif.appName.toLowerCase() === appName.toLowerCase()); + } + + function getPersistentNotificationsByType(type) { + switch (type) { + case "conversation": return persistedNotifications.filter(notif => notif.isConversation); + case "media": return persistedNotifications.filter(notif => notif.isMedia); + case "system": return persistedNotifications.filter(notif => notif.isSystem); + default: return persistedNotifications; + } + } + + function searchPersistentNotifications(query) { + const searchLower = query.toLowerCase(); + return persistedNotifications.filter(notif => + notif.appName.toLowerCase().includes(searchLower) || + notif.summary.toLowerCase().includes(searchLower) || + notif.body.toLowerCase().includes(searchLower) + ); + } + + // Initialize persistence on component creation + Component.onCompleted: { + cleanupPersistentStorage(); + } } \ No newline at end of file diff --git a/Services/NotificationSettings.qml b/Services/NotificationSettings.qml new file mode 100644 index 00000000..b0fc75f8 --- /dev/null +++ b/Services/NotificationSettings.qml @@ -0,0 +1,190 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell + +Singleton { + id: root + + // General notification settings + property bool notificationsEnabled: true + property bool soundEnabled: true + property bool persistNotifications: true + property int defaultTimeout: 5000 // milliseconds + + // Grouping settings + property bool enableSmartGrouping: true + property bool autoExpandConversations: true + property bool replaceMediaNotifications: true + + // Persistence settings + property int maxStoredNotifications: 100 + property int notificationRetentionDays: 7 + + // Display settings + property bool showNotificationPopups: true + property bool showAppIcons: true + property bool showTimestamps: true + property bool enableInlineReply: true + property bool showActionButtons: true + + // Priority settings + property bool allowCriticalNotifications: true + property bool respectDoNotDisturb: true + + // App-specific settings + property var appSettings: ({}) + + // Do Not Disturb settings + property bool doNotDisturbMode: false + property string doNotDisturbStart: "22:00" + property string doNotDisturbEnd: "08:00" + property bool allowCriticalInDND: true + + // Sound settings + property string notificationSound: "default" + property real soundVolume: 0.7 + property bool vibrationEnabled: false + + function getAppSetting(appName, setting, defaultValue) { + const app = appSettings[appName.toLowerCase()]; + if (app && app.hasOwnProperty(setting)) { + return app[setting]; + } + return defaultValue; + } + + function setAppSetting(appName, setting, value) { + let newAppSettings = {}; + for (const app in appSettings) { + newAppSettings[app] = appSettings[app]; + } + + const appKey = appName.toLowerCase(); + if (!newAppSettings[appKey]) { + newAppSettings[appKey] = {}; + } + newAppSettings[appKey][setting] = value; + appSettings = newAppSettings; + + // Save to persistent storage + saveSettings(); + } + + function isAppBlocked(appName) { + return getAppSetting(appName, "blocked", false); + } + + function isAppMuted(appName) { + return getAppSetting(appName, "muted", false); + } + + function getAppTimeout(appName) { + return getAppSetting(appName, "timeout", defaultTimeout); + } + + function isInDoNotDisturbMode() { + if (!doNotDisturbMode && !respectDoNotDisturb) { + return false; + } + + const now = new Date(); + const currentTime = now.getHours() * 60 + now.getMinutes(); + + const startParts = doNotDisturbStart.split(":"); + const endParts = doNotDisturbEnd.split(":"); + const startTime = parseInt(startParts[0]) * 60 + parseInt(startParts[1]); + const endTime = parseInt(endParts[0]) * 60 + parseInt(endParts[1]); + + if (startTime <= endTime) { + // Same day range (e.g., 9:00 - 17:00) + return currentTime >= startTime && currentTime <= endTime; + } else { + // Overnight range (e.g., 22:00 - 08:00) + return currentTime >= startTime || currentTime <= endTime; + } + } + + function shouldShowNotification(notification) { + // Check if notifications are globally disabled + if (!notificationsEnabled) { + return false; + } + + // Check if app is blocked + if (isAppBlocked(notification.appName)) { + return false; + } + + // Check Do Not Disturb mode + if (isInDoNotDisturbMode()) { + // Allow critical notifications if configured + if (allowCriticalInDND && notification.urgency === 2) { + return true; + } + return false; + } + + return true; + } + + function shouldPlaySound(notification) { + if (!soundEnabled) { + return false; + } + + if (isAppMuted(notification.appName)) { + return false; + } + + if (isInDoNotDisturbMode() && !allowCriticalInDND) { + return false; + } + + return true; + } + + function saveSettings() { + // In a real implementation, this would save to a config file + console.log("NotificationSettings: Settings saved"); + } + + function loadSettings() { + // In a real implementation, this would load from a config file + console.log("NotificationSettings: Settings loaded"); + } + + function resetToDefaults() { + notificationsEnabled = true; + soundEnabled = true; + persistNotifications = true; + defaultTimeout = 5000; + enableSmartGrouping = true; + autoExpandConversations = true; + replaceMediaNotifications = true; + maxStoredNotifications = 100; + notificationRetentionDays = 7; + showNotificationPopups = true; + showAppIcons = true; + showTimestamps = true; + enableInlineReply = true; + showActionButtons = true; + allowCriticalNotifications = true; + respectDoNotDisturb = true; + doNotDisturbMode = false; + doNotDisturbStart = "22:00"; + doNotDisturbEnd = "08:00"; + allowCriticalInDND = true; + notificationSound = "default"; + soundVolume = 0.7; + vibrationEnabled = false; + appSettings = {}; + + saveSettings(); + } + + Component.onCompleted: { + loadSettings(); + } +} \ No newline at end of file diff --git a/verify-notifications.sh b/verify-notifications.sh index 36cfa89f..3aeb0cca 100755 --- a/verify-notifications.sh +++ b/verify-notifications.sh @@ -1,45 +1,90 @@ #!/bin/bash -echo "Waiting for notification service to be ready..." +# Enhanced Notification System Test Script with Common Icons +# Uses icons that are more likely to be available on most systems -# Wait for the notification service to be available -max_attempts=8 -attempt=0 +echo "🔔 Testing Enhanced Notification System Features" +echo "=============================================================" -while [ $attempt -lt $max_attempts ]; do - if notify-send -a "test" "Service Ready Test" "Testing..." 2>/dev/null; then - echo "Notification service is ready!" - break - fi - echo "Attempt $((attempt + 1))/$max_attempts - waiting..." - sleep 2 - attempt=$((attempt + 1)) -done - -if [ $attempt -eq $max_attempts ]; then - echo "Timeout waiting for notification service" - exit 1 +# Check what icons are available +echo "Checking available icons..." +if [ -d "~/.local/share/icons/Papirus" ]; then + echo "✓ Icon theme found" + ICON_BASE="~/.local/share/icons/Papirus" +else + echo "! Using fallback icons" + ICON_BASE="" fi -echo "" -echo "Running layout and functionality tests..." -echo "" +# Test 1: Basic notifications +echo "📱 Test 1: Basic notifications" +notify-send -i preferences-desktop "Test App" "Basic notification message" +sleep 2 -# Now run the actual tests -notify-send -a "firefox" "Test 1" "Firefox notification 1" -sleep 0.5 -notify-send -a "firefox" "Test 2" "Firefox notification 2" +# Test 2: Media notifications (should replace each other) +echo "🎵 Test 2: Media notifications (replacement behavior)" +notify-send -i audio-x-generic "Spotify" "Now Playing: Song 1 - Artist A" +sleep 2 +notify-send -i audio-x-generic "Spotify" "Now Playing: Song 2 - Artist B" +sleep 2 + +# Test 3: System notifications (grouped by category) +echo "🔋 Test 3: System notifications (grouped by category)" +notify-send -i battery "UPower" "Battery Low: 15% remaining" sleep 1 - -notify-send -a "MyCustomApp" "Custom 1" "Custom app notification 1" -sleep 0.5 -notify-send -a "MyCustomApp" "Custom 2" "Custom app notification 2" +notify-send -i network-wired "NetworkManager" "Network Connected: WiFi connected" sleep 1 +notify-send -i system-software-update "System" "Updates Available: 5 packages can be updated" +sleep 2 -notify-send -a "code" "VS Code 1" "Code notification 1" +# Test 4: Conversation notifications (should group and auto-expand) +echo "💬 Test 4: Conversation notifications (grouping)" +if command -v discord &> /dev/null; then + notify-send -i discord "Discord" "#general: User1 says Hello everyone!" + sleep 1 + notify-send -i discord "Discord" "#general: User2 says Hey there!" + sleep 1 + notify-send -i discord "Discord" "john_doe: Private message from John" +else + notify-send -i internet-chat "Discord" "#general: User1 says Hello everyone!" + sleep 1 + notify-send -i internet-chat "Discord" "#general: User2 says Hey there!" + sleep 1 + notify-send -i internet-chat "Discord" "john_doe: Private message from John" +fi +sleep 2 + +# Test 5: Urgent notifications +echo "🚨 Test 5: Urgent notifications" +notify-send -u critical -i dialog-warning "Critical Alert" "System overheating detected - Temperature: 85°C" +sleep 2 + +# Test 6: Notifications with actions (simulated) +echo "⚡ Test 6: Action buttons" +notify-send -i system-upgrade "System Update" "Updates available - Click to install or remind later" +sleep 2 + +# Test 7: Multiple apps generating notifications +echo "📊 Test 7: Multiple apps" +notify-send -i mail-message-new "Email" "You have 3 new emails" sleep 0.5 -notify-send -a "code" "VS Code 2" "Code notification 2" +notify-send -i office-calendar "Calendar" "Daily standup in 5 minutes" +sleep 0.5 +notify-send -i folder-downloads "File Manager" "document.pdf downloaded" +sleep 2 echo "" -echo "✅ All notifications sent successfully!" +echo "✅ Notification tests completed!" echo "" +echo "📋 Enhanced Features Tested:" +echo " • Media notification replacement" +echo " • System notification grouping" +echo " • Conversation grouping and auto-expansion" +echo " • Urgency level handling" +echo " • Action button support" +echo " • Multi-app notification handling" +echo "" +echo "🎯 Check your notification popup and notification center to see the results!" +echo "" +echo "Note: Some icons may show as fallback (checkerboard) if icon themes aren't installed." +echo "To install more icons: sudo pacman -S papirus-icon-theme adwaita-icon-theme" \ No newline at end of file