From 2b04f8a9102702fe98d4acbe02421ea147ad2419 Mon Sep 17 00:00:00 2001 From: Ly-sec Date: Sat, 8 Nov 2025 14:06:16 +0100 Subject: [PATCH] NotificationService: implement (#673) replacing functionality for notifications --- Bin/test-notification-replace.sh | 23 ++ Services/NotificationService.qml | 381 ++++++++++++++++++++----------- 2 files changed, 276 insertions(+), 128 deletions(-) create mode 100755 Bin/test-notification-replace.sh diff --git a/Bin/test-notification-replace.sh b/Bin/test-notification-replace.sh new file mode 100755 index 00000000..50fcf1ae --- /dev/null +++ b/Bin/test-notification-replace.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Test script for notification replacement functionality + +echo "Testing notification replacement..." +echo "" + +# Send initial notification and capture the ID +echo "Step 1: Sending initial notification 'asdf'..." +NOTIF_ID=$(notify-send -p "asdf") +echo "Notification ID: $NOTIF_ID" +echo "" + +# Wait a moment for the notification to appear +sleep 1 + +# Replace the notification +echo "Step 2: Replacing notification $NOTIF_ID with 'test'..." +notify-send -r "$NOTIF_ID" -p "test" +echo "" + +echo "The notification should now show 'test' instead of 'asdf'." +echo "If it still shows 'asdf', the replacement is not working." + diff --git a/Services/NotificationService.qml b/Services/NotificationService.qml index 46635e0c..706e55fd 100644 --- a/Services/NotificationService.qml +++ b/Services/NotificationService.qml @@ -26,12 +26,10 @@ Singleton { property ListModel historyList: ListModel {} // Internal state - property var activeMap: ({}) + property var activeNotifications: ({}) // Maps internal ID to {notification, watcher, metadata} + property var quickshellIdToInternalId: ({}) property var imageQueue: [] - // Performance optimization: Track notification metadata separately - property var notificationMetadata: ({}) // Stores timestamp and duration for each notification - PanelWindow { implicitHeight: 1 implicitWidth: 1 @@ -76,7 +74,7 @@ Singleton { } } - // Notification server - only created when notifications are enabled + // Notification server property var notificationServerLoader: null Component { @@ -89,14 +87,43 @@ Singleton { } } + Component { + id: notificationWatcherComponent + Connections { + property var targetNotification + property var targetDataId + target: targetNotification + + function onSummaryChanged() { + updateNotificationFromObject(targetDataId) + } + function onBodyChanged() { + updateNotificationFromObject(targetDataId) + } + function onAppNameChanged() { + updateNotificationFromObject(targetDataId) + } + function onUrgencyChanged() { + updateNotificationFromObject(targetDataId) + } + function onAppIconChanged() { + updateNotificationFromObject(targetDataId) + } + function onImageChanged() { + updateNotificationFromObject(targetDataId) + } + function onActionsChanged() { + updateNotificationFromObject(targetDataId) + } + } + } + function updateNotificationServer() { - // Destroy existing server if it exists if (notificationServerLoader) { notificationServerLoader.destroy() notificationServerLoader = null } - // Create server only if enabled if (Settings.isLoaded && Settings.data.notifications.enabled !== false) { notificationServerLoader = notificationServerComponent.createObject(root) } @@ -118,7 +145,7 @@ Singleton { } } - // Helper function to generate content-based ID for deduplication (without timestamp) + // Helper function to generate content-based ID for deduplication function getContentId(summary, body, appName) { return Checksum.sha256(JSON.stringify({ "summary": summary || "", @@ -129,69 +156,126 @@ Singleton { // Main handler function handleNotification(notification) { + const quickshellId = notification.id const data = createData(notification) addToHistory(data) if (Settings.data.notifications?.doNotDisturb || PowerProfileService.noctaliaPerformanceMode) return - // Check for duplicate notification (same content) - const normalizedAppName = getAppName(notification.appName || notification.desktopEntry || "") - const contentId = getContentId(notification.summary || "", notification.body || "", normalizedAppName) - let duplicateIndex = -1 - for (var i = 0; i < activeList.count; i++) { - const existing = activeList.get(i) - const existingContentId = getContentId(existing.summary || "", existing.body || "", existing.appName || "") - if (existingContentId === contentId) { - duplicateIndex = i - break - } + // Check if this is a replacement notification + const existingInternalId = quickshellIdToInternalId[quickshellId] + if (existingInternalId && activeNotifications[existingInternalId]) { + updateExistingNotification(existingInternalId, notification, data) + return } - // If duplicate found, remove the old one - if (duplicateIndex >= 0) { - const oldNotif = activeList.get(duplicateIndex) - activeMap[oldNotif.id]?.dismiss() - removeActive(oldNotif.id) + // Check for duplicate content + const duplicateId = findDuplicateNotification(data) + if (duplicateId) { + removeNotification(duplicateId) } - activeMap[data.id] = notification + // Add new notification + addNewNotification(quickshellId, notification, data) + } + + function updateExistingNotification(internalId, notification, data) { + const index = findNotificationIndex(internalId) + if (index < 0) + return + + const existing = activeList.get(index) + const oldTimestamp = existing.timestamp + const oldProgress = existing.progress + + // Update properties (keeping original timestamp and progress) + activeList.setProperty(index, "summary", data.summary) + activeList.setProperty(index, "body", data.body) + activeList.setProperty(index, "appName", data.appName) + activeList.setProperty(index, "urgency", data.urgency) + activeList.setProperty(index, "expireTimeout", data.expireTimeout) + activeList.setProperty(index, "originalImage", data.originalImage) + activeList.setProperty(index, "cachedImage", data.cachedImage) + activeList.setProperty(index, "actionsJson", data.actionsJson) + activeList.setProperty(index, "timestamp", oldTimestamp) + activeList.setProperty(index, "progress", oldProgress) + + // Update stored notification object + const notifData = activeNotifications[internalId] + notifData.notification = notification notification.tracked = true - notification.closed.connect(() => removeActive(data.id)) + notification.closed.connect(() => removeNotification(internalId)) - // Store metadata for efficient progress calculation - const durations = [Settings.data.notifications?.lowUrgencyDuration * 1000 || 3000, Settings.data.notifications?.normalUrgencyDuration * 1000 || 8000, Settings.data.notifications?.criticalUrgencyDuration * 1000 || 15000] + // Update metadata + notifData.metadata.urgency = data.urgency + notifData.metadata.duration = calculateDuration(data) + } - let expire = 0 - if (Settings.data.notifications?.respectExpireTimeout) { - if (data.expireTimeout === 0) { - expire = -1 // Never expire - } else if (data.expireTimeout > 0) { - expire = data.expireTimeout - } else { - expire = durations[data.urgency] + function addNewNotification(quickshellId, notification, data) { + // Map IDs + quickshellIdToInternalId[quickshellId] = data.id + + // Create watcher + const watcher = notificationWatcherComponent.createObject(root, { + "targetNotification": notification, + "targetDataId": data.id + }) + + // Store notification data + activeNotifications[data.id] = { + "notification": notification, + "watcher": watcher, + "metadata": { + "timestamp": data.timestamp.getTime(), + "duration": calculateDuration(data), + "urgency": data.urgency, + "paused": false, + "pauseTime": 0 } - } else { - expire = durations[data.urgency] } - notificationMetadata[data.id] = { - "timestamp": data.timestamp.getTime(), - "duration": expire, - "urgency": data.urgency, - "paused": false, - "pauseTime": 0 - } + notification.tracked = true + notification.closed.connect(() => removeNotification(data.id)) + // Add to list activeList.insert(0, data) + + // Remove overflow while (activeList.count > maxVisible) { const last = activeList.get(activeList.count - 1) - activeMap[last.id]?.dismiss() + activeNotifications[last.id]?.notification?.dismiss() activeList.remove(activeList.count - 1) - delete notificationMetadata[last.id] + cleanupNotification(last.id) } } + function findDuplicateNotification(data) { + const contentId = getContentId(data.summary, data.body, data.appName) + + for (var i = 0; i < activeList.count; i++) { + const existing = activeList.get(i) + const existingContentId = getContentId(existing.summary, existing.body, existing.appName) + if (existingContentId === contentId) { + return existing.id + } + } + return null + } + + function calculateDuration(data) { + const durations = [Settings.data.notifications?.lowUrgencyDuration * 1000 || 3000, Settings.data.notifications?.normalUrgencyDuration * 1000 || 8000, Settings.data.notifications?.criticalUrgencyDuration * 1000 || 15000] + + if (Settings.data.notifications?.respectExpireTimeout) { + if (data.expireTimeout === 0) + return -1 // Never expire + if (data.expireTimeout > 0) + return data.expireTimeout + } + + return durations[data.urgency] + } + function createData(n) { const time = new Date() const id = Checksum.sha256(JSON.stringify({ @@ -207,7 +291,7 @@ Singleton { return { "id": id, - "summary": (n.summary || ""), + "summary": n.summary || "", "body": stripTags(n.body || ""), "appName": getAppName(n.appName || n.desktopEntry || ""), "urgency": n.urgency < 0 || n.urgency > 2 ? 1 : n.urgency, @@ -223,6 +307,104 @@ Singleton { } } + function findNotificationIndex(internalId) { + for (var i = 0; i < activeList.count; i++) { + if (activeList.get(i).id === internalId) { + return i + } + } + return -1 + } + + function updateNotificationFromObject(internalId) { + const notifData = activeNotifications[internalId] + if (!notifData) + return + + const index = findNotificationIndex(internalId) + if (index < 0) + return + + const data = createData(notifData.notification) + const existing = activeList.get(index) + + // Update properties (keeping timestamp and progress) + activeList.setProperty(index, "summary", data.summary) + activeList.setProperty(index, "body", data.body) + activeList.setProperty(index, "appName", data.appName) + activeList.setProperty(index, "urgency", data.urgency) + activeList.setProperty(index, "expireTimeout", data.expireTimeout) + activeList.setProperty(index, "originalImage", data.originalImage) + activeList.setProperty(index, "cachedImage", data.cachedImage) + activeList.setProperty(index, "actionsJson", data.actionsJson) + + // Update metadata + notifData.metadata.urgency = data.urgency + notifData.metadata.duration = calculateDuration(data) + } + + function removeNotification(id) { + const index = findNotificationIndex(id) + if (index >= 0) { + activeList.remove(index) + } + cleanupNotification(id) + } + + function cleanupNotification(id) { + const notifData = activeNotifications[id] + if (notifData) { + notifData.watcher?.destroy() + delete activeNotifications[id] + } + + // Clean up quickshell ID mapping + for (const qsId in quickshellIdToInternalId) { + if (quickshellIdToInternalId[qsId] === id) { + delete quickshellIdToInternalId[qsId] + break + } + } + } + + // Progress updates + Timer { + interval: 50 + repeat: true + running: activeList.count > 0 + onTriggered: updateAllProgress() + } + + function updateAllProgress() { + const now = Date.now() + const toRemove = [] + + for (var i = 0; i < activeList.count; i++) { + const notif = activeList.get(i) + const notifData = activeNotifications[notif.id] + if (!notifData) + continue + + const meta = notifData.metadata + if (meta.duration === -1 || meta.paused) + continue + + const elapsed = now - meta.timestamp + const progress = Math.max(1.0 - (elapsed / meta.duration), 0.0) + + if (progress <= 0) { + toRemove.push(notif.id) + } else if (Math.abs(notif.progress - progress) > 0.005) { + activeList.setProperty(i, "progress", progress) + } + } + + if (toRemove.length > 0) { + animateAndRemove(toRemove[0]) + } + } + + // Image handling function queueImage(path, imageId) { if (!path || !path.startsWith("image://") || !imageId) return @@ -259,64 +441,6 @@ Singleton { } } - function removeActive(id) { - for (var i = 0; i < activeList.count; i++) { - if (activeList.get(i).id === id) { - activeList.remove(i) - delete activeMap[id] - delete notificationMetadata[id] - break - } - } - } - - // Optimized batch progress update - Timer { - interval: 50 // Reduced from 10ms to 50ms (20 updates/sec instead of 100) - repeat: true - running: activeList.count > 0 - onTriggered: updateAllProgress() - } - - function updateAllProgress() { - const now = Date.now() - const toRemove = [] - const updates = [] // Batch updates - - // Collect all updates first - for (var i = 0; i < activeList.count; i++) { - const notif = activeList.get(i) - const meta = notificationMetadata[notif.id] - - if (!meta || meta.duration === -1 || meta.paused) - continue - - // Skip infinite notifications - const elapsed = now - meta.timestamp - const progress = Math.max(1.0 - (elapsed / meta.duration), 0.0) - - if (progress <= 0) { - toRemove.push(notif.id) - } else if (Math.abs(notif.progress - progress) > 0.005) { - // Only update if change is significant - updates.push({ - "index": i, - "progress": progress - }) - } - } - - // Apply batch updates - for (const update of updates) { - activeList.setProperty(update.index, "progress", update.progress) - } - - // Remove expired notifications (one at a time to allow animation) - if (toRemove.length > 0) { - animateAndRemove(toRemove[0]) - } - } - // History management function addToHistory(data) { historyList.insert(0, data) @@ -348,7 +472,7 @@ Singleton { } } - // Persistence - State (lastSeenTs, etc.) + // Persistence - State FileView { id: stateFileView path: stateFile @@ -425,7 +549,6 @@ Singleton { try { root.lastSeenTs = stateAdapter.lastSeenTs || 0 - // Migration: if state file is empty but settings has lastSeenTs, migrate it if (root.lastSeenTs === 0 && Settings.data.notifications && Settings.data.notifications.lastSeenTs) { root.lastSeenTs = Settings.data.notifications.lastSeenTs saveState() @@ -450,10 +573,10 @@ Singleton { saveState() } + // Utility functions function getAppName(name) { if (!name || name.trim() === "") return "Unknown" - name = name.trim() if (name.includes(".") && (name.startsWith("com.") || name.startsWith("org.") || name.startsWith("io.") || name.startsWith("net."))) { @@ -464,9 +587,8 @@ Singleton { appPart = parts[parts.length - 2] || parts[0] } - if (appPart) { + if (appPart) name = appPart - } } if (name.includes(".")) { @@ -524,25 +646,25 @@ Singleton { } function pauseTimeout(id) { - const meta = notificationMetadata[id] - if (meta && !meta.paused) { - meta.paused = true - meta.pauseTime = Date.now() + const notifData = activeNotifications[id] + if (notifData && !notifData.metadata.paused) { + notifData.metadata.paused = true + notifData.metadata.pauseTime = Date.now() } } function resumeTimeout(id) { - const meta = notificationMetadata[id] - if (meta && meta.paused) { - meta.timestamp += Date.now() - meta.pauseTime - meta.paused = false + const notifData = activeNotifications[id] + if (notifData && notifData.metadata.paused) { + notifData.metadata.timestamp += Date.now() - notifData.metadata.pauseTime + notifData.metadata.paused = false } } // Public API function dismissActiveNotification(id) { - activeMap[id]?.dismiss() - removeActive(id) + activeNotifications[id]?.notification?.dismiss() + removeNotification(id) } function dismissOldestActive() { @@ -553,18 +675,21 @@ Singleton { } function dismissAllActive() { - Object.values(activeMap).forEach(n => n.dismiss()) + for (const id in activeNotifications) { + activeNotifications[id].notification?.dismiss() + activeNotifications[id].watcher?.destroy() + } activeList.clear() - activeMap = {} - notificationMetadata = {} + activeNotifications = {} + quickshellIdToInternalId = {} } function invokeAction(id, actionId) { - const n = activeMap[id] - if (!n?.actions) + const notifData = activeNotifications[id] + if (!notifData?.notification?.actions) return false - for (const action of n.actions) { + for (const action of notifData.notification.actions) { if (action.identifier === actionId && action.invoke) { action.invoke() return true @@ -599,7 +724,7 @@ Singleton { saveHistory() } - // Signals & connections + // Signals signal animateAndRemove(string notificationId) Connections {