From 10534b46f95ea81000f9895114995f4215da1787 Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sat, 20 Sep 2025 16:40:36 -0400 Subject: [PATCH 01/10] test-notif: changed debian-logo to steam, as I don't have a debian logo --- Bin/test-notifications.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bin/test-notifications.sh b/Bin/test-notifications.sh index 5e677c49..7619f621 100755 --- a/Bin/test-notifications.sh +++ b/Bin/test-notifications.sh @@ -18,7 +18,7 @@ if command -v notify-send >/dev/null 2>&1; then notify-send -i dialog-information "Icon name test" "Should resolve from theme (dialog-information)" # 2) Absolute path if a sample image exists - SAMPLE_IMG="/usr/share/pixmaps/debian-logo.png" + SAMPLE_IMG="/usr/share/pixmaps/steam.png" if [ -f "$SAMPLE_IMG" ]; then notify-send -i "$SAMPLE_IMG" "Absolute path test" "Should show the provided image path" fi From aed7440c5bbd88bc5ac67fe004ad0b6945e73cbc Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sat, 20 Sep 2025 17:23:49 -0400 Subject: [PATCH 02/10] Center Fallback icon --- Widgets/NImageCircled.qml | 1 + Widgets/NImageRounded.qml | 1 + 2 files changed, 2 insertions(+) diff --git a/Widgets/NImageCircled.qml b/Widgets/NImageCircled.qml index 61190ea7..c1ed5aa0 100644 --- a/Widgets/NImageCircled.qml +++ b/Widgets/NImageCircled.qml @@ -54,6 +54,7 @@ Rectangle { // Fallback icon Loader { active: fallbackIcon !== undefined && fallbackIcon !== "" && (imagePath === undefined || imagePath === "") + anchors.centerIn: parent sourceComponent: NIcon { anchors.centerIn: parent icon: fallbackIcon diff --git a/Widgets/NImageRounded.qml b/Widgets/NImageRounded.qml index b1950f39..d03dcf1d 100644 --- a/Widgets/NImageRounded.qml +++ b/Widgets/NImageRounded.qml @@ -74,6 +74,7 @@ Rectangle { // Fallback icon Loader { active: fallbackIcon !== undefined && fallbackIcon !== "" && (imagePath === undefined || imagePath === "") + anchors.centerIn: parent sourceComponent: NIcon { anchors.centerIn: parent icon: fallbackIcon From 1ad6969d9b14cd019a6761764744f55e3e6e4dfc Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sat, 20 Sep 2025 18:04:34 -0400 Subject: [PATCH 03/10] Notification service: Full refactoring to support image caching for history. --- ...notifications.sh => notifications-test.sh} | 22 +- Commons/Settings.qml | 2 + Modules/Bar/Widgets/NotificationHistory.qml | 2 +- Modules/Notification/Notification.qml | 195 +++-- .../Notification/NotificationHistoryPanel.qml | 115 ++- .../Bar/WidgetSettings/ClockSettings.qml | 1 - Services/NotificationService.qml | 801 ++++++++++++------ Widgets/NButton.qml | 6 +- Widgets/NColorPickerDialog.qml | 4 - Widgets/NImageCircled.qml | 2 +- Widgets/NSlider.qml | 2 +- Widgets/NValueSlider.qml | 2 +- 12 files changed, 731 insertions(+), 423 deletions(-) rename Bin/{test-notifications.sh => notifications-test.sh} (56%) diff --git a/Bin/test-notifications.sh b/Bin/notifications-test.sh similarity index 56% rename from Bin/test-notifications.sh rename to Bin/notifications-test.sh index 7619f621..6b6a1458 100755 --- a/Bin/test-notifications.sh +++ b/Bin/notifications-test.sh @@ -1,10 +1,10 @@ #!/usr/bin/env -S bash -echo "Sending 8 test notifications..." +echo "Sending test notifications..." -# Send 8 notifications with numbers -for i in {1..8}; do - notify-send "Notification $i" "This is test notification number $i of 8" +# Send a bunch of notifications with numbers +for i in {1..4}; do + notify-send "Notification $i" "This is test notification number $i with a very long text that will probably break the layout or maybe not? Who knows?" sleep 1 done @@ -30,3 +30,17 @@ if command -v notify-send >/dev/null 2>&1; then echo "Icon/image tests sent!" fi + +# A test notification with actions +gdbus call --session \ + --dest org.freedesktop.Notifications \ + --object-path /org/freedesktop/Notifications \ + --method org.freedesktop.Notifications.Notify \ + "my-app" \ + 0 \ + "dialog-question" \ + "Confirmation Required" \ + "Do you want to proceed with the action?" \ + "['default', 'OK', 'cancel', 'Cancel']" \ + "{}" \ + 5000 \ No newline at end of file diff --git a/Commons/Settings.qml b/Commons/Settings.qml index 4e662457..ea7a2f38 100644 --- a/Commons/Settings.qml +++ b/Commons/Settings.qml @@ -16,6 +16,7 @@ Singleton { property string configDir: Quickshell.env("NOCTALIA_CONFIG_DIR") || (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + "/" + shellName + "/" property string cacheDir: Quickshell.env("NOCTALIA_CACHE_DIR") || (Quickshell.env("XDG_CACHE_HOME") || Quickshell.env("HOME") + "/.cache") + "/" + shellName + "/" property string cacheDirImages: cacheDir + "images/" + property string cacheDirImagesNotifications: cacheDir + "images/notifications/" property string settingsFile: Quickshell.env("NOCTALIA_SETTINGS_FILE") || (configDir + "settings.json") @@ -203,6 +204,7 @@ Singleton { Quickshell.execDetached(["mkdir", "-p", configDir]) Quickshell.execDetached(["mkdir", "-p", cacheDir]) Quickshell.execDetached(["mkdir", "-p", cacheDirImages]) + Quickshell.execDetached(["mkdir", "-p", cacheDirImagesNotifications]) // Mark directories as created and trigger file loading directoriesCreated = true diff --git a/Modules/Bar/Widgets/NotificationHistory.qml b/Modules/Bar/Widgets/NotificationHistory.qml index 38883d02..1fe5020e 100644 --- a/Modules/Bar/Widgets/NotificationHistory.qml +++ b/Modules/Bar/Widgets/NotificationHistory.qml @@ -39,7 +39,7 @@ NIconButton { function computeUnreadCount() { var since = lastSeenTs() var count = 0 - var model = NotificationService.historyModel + var model = NotificationService.notificationHistory for (var i = 0; i < model.count; i++) { var item = model.get(i) var ts = item.timestamp instanceof Date ? item.timestamp.getTime() : item.timestamp diff --git a/Modules/Notification/Notification.qml b/Modules/Notification/Notification.qml index bea8e7a4..0f389c6b 100644 --- a/Modules/Notification/Notification.qml +++ b/Modules/Notification/Notification.qml @@ -18,16 +18,13 @@ Variants { required property ShellScreen modelData readonly property real scaling: ScalingService.getScreenScale(modelData) - // Access the notification model from the service - property ListModel notificationModel: NotificationService.notificationModel - - // Track notifications being removed for animation - property var removingNotifications: ({}) + // Access the notification model from the service - UPDATED NAME + property ListModel notificationModel: NotificationService.activeNotifications // If no notification display activated in settings, then show them all - active: Settings.isLoaded && modelData && (NotificationService.notificationModel.count > 0) ? (Settings.data.notifications.monitors.includes(modelData.name) || (Settings.data.notifications.monitors.length === 0)) : false + active: Settings.isLoaded && modelData && (notificationModel.count > 0) ? (Settings.data.notifications.monitors.includes(modelData.name) || (Settings.data.notifications.monitors.length === 0)) : false - visible: (NotificationService.notificationModel.count > 0) + visible: (notificationModel.count > 0) sourceComponent: PanelWindow { screen: modelData @@ -78,26 +75,25 @@ Variants { } implicitWidth: 360 * scaling - implicitHeight: Math.min(notificationStack.implicitHeight, (NotificationService.maxVisible * 120) * scaling) - //WlrLayershell.layer: WlrLayer.Overlay + implicitHeight: notificationStack.implicitHeight WlrLayershell.exclusionMode: ExclusionMode.Ignore - // Connect to animation signal from service + // Connect to animation signal from service - UPDATED TO USE ID Component.onCompleted: { - NotificationService.animateAndRemove.connect(function (notification, index) { - // Prefer lookup by identity to avoid index mismatches + NotificationService.animateAndRemove.connect(function (notificationId, index) { + // Find the delegate by notification ID var delegate = null if (notificationStack && notificationStack.children && notificationStack.children.length > 0) { for (var i = 0; i < notificationStack.children.length; i++) { var child = notificationStack.children[i] - if (child && child.model && child.model.rawNotification === notification) { + if (child && child.notificationId === notificationId) { delegate = child break } } } - // Fallback to index if identity lookup failed + // Fallback to index if ID lookup failed if (!delegate && notificationStack && notificationStack.children && notificationStack.children[index]) { delegate = notificationStack.children[index] } @@ -105,8 +101,8 @@ Variants { if (delegate && delegate.animateOut) { delegate.animateOut() } else { - // As a last resort, force-remove without animation to avoid stuck popups - NotificationService.forceRemoveNotification(notification) + // Force removal without animation as fallback + NotificationService.removeActiveNotification(notificationId) } }) } @@ -114,7 +110,6 @@ Variants { // Main notification container ColumnLayout { id: notificationStack - // Position based on bar location - always at top anchors.top: parent.top anchors.right: (Settings.data.bar.position === "right" || Settings.data.bar.position === "top" || Settings.data.bar.position === "bottom") ? parent.right : undefined anchors.left: Settings.data.bar.position === "left" ? parent.left : undefined @@ -126,6 +121,9 @@ Variants { Repeater { model: notificationModel delegate: Rectangle { + // Store the notification ID for reference + property string notificationId: model.id + Layout.preferredWidth: 360 * scaling Layout.preferredHeight: notificationLayout.implicitHeight + (Style.marginL * 2 * scaling) Layout.maximumHeight: Layout.preferredHeight @@ -174,14 +172,14 @@ Variants { interval: Style.animationSlow repeat: false onTriggered: { - NotificationService.forceRemoveNotification(model.rawNotification) + // Use the new API method with notification ID + NotificationService.dismissActiveNotification(notificationId) } } // Check if this notification is being removed onIsRemovingChanged: { if (isRemoving) { - // Remove from model after animation completes removalTimer.start() } } @@ -191,7 +189,6 @@ Variants { NumberAnimation { duration: Style.animationSlow easing.type: Easing.OutExpo - //easing.type: Easing.OutBack looks better but notification get clipped on all sides } } @@ -209,44 +206,28 @@ Variants { anchors.rightMargin: (Style.marginM + 32) * scaling // Leave space for close button spacing: Style.marginM * scaling - // Header section with app name and timestamp - RowLayout { - Layout.fillWidth: true - spacing: Style.marginS * scaling - - NText { - text: `${(model.appName || model.desktopEntry) || "Unknown App"} · ${NotificationService.formatTimestamp(model.timestamp)}` - color: Color.mSecondary - font.pointSize: Style.fontSizeXS * scaling - } - - Rectangle { - Layout.preferredWidth: 6 * scaling - Layout.preferredHeight: 6 * scaling - radius: Style.radiusXS * scaling - color: (model.urgency === NotificationUrgency.Critical) ? Color.mError : (model.urgency === NotificationUrgency.Low) ? Color.mOnSurface : Color.mPrimary - Layout.alignment: Qt.AlignVCenter - } - - Item { - Layout.fillWidth: true - } - } - // Main content section RowLayout { Layout.fillWidth: true spacing: Style.marginM * scaling - // Image - NImageCircled { - Layout.preferredWidth: 40 * scaling - Layout.preferredHeight: 40 * scaling - Layout.alignment: Qt.AlignTop - imagePath: model.image && model.image !== "" ? model.image : "" - borderColor: Color.transparent - borderWidth: 0 - visible: (model.image && model.image !== "") + ColumnLayout { + // For real-time notification always show the original image + // as the cached version is most likely still processing. + NImageCircled { + Layout.preferredWidth: 40 * scaling + Layout.preferredHeight: 40 * scaling + Layout.alignment: Qt.AlignTop + Layout.topMargin: 30 * scaling + imagePath: model.originalImage || "" + borderColor: Color.transparent + borderWidth: 0 + fallbackIcon: "bell" + fallbackIconSize: 24 * scaling + } + Item { + Layout.fillHeight: true + } } // Text content @@ -254,6 +235,37 @@ Variants { Layout.fillWidth: true spacing: Style.marginS * scaling + // Header section with app name and timestamp + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS * scaling + + Rectangle { + Layout.preferredWidth: 6 * scaling + Layout.preferredHeight: 6 * scaling + radius: Style.radiusXS * scaling + color: { + if (model.urgency === NotificationUrgency.Critical || model.urgency === 2) + return Color.mError + else if (model.urgency === NotificationUrgency.Low || model.urgency === 0) + return Color.mOnSurface + else + return Color.mPrimary + } + Layout.alignment: Qt.AlignVCenter + } + + NText { + text: `${model.appName || "Unknown App"} · ${NotificationService.formatTimestamp(model.timestamp)}` + color: Color.mSecondary + font.pointSize: Style.fontSizeXS * scaling + } + + Item { + Layout.fillWidth: true + } + } + NText { text: model.summary || "No summary" font.pointSize: Style.fontSizeL * scaling @@ -264,6 +276,7 @@ Variants { Layout.fillWidth: true maximumLineCount: 3 elide: Text.ElideRight + visible: text.length > 0 } NText { @@ -277,50 +290,58 @@ Variants { elide: Text.ElideRight visible: text.length > 0 } - } - } - // Notification actions - RowLayout { - Layout.fillWidth: true - spacing: Style.marginS * scaling - visible: model.rawNotification && model.rawNotification.actions && model.rawNotification.actions.length > 0 + // Notification actions + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS * scaling + Layout.topMargin: Style.marginM * scaling - property var notificationActions: model.rawNotification ? model.rawNotification.actions : [] + // Store the notification ID for access in button delegates + property string parentNotificationId: notificationId - Repeater { - model: parent.notificationActions - - delegate: NButton { - text: { - var actionText = modelData.text || "Open" - // If text contains comma, take the part after the comma (the display text) - if (actionText.includes(",")) { - return actionText.split(",")[1] || actionText + // Parse actions from JSON string + property var parsedActions: { + try { + return model.actionsJson ? JSON.parse(model.actionsJson) : [] + } catch (e) { + return [] } - return actionText } - fontSize: Style.fontSizeS * scaling - backgroundColor: Color.mPrimary - textColor: Color.mOnPrimary - hoverColor: Color.mSecondary - pressColor: Color.mTertiary - outlined: false - customHeight: 32 * scaling - Layout.preferredHeight: 32 * scaling + visible: parsedActions.length > 0 - onClicked: { - if (modelData && modelData.invoke) { - modelData.invoke() + Repeater { + model: parent.parsedActions + + delegate: NButton { + property var actionData: modelData + + text: { + var actionText = actionData.text || "Open" + // If text contains comma, take the part after the comma (the display text) + if (actionText.includes(",")) { + return actionText.split(",")[1] || actionText + } + return actionText + } + fontSize: Style.fontSizeS * scaling + backgroundColor: Color.mPrimary + textColor: hovered ? Color.mOnTertiary : Color.mOnPrimary + hoverColor: Color.mTertiary + outlined: false + Layout.preferredHeight: 24 * scaling + onClicked: { + NotificationService.invokeAction(parent.parentNotificationId, actionData.identifier) + } } } + + // Spacer to push buttons to the left + Item { + Layout.fillWidth: true + } } } - - // Spacer to push buttons to the left if needed - Item { - Layout.fillWidth: true - } } } diff --git a/Modules/Notification/NotificationHistoryPanel.qml b/Modules/Notification/NotificationHistoryPanel.qml index 14c13f89..2016e1fe 100644 --- a/Modules/Notification/NotificationHistoryPanel.qml +++ b/Modules/Notification/NotificationHistoryPanel.qml @@ -81,7 +81,7 @@ NPanel { Layout.fillWidth: true Layout.fillHeight: true Layout.alignment: Qt.AlignHCenter - visible: NotificationService.historyModel.count === 0 + visible: NotificationService.notificationHistory.count === 0 spacing: Style.marginL * scaling Item { @@ -125,13 +125,15 @@ NPanel { horizontalPolicy: ScrollBar.AlwaysOff verticalPolicy: ScrollBar.AsNeeded - model: NotificationService.historyModel + model: NotificationService.notificationHistory spacing: Style.marginM * scaling clip: true boundsBehavior: Flickable.StopAtBounds - visible: NotificationService.historyModel.count > 0 + visible: NotificationService.notificationHistory.count > 0 delegate: Rectangle { + property string notificationId: model.id + width: notificationList.width height: notificationLayout.implicitHeight + (Style.marginM * scaling * 2) radius: Style.radiusM * scaling @@ -139,36 +141,87 @@ NPanel { border.color: Qt.alpha(Color.mOutline, Style.opacityMedium) border.width: Math.max(1, Style.borderS * scaling) + // Smooth color transition on hover + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + RowLayout { id: notificationLayout anchors.fill: parent anchors.margins: Style.marginM * scaling spacing: Style.marginM * scaling - // App icon (same style as popup) - NImageCircled { - Layout.preferredWidth: 28 * scaling - Layout.preferredHeight: 28 * scaling - Layout.alignment: Qt.AlignVCenter - // Prefer stable themed icons over transient image paths - imagePath: (appIcon && appIcon !== "") ? (AppIcons.iconFromName(appIcon, "application-x-executable") || appIcon) : ((AppIcons.iconForAppId(desktopEntry || appName, "application-x-executable") || (image && image !== "" ? image : AppIcons.iconFromName("application-x-executable", "application-x-executable")))) - borderColor: Color.transparent - borderWidth: 0 - visible: true + ColumnLayout { + NImageCircled { + Layout.preferredWidth: 40 * scaling + Layout.preferredHeight: 40 * scaling + Layout.alignment: Qt.AlignTop + Layout.topMargin: 20 * scaling + imagePath: model.cachedImage || model.originalImage || "" + borderColor: Color.transparent + borderWidth: 0 + fallbackIcon: "bell" + fallbackIconSize: 24 * scaling + } + Item { + Layout.fillHeight: true + } } // Notification content column ColumnLayout { Layout.fillWidth: true - Layout.alignment: Qt.AlignVCenter - Layout.maximumWidth: notificationList.width - (Style.marginM * scaling * 4) // Account for margins and delete button - spacing: Style.marginXXS * scaling + Layout.alignment: Qt.AlignTop + spacing: Style.marginXS * scaling + // Header row with app name and timestamp + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS * scaling + + // Urgency indicator + Rectangle { + Layout.preferredWidth: 6 * scaling + Layout.preferredHeight: 6 * scaling + Layout.alignment: Qt.AlignVCenter + radius: 3 * scaling + visible: model.urgency !== 1 + color: { + if (model.urgency === 2) + return Color.mError + else if (model.urgency === 0) + return Color.mOnSurfaceVariant + else + return Color.transparent + } + } + + NText { + text: model.appName || "Unknown App" + font.pointSize: Style.fontSizeXS * scaling + color: Color.mSecondary + } + + NText { + text: NotificationService.formatTimestamp(model.timestamp) + font.pointSize: Style.fontSizeXS * scaling + color: Color.mSecondary + } + + Item { + Layout.fillWidth: true + } + } + + // Summary NText { - text: (summary || "No summary").substring(0, 100) + text: model.summary || "No summary" font.pointSize: Style.fontSizeM * scaling font.weight: Font.Medium - color: Color.mPrimary + color: Color.mOnSurface textFormat: Text.PlainText wrapMode: Text.Wrap Layout.fillWidth: true @@ -176,10 +229,11 @@ NPanel { elide: Text.ElideRight } + // Body NText { - text: (body || "").substring(0, 150) - font.pointSize: Style.fontSizeXS * scaling - color: Color.mOnSurface + text: model.body || "" + font.pointSize: Style.fontSizeS * scaling + color: Color.mOnSurfaceVariant textFormat: Text.PlainText wrapMode: Text.Wrap Layout.fillWidth: true @@ -187,13 +241,6 @@ NPanel { elide: Text.ElideRight visible: text.length > 0 } - - NText { - text: NotificationService.formatTimestamp(timestamp) - font.pointSize: Style.fontSizeXS * scaling - color: Color.mOnSurface - Layout.fillWidth: true - } } // Delete button @@ -204,19 +251,11 @@ NPanel { Layout.alignment: Qt.AlignTop onClicked: { - Logger.log("NotificationHistory", "Removing notification:", summary) - NotificationService.historyModel.remove(index) - NotificationService.saveHistory() + // Remove from history using the service API + NotificationService.removeFromHistory(notificationId) } } } - - MouseArea { - id: notificationMouseArea - anchors.fill: parent - anchors.rightMargin: Style.marginXL * scaling - hoverEnabled: true - } } } } diff --git a/Modules/SettingsPanel/Bar/WidgetSettings/ClockSettings.qml b/Modules/SettingsPanel/Bar/WidgetSettings/ClockSettings.qml index 5d8847b5..689612e2 100644 --- a/Modules/SettingsPanel/Bar/WidgetSettings/ClockSettings.qml +++ b/Modules/SettingsPanel/Bar/WidgetSettings/ClockSettings.qml @@ -241,7 +241,6 @@ ColumnLayout { Layout.bottomMargin: Style.marginM * scaling } - NDateTimeTokens { Layout.fillWidth: true height: 200 * scaling diff --git a/Services/NotificationService.qml b/Services/NotificationService.qml index 58819550..d24c0c23 100644 --- a/Services/NotificationService.qml +++ b/Services/NotificationService.qml @@ -1,20 +1,107 @@ pragma Singleton import QtQuick +import QtQuick.Window import Quickshell import Quickshell.Io +import Quickshell.Services.Notifications import qs.Commons import qs.Services -import Quickshell.Services.Notifications +import "../Helpers/sha256.js" as Checksum Singleton { id: root - // Notification server instance + // ===== Configuration ===== + property int maxVisible: 5 + property int maxHistory: 100 + property string historyFile: Quickshell.env("NOCTALIA_NOTIF_HISTORY_FILE") || (Settings.cacheDir + "notifications.json") + + // ===== Models ===== + property ListModel activeNotifications: ListModel {} + property ListModel notificationHistory: ListModel {} + + // ===== Internal tracking ===== + property var activeNotificationMap: ({}) // Maps notification ID to raw notification object + property var cachingQueue: ({}) // Maps notification ID to caching status + + // ===== Image caching window ===== + property PanelWindow imageCachingWindow: PanelWindow { + id: imageCachingWindow + + width: 1 + height: 1 + color: "transparent" + mask: Region {} + + Item { + id: cachingContainer + width: 256 + height: 256 + + Image { + id: imageCacher + anchors.fill: parent + visible: true // Must be visible for grabToImage to work + cache: false // Disable QML cache since we're doing disk cache + mipmap: true + smooth: true + asynchronous: true + antialiasing: true + + property string currentNotificationId: "" + property string targetCachePath: "" + + onStatusChanged: { + if (status === Image.Ready && currentNotificationId && targetCachePath) { + // Logger.log("Notification", "Image loaded successfully, attempting to cache to:", targetCachePath) + + // Create cache directory if it doesn't exist using mkdir + try { + Quickshell.execDetached(["mkdir", "-p", Settings.cacheDirImagesNotifications]) + } catch (e) { + Logger.error("Notification", "Failed to create cache directory:", e) + } + + // Cache the image to disk + grabToImage(function (result) { + if (result.saveToFile(targetCachePath)) { + //Logger.log("Notification", "Successfully cached image to:", targetCachePath) + // Update the notification data with cached path + updateNotificationCachedImage(currentNotificationId, targetCachePath) + } else { + Logger.error("Notification", "Failed to save cached image:", targetCachePath) + } + + // Clear current caching operation + currentNotificationId = "" + targetCachePath = "" + source = "" + + // Process next item in queue if any + processNextCacheRequest() + }) + } else if (status === Image.Error) { + Logger.error("Notification", "Failed to load image for caching:", source, "error for:", currentNotificationId) + + // Clear current caching operation and process next + currentNotificationId = "" + targetCachePath = "" + source = "" + processNextCacheRequest() + } + } + } + } + } + + // ===== Convenience property to access the image cacher ===== + property alias imageCacher: imageCacher + + // ===== Notification Server ===== property NotificationServer server: NotificationServer { id: notificationServer - // Server capabilities keepOnReload: false imageSupported: true actionsSupported: true @@ -26,270 +113,371 @@ Singleton { bodyHyperlinksSupported: true bodyImagesSupported: true - // Signal when notification is received onNotification: function (notification) { - // Always add notification to history - root.addToHistory(notification) - - // Check if do-not-disturb is enabled - if (Settings.data.notifications && Settings.data.notifications.doNotDisturb) { - return - } - - // Track the notification - notification.tracked = true - - // Connect to closed signal for cleanup - notification.closed.connect(function () { - root.removeNotification(notification) - }) - - // Add to our model - root.addNotification(notification) + root.handleIncomingNotification(notification) } } - // List model to hold notifications - property ListModel notificationModel: ListModel {} + // ===== Main notification handler ===== + function handleIncomingNotification(notification) { + // Create standardized notification data + const notifData = createNotificationData(notification) - // Persistent history of notifications (most recent first) - property ListModel historyModel: ListModel {} - property int maxHistory: 100 + // Always add to history + addToHistory(notifData) - // Cached history file path - property string historyFile: Quickshell.env("NOCTALIA_NOTIF_HISTORY_FILE") || (Settings.cacheDir + "notifications.json") + // Check do-not-disturb + if (Settings.data.notifications?.doNotDisturb) { + return + } - // Persisted storage for history + // Track the raw notification for dismissal + notification.tracked = true + activeNotificationMap[notifData.id] = notification + + // Handle notification closure + notification.closed.connect(function () { + removeActiveNotification(notifData.id) + }) + + // Add to active notifications + addActiveNotification(notifData) + } + + // ===== Data creation ===== + function createNotificationData(notification) { + + //console.log(JSON.stringify(notification)) + const timestamp = new Date() + const id = generateNotificationId(notification, timestamp) + + // Resolve display values + const appName = resolveAppName(notification) + const imagePath = resolveNotificationImage(notification) + const cachedImagePath = cacheImageIfNeeded(imagePath, id) + + // Process actions to store them in a serializable format + const actions = [] + if (notification.actions && notification.actions.length > 0) { + for (let action of notification.actions) { + actions.push({ + "text": action.text || "Action", + "identifier": action.identifier || "" + }) + } + } + + return { + "id": id, + "summary": notification.summary.substring(0, 100) || "", + "body": strip_tags_regex(notification.body).substring(0, 100) || "", + "appName": appName, + "desktopEntry": notification.desktopEntry || "", + "urgency": notification.urgency || 1, + "timestamp": timestamp, + "originalImage": imagePath, + "cachedImage": cachedImagePath, + "actionsJson": JSON.stringify(actions) + } + } + + function generateNotificationId(notification, timestamp) { + // Create a unique ID based on notification content and timestamp + const data = { + "summary": notification.summary, + "body": notification.body, + "appName": notification.appName, + "timestamp": timestamp.getTime() + } + return Checksum.sha256(JSON.stringify(data)) + } + + function cacheImageIfNeeded(imagePath, notificationId) { + if (!imagePath) { + return "" + } + + const destination = Settings.cacheDirImagesNotifications + notificationId + ".png" + + // Handle different image types differently + if (imagePath.startsWith("image://")) { + // For image:// URLs, use the Image component to cache + queueImageForCaching(imagePath, notificationId, destination) + return imagePath + } else if (imagePath.startsWith("/") || imagePath.startsWith("file://")) { + // For local files, use direct copy + try { + const sourceFile = imagePath.startsWith("file://") ? imagePath.substring(7) : imagePath + + // Create cache directory and copy file + Quickshell.execDetached(["sh", "-c", `cp "${sourceFile}" "${destination}"`]) + // Logger.log("Notification", "Initiated direct file copy to:", destination) + + // For direct copies, we assume success and return the destination + // If the copy failed, the original path will still work + return destination + } catch (e) { + Logger.error("Notification", "File copy failed, using Image fallback:", e) + queueImageForCaching(imagePath, notificationId, destination) + return imagePath + } + } else { + // For other URLs or unknown formats, use Image component + queueImageForCaching(imagePath, notificationId, destination) + return imagePath + } + } + + function queueImageForCaching(imagePath, notificationId, destination) { + // Add to caching queue + cachingQueue[notificationId] = { + "source": imagePath, + "destination": destination, + "status": "queued" + } + + // Start processing if not already busy + if (!imageCacher.currentNotificationId) { + processNextCacheRequest() + } + } + + function processNextCacheRequest() { + // Find next queued item + for (const notifId in cachingQueue) { + if (cachingQueue[notifId].status === "queued") { + const request = cachingQueue[notifId] + + // Mark as processing + cachingQueue[notifId].status = "processing" + + // Set up the image cacher + imageCacher.currentNotificationId = notifId + imageCacher.targetCachePath = request.destination + imageCacher.source = request.source + + //Logger.log("Notification", "Starting image cache for:", notifId, "from:", request.source) + return + } + } + } + + function updateNotificationCachedImage(notificationId, cachedPath) { + var updated = false + + // Update active notifications + for (var i = 0; i < activeNotifications.count; i++) { + const notif = activeNotifications.get(i) + if (notif.id === notificationId) { + activeNotifications.setProperty(i, "cachedImage", cachedPath) + updated = true + break + } + } + + // Update history + for (var j = 0; j < notificationHistory.count; j++) { + const histNotif = notificationHistory.get(j) + if (histNotif.id === notificationId) { + notificationHistory.setProperty(j, "cachedImage", cachedPath) + updated = true + break + } + } + + if (!updated) { + Logger.warn("Notification", "Could not find notification to update:", notificationId) + } + + // Remove from caching queue + delete cachingQueue[notificationId] + + // Save updated history + if (updated) { + saveHistory() + // performHistorySave() // Immediate save for cache updates + } + } + + // ===== Active notification management ===== + function addActiveNotification(notifData) { + activeNotifications.insert(0, notifData) + + // Enforce max visible + while (activeNotifications.count > maxVisible) { + const oldest = activeNotifications.get(activeNotifications.count - 1) + dismissNotification(oldest.id) + activeNotifications.remove(activeNotifications.count - 1) + } + } + + function removeActiveNotification(notificationId) { + for (var i = 0; i < activeNotifications.count; i++) { + if (activeNotifications.get(i).id === notificationId) { + activeNotifications.remove(i) + delete activeNotificationMap[notificationId] + + // Also clean up any pending cache operations + if (cachingQueue[notificationId]) { + delete cachingQueue[notificationId] + } + + break + } + } + } + + function dismissNotification(notificationId) { + const rawNotification = activeNotificationMap[notificationId] + if (rawNotification) { + rawNotification.dismiss() + } + removeActiveNotification(notificationId) + } + + // ===== Auto-hide timer ===== + property Timer autoHideTimer: Timer { + interval: 1000 + repeat: true + running: activeNotifications.count > 0 + + onTriggered: { + const now = new Date().getTime() + + for (var i = activeNotifications.count - 1; i >= 0; i--) { + const notif = activeNotifications.get(i) + const elapsed = now - notif.timestamp.getTime() + const duration = getDurationForUrgency(notif.urgency) + + if (elapsed >= duration) { + animateAndRemove(notif.id, i) + break + // Only remove one per tick for animation + } + } + } + } + + function getDurationForUrgency(urgency) { + const durations = Settings.data.notifications || {} + switch (urgency) { + case 0: + return (durations.lowUrgencyDuration || 3) * 1000 + case 1: + return (durations.normalUrgencyDuration || 8) * 1000 + case 2: + return (durations.criticalUrgencyDuration || 15) * 1000 + default: + return 8000 + } + } + + // ===== Persistence ===== property FileView historyFileView: FileView { id: historyFileView - objectName: "notificationHistoryFileView" path: historyFile printErrors: false watchChanges: true + onFileChanged: reload() onAdapterUpdated: writeAdapter() Component.onCompleted: reload() - onLoaded: loadFromHistory() + onLoaded: loadHistoryFromFile() + onLoadFailed: function (error) { - // Create file on first use if (error.toString().includes("No such file") || error === 2) { - writeAdapter() + writeAdapter() // Create file } } JsonAdapter { id: historyAdapter - property var history: [] - property real timestamp: 0 + property var notifications: [] + property real lastSaved: 0 } } - // Maximum visible notifications - property int maxVisible: 5 - - // Function to get duration based on urgency - function getDurationForUrgency(urgency) { - switch (urgency) { - case 0: - // Low urgency - return (Settings.data.notifications.lowUrgencyDuration || 3) * 1000 - case 1: - // Normal urgency - return (Settings.data.notifications.normalUrgencyDuration || 8) * 1000 - case 2: - // Critical urgency - return (Settings.data.notifications.criticalUrgencyDuration || 15) * 1000 - default: - return (Settings.data.notifications.normalUrgencyDuration || 8) * 1000 - } + property Timer saveHistoryTimer: Timer { + interval: 200 + repeat: false + onTriggered: performHistorySave() } - // Auto-hide timer - property Timer hideTimer: Timer { - interval: 1000 // Check every second - repeat: true - running: notificationModel.count > 0 + // ===== History management =====H + function addToHistory(notifData) { + notificationHistory.insert(0, notifData) - onTriggered: { - if (notificationModel.count === 0) { - return - } + // Enforce max history - use removeFromHistory to properly clean up cached images + while (notificationHistory.count > maxHistory) { + const oldestNotif = notificationHistory.get(notificationHistory.count - 1) + removeFromHistory(oldestNotif.id) + } - // Check each notification for expiration - for (var i = notificationModel.count - 1; i >= 0; i--) { - let notificationData = notificationModel.get(i) - if (notificationData && notificationData.rawNotification) { - let notification = notificationData.rawNotification - let urgency = notificationData.urgency - let timestamp = notificationData.timestamp + saveHistory() + } - // Calculate if this notification should be removed - let duration = getDurationForUrgency(urgency) - let now = new Date() - let elapsed = now.getTime() - timestamp.getTime() - - if (elapsed >= duration) { - // Trigger animation signal instead of direct dismiss - animateAndRemove(notification, i) - break - // Only remove one notification per check to avoid conflicts + function removeFromHistory(notificationId) { + for (var i = 0; i < notificationHistory.count; i++) { + const notif = notificationHistory.get(i) + if (notif.id === notificationId) { + // Delete cached image if it exists + if (notif.cachedImage && notif.cachedImage.length > 0 && !notif.cachedImage.startsWith("image://")) { + try { + // rm -f won't error if file doesn't exist + Quickshell.execDetached(["rm", "-f", notif.cachedImage]) + //Logger.log("Notifications", "Deleted cached image:", notif.cachedImage) + } catch (e) { + Logger.error("Notifications", "Failed to delete cached image:", e) } } + + notificationHistory.remove(i) + saveHistory() + return true } } - } - - Connections { - target: Settings.data.notifications - function onDoNotDisturbChanged() { - const label = Settings.data.notifications.doNotDisturb ? "'Do not disturb' enabled" : "'Do not disturb' disabled" - const description = Settings.data.notifications.doNotDisturb ? "You'll find these notifications in your history." : "Showing all notifications." - ToastService.showNotice(label, description) - } - } - - // Function to resolve app name from notification - function resolveAppName(notification) { - try { - const appName = notification.appName || "" - - // If it's already a clean name (no dots or reverse domain notation), use it - if (!appName.includes(".") || appName.length < 10) { - return appName - } - - // Try to find a desktop entry for this app ID - const desktopEntries = DesktopEntries.byId(appName) - if (desktopEntries && desktopEntries.length > 0) { - const entry = desktopEntries[0] - // Prefer name over genericName, fallback to original appName - return entry.name || entry.genericName || appName - } - - // If no desktop entry found, try to clean up the app ID - // Convert "org.gnome.Nautilus" to "Nautilus" - const parts = appName.split(".") - if (parts.length > 1) { - // Take the last part and capitalize it - const lastPart = parts[parts.length - 1] - return lastPart.charAt(0).toUpperCase() + lastPart.slice(1) - } - - return appName - } catch (e) { - // Fallback to original app name on any error - return notification.appName || "" - } - } - - // Function to add notification to model - function addNotification(notification) { - const resolvedImage = resolveNotificationImage(notification) - const resolvedAppName = resolveAppName(notification) - - notificationModel.insert(0, { - "rawNotification": notification, - "summary": notification.summary, - "body": notification.body, - "appName": resolvedAppName, - "desktopEntry": notification.desktopEntry, - "image": resolvedImage, - "appIcon": notification.appIcon, - "urgency": notification.urgency, - "timestamp": new Date() - }) - - // Remove oldest notifications if we exceed maxVisible - while (notificationModel.count > maxVisible) { - let oldestNotification = notificationModel.get(notificationModel.count - 1).rawNotification - if (oldestNotification) { - oldestNotification.dismiss() - } - notificationModel.remove(notificationModel.count - 1) - } - } - - // Resolve an image path for a notification, supporting icon names and absolute paths - function resolveNotificationImage(notification) { - try { - // If an explicit image is already provided, prefer it - if (notification && notification.image && notification.image !== "") { - return notification.image - } - - // Fallback to appIcon which may be a name or a path (notify-send -i) - const icon = notification ? (notification.appIcon || "") : "" - if (!icon) - return "" - - // Accept absolute file paths or file URLs directly - if (icon.startsWith("/")) { - return icon - } - if (icon.startsWith("file://")) { - // Strip the scheme for QML image source compatibility - return icon.substring("file://".length) - } - - // Resolve themed icon names to absolute paths - try { - const p = AppIcons.iconFromName(icon, "") - return p || "" - } catch (e2) { - return "" - } - } catch (e) { - return "" - } - } - - function addToHistory(notification) { - const resolvedAppName = resolveAppName(notification) - const resolvedImage = resolveNotificationImage(notification) - - historyModel.insert(0, { - "summary": notification.summary, - "body": notification.body, - "appName": resolvedAppName, - "desktopEntry": notification.desktopEntry || "", - "image": resolvedImage, - "appIcon": notification.appIcon || "", - "urgency": notification.urgency, - "timestamp": new Date() - }) - while (historyModel.count > maxHistory) { - historyModel.remove(historyModel.count - 1) - } - saveHistory() + return false } function clearHistory() { - historyModel.clear() + // Remove all images, yay! + try { + Quickshell.execDetached(["sh", "-c", `rm -rf "${Settings.cacheDirImagesNotifications}"*`]) + } catch (e) { + Logger.error("Notifications", "Failed to clear cache directory:", e) + } + + notificationHistory.clear() saveHistory() } - function loadFromHistory() { - // Populate in-memory model from adapter + function loadHistoryFromFile() { try { - historyModel.clear() - const items = historyAdapter.history || [] - for (var i = 0; i < items.length; i++) { - const it = items[i] - // Coerce legacy second-based timestamps to milliseconds - var ts = it.timestamp - if (typeof ts === "number" && ts < 1e12) { - ts = ts * 1000 + notificationHistory.clear() + const items = historyAdapter.notifications || [] + + for (const item of items) { + // Ensure timestamp is properly converted + let timestamp = item.timestamp + if (typeof timestamp === "number") { + if (timestamp < 1e12) + timestamp *= 1000 // Convert seconds to ms + timestamp = new Date(timestamp) + } else if (!(timestamp instanceof Date)) { + timestamp = new Date() } - historyModel.append({ - "summary": it.summary || "", - "body": it.body || "", - "appName": it.appName || "", - "desktopEntry": it.desktopEntry || "", - "image": it.image || "", - "appIcon": it.appIcon || "", - "urgency": it.urgency, - "timestamp": ts ? new Date(ts) : new Date() - }) + + notificationHistory.append({ + "id": item.id || generateNotificationId(item, timestamp), + "summary": item.summary || "", + "body": item.body || "", + "appName": item.appName || "", + "desktopEntry": item.desktopEntry || "", + "urgency": item.urgency || 1, + "timestamp": timestamp, + "originalImage": item.originalImage || "", + "cachedImage": item.cachedImage || "" + }) } } catch (e) { Logger.error("Notifications", "Failed to load history:", e) @@ -297,81 +485,132 @@ Singleton { } function saveHistory() { - try { - // Serialize model back to adapter - var arr = [] - for (var i = 0; i < historyModel.count; i++) { - const n = historyModel.get(i) - arr.push({ - "summary": n.summary, - "body": n.body, - "appName": n.appName, - "desktopEntry": n.desktopEntry, - "image": n.image, - "appIcon": n.appIcon, - "urgency": n.urgency, - "timestamp"// Always persist in milliseconds - : (n.timestamp instanceof Date) ? n.timestamp.getTime() : (typeof n.timestamp === "number" && n.timestamp < 1e12 ? n.timestamp * 1000 : n.timestamp) - }) - } - historyAdapter.history = arr - historyAdapter.timestamp = Time.timestamp + saveHistoryTimer.restart() // Debounce multiple saves + } - Qt.callLater(function () { - historyFileView.writeAdapter() - }) + function performHistorySave() { + try { + const notifications = [] + + for (var i = 0; i < notificationHistory.count; i++) { + const notif = notificationHistory.get(i) + + // Create a shallow copy and fix the timestamp + const copy = Object.assign({}, notif) + copy.timestamp = notif.timestamp.getTime() // Convert Date to milliseconds + notifications.push(copy) + } + + historyAdapter.notifications = notifications + historyAdapter.lastSaved = Date.now() + + historyFileView.writeAdapter() + + Logger.log("Notifications", "Saved", notifications.length, "notifications to history") } catch (e) { Logger.error("Notifications", "Failed to save history:", e) } } - // Signal to trigger animation before removal - signal animateAndRemove(var notification, int index) + // ===== Helper functions ===== + function resolveAppName(notification) { + const appName = notification.appName || "" - // Function to remove notification from model - function removeNotification(notification) { - for (var i = 0; i < notificationModel.count; i++) { - if (notificationModel.get(i).rawNotification === notification) { - // Emit signal to trigger animation first - animateAndRemove(notification, i) - break - } + if (!appName.includes(".") || appName.length < 10) { + return appName } + + // Try desktop entry lookup + const desktopEntries = DesktopEntries.byId(appName) + if (desktopEntries?.length > 0) { + return desktopEntries[0].name || desktopEntries[0].genericName || appName + } + + // Clean up reverse domain notation + const parts = appName.split(".") + if (parts.length > 1) { + const lastPart = parts[parts.length - 1] + return lastPart.charAt(0).toUpperCase() + lastPart.slice(1) + } + + return appName } - // Function to actually remove notification after animation - function forceRemoveNotification(notification) { - for (var i = 0; i < notificationModel.count; i++) { - if (notificationModel.get(i).rawNotification === notification) { - notificationModel.remove(i) - break - } + function resolveNotificationImage(notification) { + const image = notification?.image || "" + if (image) { + return image } + + const icon = notification?.appIcon || "" + if (!icon) + return "" + + // Handle absolute paths and file URLs + if (icon.startsWith("/")) + return icon + if (icon.startsWith("file://")) + return icon.substring(7) + + // Resolve the icon + return AppIcons.iconFromName(icon) } - // Function to format timestamp function formatTimestamp(timestamp) { if (!timestamp) return "" - const now = new Date() - const diff = now - timestamp + const diff = Date.now() - timestamp.getTime() - // Less than 1 minute - if (diff < 60000) { + if (diff < 60000) return "now" - } // Less than 1 hour - else if (diff < 3600000) { - const minutes = Math.floor(diff / 60000) - return `${minutes}m ago` - } // Less than 24 hours - else if (diff < 86400000) { - const hours = Math.floor(diff / 3600000) - return `${hours}h ago` - } // More than 24 hours - else { - const days = Math.floor(diff / 86400000) - return `${days}d ago` + if (diff < 3600000) + return `${Math.floor(diff / 60000)}m ago` + if (diff < 86400000) + return `${Math.floor(diff / 3600000)}h ago` + return `${Math.floor(diff / 86400000)}d ago` + } + + function strip_tags_regex(text) { + return text.replace(/<[^>]*>?/gm, '') + } + + // ===== Signals ===== + signal animateAndRemove(string notificationId, int index) + + // ===== Public API ===== + function dismissActiveNotification(notificationId) { + dismissNotification(notificationId) + } + + function dismissAllActive() { + while (activeNotifications.count > 0) { + const notif = activeNotifications.get(0) + dismissNotification(notif.id) + } + } + + function invokeAction(notificationId, actionIdentifier) { + const rawNotification = activeNotificationMap[notificationId] + if (rawNotification && rawNotification.actions) { + for (let action of rawNotification.actions) { + if (action.identifier === actionIdentifier && action.invoke) { + action.invoke() + return true + } + } + } + return false + } + + // ===== Do Not Disturb handler ===== + Connections { + target: Settings.data.notifications + function onDoNotDisturbChanged() { + const enabled = Settings.data.notifications.doNotDisturb + const label = enabled ? "'Do not disturb' enabled" : "'Do not disturb' disabled" + const description = enabled ? "You'll find these notifications in your history." : "Showing all notifications." + ToastService.showNotice(label, description) } } } diff --git a/Widgets/NButton.qml b/Widgets/NButton.qml index b8fe113a..7dc8841c 100644 --- a/Widgets/NButton.qml +++ b/Widgets/NButton.qml @@ -19,8 +19,6 @@ Rectangle { property int fontWeight: Style.fontWeightBold property real iconSize: Style.fontSizeL * scaling property bool outlined: false - property real customWidth: -1 - property real customHeight: -1 // Signals signal clicked @@ -32,8 +30,8 @@ Rectangle { property bool pressed: false // Dimensions - implicitWidth: customWidth > 0 ? customWidth : contentRow.implicitWidth + (Style.marginL * 2 * scaling) - implicitHeight: customHeight > 0 ? customHeight : Math.max(Style.baseWidgetSize * scaling, contentRow.implicitHeight + (Style.marginM * scaling)) + implicitWidth: contentRow.implicitWidth + (Style.marginL * 2 * scaling) + implicitHeight: Math.max(Style.baseWidgetSize * scaling, contentRow.implicitHeight + (Style.marginM * scaling)) // Appearance radius: Style.radiusS * scaling diff --git a/Widgets/NColorPickerDialog.qml b/Widgets/NColorPickerDialog.qml index 3fef147b..6cf6965f 100644 --- a/Widgets/NColorPickerDialog.qml +++ b/Widgets/NColorPickerDialog.qml @@ -464,8 +464,6 @@ Popup { id: cancelButton text: "Cancel" outlined: cancelButton.hovered ? false : true - customHeight: 36 * scaling - customWidth: 100 * scaling onClicked: { root.close() } @@ -474,8 +472,6 @@ Popup { NButton { text: "Apply" icon: "check" - customHeight: 36 * scaling - customWidth: 100 * scaling onClicked: { root.colorSelected(root.selectedColor) root.close() diff --git a/Widgets/NImageCircled.qml b/Widgets/NImageCircled.qml index c1ed5aa0..57ff3db4 100644 --- a/Widgets/NImageCircled.qml +++ b/Widgets/NImageCircled.qml @@ -64,7 +64,7 @@ Rectangle { } } - //Border + // Border Rectangle { anchors.fill: parent radius: parent.radius diff --git a/Widgets/NSlider.qml b/Widgets/NSlider.qml index a0edbbbd..51b2f62f 100644 --- a/Widgets/NSlider.qml +++ b/Widgets/NSlider.qml @@ -9,7 +9,7 @@ Slider { property var cutoutColor: Color.mSurface property bool snapAlways: true - property real heightRatio: 0.75 + property real heightRatio: 0.7 readonly property real knobDiameter: Math.round(Style.baseWidgetSize * heightRatio * scaling) readonly property real trackHeight: knobDiameter * 0.4 diff --git a/Widgets/NValueSlider.qml b/Widgets/NValueSlider.qml index 4f069548..981b7905 100644 --- a/Widgets/NValueSlider.qml +++ b/Widgets/NValueSlider.qml @@ -14,7 +14,7 @@ RowLayout { property real stepSize: 0.01 property var cutoutColor: Color.mSurface property bool snapAlways: true - property real heightRatio: 0.75 + property real heightRatio: 0.7 property string text: "" // Signals From ff34696d289b9a3424ad196d6742ec953a71da52 Mon Sep 17 00:00:00 2001 From: Ly-sec Date: Sun, 21 Sep 2025 10:48:43 +0200 Subject: [PATCH 04/10] NotificationService: cleanup, fix duplicate images, resize to 64x64 --- Modules/Notification/Notification.qml | 2 +- .../Notification/NotificationHistoryPanel.qml | 14 +- Services/NotificationService.qml | 754 ++++++------------ 3 files changed, 261 insertions(+), 509 deletions(-) diff --git a/Modules/Notification/Notification.qml b/Modules/Notification/Notification.qml index 0f389c6b..3b2f2e75 100644 --- a/Modules/Notification/Notification.qml +++ b/Modules/Notification/Notification.qml @@ -102,7 +102,7 @@ Variants { delegate.animateOut() } else { // Force removal without animation as fallback - NotificationService.removeActiveNotification(notificationId) + NotificationService.dismissActiveNotification(notificationId) } }) } diff --git a/Modules/Notification/NotificationHistoryPanel.qml b/Modules/Notification/NotificationHistoryPanel.qml index 2016e1fe..2367ed72 100644 --- a/Modules/Notification/NotificationHistoryPanel.qml +++ b/Modules/Notification/NotificationHistoryPanel.qml @@ -12,8 +12,8 @@ import qs.Widgets NPanel { id: root - preferredWidth: 380 - preferredHeight: 500 + preferredWidth: 360 + preferredHeight: 480 panelKeyboardFocus: true panelContent: Rectangle { @@ -49,26 +49,20 @@ NPanel { tooltipText: `'Do not disturb' ${Settings.data.notifications.doNotDisturb ? "enabled" : "disabled"}` baseSize: Style.baseWidgetSize * 0.8 onClicked: Settings.data.notifications.doNotDisturb = !Settings.data.notifications.doNotDisturb - onRightClicked: Settings.data.notifications.doNotDisturb = !Settings.data.notifications.doNotDisturb } NIconButton { icon: "trash" tooltipText: "Clear history" baseSize: Style.baseWidgetSize * 0.8 - onClicked: { - NotificationService.clearHistory() - root.close() - } + onClicked: NotificationService.clearHistory() } NIconButton { icon: "close" tooltipText: "Close" baseSize: Style.baseWidgetSize * 0.8 - onClicked: { - root.close() - } + onClicked: root.close() } } diff --git a/Services/NotificationService.qml b/Services/NotificationService.qml index d24c0c23..a88e281a 100644 --- a/Services/NotificationService.qml +++ b/Services/NotificationService.qml @@ -12,427 +12,330 @@ import "../Helpers/sha256.js" as Checksum Singleton { id: root - // ===== Configuration ===== + // Configuration property int maxVisible: 5 property int maxHistory: 100 property string historyFile: Quickshell.env("NOCTALIA_NOTIF_HISTORY_FILE") || (Settings.cacheDir + "notifications.json") - // ===== Models ===== + // Models property ListModel activeNotifications: ListModel {} property ListModel notificationHistory: ListModel {} - // ===== Internal tracking ===== - property var activeNotificationMap: ({}) // Maps notification ID to raw notification object - property var cachingQueue: ({}) // Maps notification ID to caching status - - // ===== Image caching window ===== - property PanelWindow imageCachingWindow: PanelWindow { - id: imageCachingWindow + // Internal state + property var activeMap: ({}) + property var imageQueue: [] + // Simple image cacher + PanelWindow { width: 1 height: 1 color: "transparent" mask: Region {} - Item { - id: cachingContainer - width: 256 - height: 256 + Image { + id: cacher + width: 64 + height: 64 + visible: true + cache: false + asynchronous: true - Image { - id: imageCacher - anchors.fill: parent - visible: true // Must be visible for grabToImage to work - cache: false // Disable QML cache since we're doing disk cache - mipmap: true - smooth: true - asynchronous: true - antialiasing: true + onStatusChanged: { + if (imageQueue.length === 0) + return + const req = imageQueue[0] - property string currentNotificationId: "" - property string targetCachePath: "" + if (status === Image.Ready) { + Quickshell.execDetached(["mkdir", "-p", Settings.cacheDirImagesNotifications]) + grabToImage(result => { + if (result.saveToFile(req.dest)) + updateImagePath(req.imageId, req.dest) + processNextImage() + }) + } else if (status === Image.Error) { + processNextImage() + } + } - onStatusChanged: { - if (status === Image.Ready && currentNotificationId && targetCachePath) { - // Logger.log("Notification", "Image loaded successfully, attempting to cache to:", targetCachePath) - - // Create cache directory if it doesn't exist using mkdir - try { - Quickshell.execDetached(["mkdir", "-p", Settings.cacheDirImagesNotifications]) - } catch (e) { - Logger.error("Notification", "Failed to create cache directory:", e) - } - - // Cache the image to disk - grabToImage(function (result) { - if (result.saveToFile(targetCachePath)) { - //Logger.log("Notification", "Successfully cached image to:", targetCachePath) - // Update the notification data with cached path - updateNotificationCachedImage(currentNotificationId, targetCachePath) - } else { - Logger.error("Notification", "Failed to save cached image:", targetCachePath) - } - - // Clear current caching operation - currentNotificationId = "" - targetCachePath = "" - source = "" - - // Process next item in queue if any - processNextCacheRequest() - }) - } else if (status === Image.Error) { - Logger.error("Notification", "Failed to load image for caching:", source, "error for:", currentNotificationId) - - // Clear current caching operation and process next - currentNotificationId = "" - targetCachePath = "" - source = "" - processNextCacheRequest() - } + function processNextImage() { + imageQueue.shift() + if (imageQueue.length > 0) { + source = imageQueue[0].src + } else { + source = "" } } } } - // ===== Convenience property to access the image cacher ===== - property alias imageCacher: imageCacher - - // ===== Notification Server ===== - property NotificationServer server: NotificationServer { - id: notificationServer - + // Notification server + NotificationServer { keepOnReload: false imageSupported: true actionsSupported: true - actionIconsSupported: true - bodyMarkupSupported: true - bodySupported: true - persistenceSupported: true - inlineReplySupported: true - bodyHyperlinksSupported: true - bodyImagesSupported: true - - onNotification: function (notification) { - root.handleIncomingNotification(notification) - } + onNotification: notification => handleNotification(notification) } - // ===== Main notification handler ===== - function handleIncomingNotification(notification) { - // Create standardized notification data - const notifData = createNotificationData(notification) + // Main handler + function handleNotification(notification) { + const data = createData(notification) + addToHistory(data) - // Always add to history - addToHistory(notifData) - - // Check do-not-disturb - if (Settings.data.notifications?.doNotDisturb) { + if (Settings.data.notifications?.doNotDisturb) return - } - // Track the raw notification for dismissal + activeMap[data.id] = notification notification.tracked = true - activeNotificationMap[notifData.id] = notification + notification.closed.connect(() => removeActive(data.id)) - // Handle notification closure - notification.closed.connect(function () { - removeActiveNotification(notifData.id) - }) - - // Add to active notifications - addActiveNotification(notifData) - } - - // ===== Data creation ===== - function createNotificationData(notification) { - - //console.log(JSON.stringify(notification)) - const timestamp = new Date() - const id = generateNotificationId(notification, timestamp) - - // Resolve display values - const appName = resolveAppName(notification) - const imagePath = resolveNotificationImage(notification) - const cachedImagePath = cacheImageIfNeeded(imagePath, id) - - // Process actions to store them in a serializable format - const actions = [] - if (notification.actions && notification.actions.length > 0) { - for (let action of notification.actions) { - actions.push({ - "text": action.text || "Action", - "identifier": action.identifier || "" - }) - } - } - - return { - "id": id, - "summary": notification.summary.substring(0, 100) || "", - "body": strip_tags_regex(notification.body).substring(0, 100) || "", - "appName": appName, - "desktopEntry": notification.desktopEntry || "", - "urgency": notification.urgency || 1, - "timestamp": timestamp, - "originalImage": imagePath, - "cachedImage": cachedImagePath, - "actionsJson": JSON.stringify(actions) - } - } - - function generateNotificationId(notification, timestamp) { - // Create a unique ID based on notification content and timestamp - const data = { - "summary": notification.summary, - "body": notification.body, - "appName": notification.appName, - "timestamp": timestamp.getTime() - } - return Checksum.sha256(JSON.stringify(data)) - } - - function cacheImageIfNeeded(imagePath, notificationId) { - if (!imagePath) { - return "" - } - - const destination = Settings.cacheDirImagesNotifications + notificationId + ".png" - - // Handle different image types differently - if (imagePath.startsWith("image://")) { - // For image:// URLs, use the Image component to cache - queueImageForCaching(imagePath, notificationId, destination) - return imagePath - } else if (imagePath.startsWith("/") || imagePath.startsWith("file://")) { - // For local files, use direct copy - try { - const sourceFile = imagePath.startsWith("file://") ? imagePath.substring(7) : imagePath - - // Create cache directory and copy file - Quickshell.execDetached(["sh", "-c", `cp "${sourceFile}" "${destination}"`]) - // Logger.log("Notification", "Initiated direct file copy to:", destination) - - // For direct copies, we assume success and return the destination - // If the copy failed, the original path will still work - return destination - } catch (e) { - Logger.error("Notification", "File copy failed, using Image fallback:", e) - queueImageForCaching(imagePath, notificationId, destination) - return imagePath - } - } else { - // For other URLs or unknown formats, use Image component - queueImageForCaching(imagePath, notificationId, destination) - return imagePath - } - } - - function queueImageForCaching(imagePath, notificationId, destination) { - // Add to caching queue - cachingQueue[notificationId] = { - "source": imagePath, - "destination": destination, - "status": "queued" - } - - // Start processing if not already busy - if (!imageCacher.currentNotificationId) { - processNextCacheRequest() - } - } - - function processNextCacheRequest() { - // Find next queued item - for (const notifId in cachingQueue) { - if (cachingQueue[notifId].status === "queued") { - const request = cachingQueue[notifId] - - // Mark as processing - cachingQueue[notifId].status = "processing" - - // Set up the image cacher - imageCacher.currentNotificationId = notifId - imageCacher.targetCachePath = request.destination - imageCacher.source = request.source - - //Logger.log("Notification", "Starting image cache for:", notifId, "from:", request.source) - return - } - } - } - - function updateNotificationCachedImage(notificationId, cachedPath) { - var updated = false - - // Update active notifications - for (var i = 0; i < activeNotifications.count; i++) { - const notif = activeNotifications.get(i) - if (notif.id === notificationId) { - activeNotifications.setProperty(i, "cachedImage", cachedPath) - updated = true - break - } - } - - // Update history - for (var j = 0; j < notificationHistory.count; j++) { - const histNotif = notificationHistory.get(j) - if (histNotif.id === notificationId) { - notificationHistory.setProperty(j, "cachedImage", cachedPath) - updated = true - break - } - } - - if (!updated) { - Logger.warn("Notification", "Could not find notification to update:", notificationId) - } - - // Remove from caching queue - delete cachingQueue[notificationId] - - // Save updated history - if (updated) { - saveHistory() - // performHistorySave() // Immediate save for cache updates - } - } - - // ===== Active notification management ===== - function addActiveNotification(notifData) { - activeNotifications.insert(0, notifData) - - // Enforce max visible + activeNotifications.insert(0, data) while (activeNotifications.count > maxVisible) { - const oldest = activeNotifications.get(activeNotifications.count - 1) - dismissNotification(oldest.id) + const last = activeNotifications.get(activeNotifications.count - 1) + activeMap[last.id]?.dismiss() activeNotifications.remove(activeNotifications.count - 1) } } - function removeActiveNotification(notificationId) { - for (var i = 0; i < activeNotifications.count; i++) { - if (activeNotifications.get(i).id === notificationId) { - activeNotifications.remove(i) - delete activeNotificationMap[notificationId] + function createData(n) { + const time = new Date() + const id = Checksum.sha256(JSON.stringify({ + "summary": n.summary, + "body": n.body, + "app": n.appName, + "time": time.getTime() + })) - // Also clean up any pending cache operations - if (cachingQueue[notificationId]) { - delete cachingQueue[notificationId] - } + const image = n.image || getIcon(n.appIcon) + const imageId = generateImageId(n, image) + queueImage(image, imageId) + return { + "id": id, + "summary": (n.summary || "").substring(0, 100), + "body": stripTags(n.body || "").substring(0, 100), + "appName": getAppName(n.appName), + "urgency": n.urgency || 1, + "timestamp": time, + "originalImage": image, + "cachedImage": imageId ? (Settings.cacheDirImagesNotifications + imageId + ".png") : image, + "actionsJson": JSON.stringify((n.actions || []).map(a => ({ + "text": a.text || "Action", + "identifier": a.identifier || "" + }))) + } + } + + function queueImage(path, imageId) { + if (!path || !path.startsWith("image://") || !imageId) + return + + const dest = Settings.cacheDirImagesNotifications + imageId + ".png" + + // Skip if already queued + for (const req of imageQueue) { + if (req.imageId === imageId) + return + } + + imageQueue.push({ + "src": path, + "dest": dest, + "imageId": imageId + }) + if (imageQueue.length === 1) + cacher.source = path + } + + function updateImagePath(id, path) { + updateModel(activeNotifications, id, "cachedImage", path) + updateModel(notificationHistory, id, "cachedImage", path) + saveHistory() + } + + function updateModel(model, id, prop, value) { + for (var i = 0; i < model.count; i++) { + if (model.get(i).id === id) { + model.setProperty(i, prop, value) break } } } - function dismissNotification(notificationId) { - const rawNotification = activeNotificationMap[notificationId] - if (rawNotification) { - rawNotification.dismiss() + function removeActive(id) { + for (var i = 0; i < activeNotifications.count; i++) { + if (activeNotifications.get(i).id === id) { + activeNotifications.remove(i) + delete activeMap[id] + break + } } - removeActiveNotification(notificationId) } - // ===== Auto-hide timer ===== - property Timer autoHideTimer: Timer { + // Auto-hide timer + Timer { interval: 1000 repeat: true running: activeNotifications.count > 0 - onTriggered: { - const now = new Date().getTime() + const now = Date.now() + const durations = [3000, 8000, 15000] // low, normal, critical for (var i = activeNotifications.count - 1; i >= 0; i--) { const notif = activeNotifications.get(i) const elapsed = now - notif.timestamp.getTime() - const duration = getDurationForUrgency(notif.urgency) - if (elapsed >= duration) { + if (elapsed >= durations[notif.urgency] || elapsed >= 8000) { animateAndRemove(notif.id, i) break - // Only remove one per tick for animation } } } } - function getDurationForUrgency(urgency) { - const durations = Settings.data.notifications || {} - switch (urgency) { - case 0: - return (durations.lowUrgencyDuration || 3) * 1000 - case 1: - return (durations.normalUrgencyDuration || 8) * 1000 - case 2: - return (durations.criticalUrgencyDuration || 15) * 1000 - default: - return 8000 - } - } + // History management + function addToHistory(data) { + notificationHistory.insert(0, data) - // ===== Persistence ===== - property FileView historyFileView: FileView { - id: historyFileView - path: historyFile - printErrors: false - watchChanges: true - - onFileChanged: reload() - onAdapterUpdated: writeAdapter() - Component.onCompleted: reload() - onLoaded: loadHistoryFromFile() - - onLoadFailed: function (error) { - if (error.toString().includes("No such file") || error === 2) { - writeAdapter() // Create file - } - } - - JsonAdapter { - id: historyAdapter - property var notifications: [] - property real lastSaved: 0 - } - } - - property Timer saveHistoryTimer: Timer { - interval: 200 - repeat: false - onTriggered: performHistorySave() - } - - // ===== History management =====H - function addToHistory(notifData) { - notificationHistory.insert(0, notifData) - - // Enforce max history - use removeFromHistory to properly clean up cached images while (notificationHistory.count > maxHistory) { - const oldestNotif = notificationHistory.get(notificationHistory.count - 1) - removeFromHistory(oldestNotif.id) + const old = notificationHistory.get(notificationHistory.count - 1) + if (old.cachedImage && !old.cachedImage.startsWith("image://")) { + Quickshell.execDetached(["rm", "-f", old.cachedImage]) + } + notificationHistory.remove(notificationHistory.count - 1) } saveHistory() } - function removeFromHistory(notificationId) { - for (var i = 0; i < notificationHistory.count; i++) { - const notif = notificationHistory.get(i) - if (notif.id === notificationId) { - // Delete cached image if it exists - if (notif.cachedImage && notif.cachedImage.length > 0 && !notif.cachedImage.startsWith("image://")) { - try { - // rm -f won't error if file doesn't exist - Quickshell.execDetached(["rm", "-f", notif.cachedImage]) - //Logger.log("Notifications", "Deleted cached image:", notif.cachedImage) - } catch (e) { - Logger.error("Notifications", "Failed to delete cached image:", e) - } + // Persistence + FileView { + path: historyFile + printErrors: false + onLoaded: loadHistory() + onLoadFailed: error => { + if (error === 2) + writeAdapter() + } + + JsonAdapter { + id: adapter + property var notifications: [] + } + } + + Timer { + id: saveTimer + interval: 200 + onTriggered: performSave() + } + + function saveHistory() { + saveTimer.restart() + } + + function performSave() { + try { + const items = [] + for (var i = 0; i < notificationHistory.count; i++) { + const n = notificationHistory.get(i) + const copy = Object.assign({}, n) + copy.timestamp = n.timestamp.getTime() + items.push(copy) + } + adapter.notifications = items + } catch (e) { + Logger.error("Notifications", "Save failed:", e) + } + } + + function loadHistory() { + try { + notificationHistory.clear() + for (const item of adapter.notifications || []) { + let time = item.timestamp + if (typeof time === "number") { + if (time < 1e12) + time *= 1000 + time = new Date(time) + } else { + time = new Date() } - notificationHistory.remove(i) - saveHistory() + notificationHistory.append({ + "id": item.id || "", + "summary": item.summary || "", + "body": item.body || "", + "appName": item.appName || "", + "urgency": item.urgency || 1, + "timestamp": time, + "originalImage": item.originalImage || "", + "cachedImage": item.cachedImage || "" + }) + } + } catch (e) { + Logger.error("Notifications", "Load failed:", e) + } + } + + // Helpers + function getAppName(name) { + if (!name?.includes(".")) + return name || "" + const entries = DesktopEntries.byId(name) + if (entries?.length) + return entries[0].name || name + const parts = name.split(".") + return parts[parts.length - 1].charAt(0).toUpperCase() + parts[parts.length - 1].slice(1) + } + + function getIcon(icon) { + if (!icon) + return "" + if (icon.startsWith("/") || icon.startsWith("file://")) + return icon + return AppIcons.iconFromName(icon) + } + + function stripTags(text) { + return text.replace(/<[^>]*>?/gm, '') + } + + function generateImageId(notification, image) { + if (image && image.startsWith("image://")) { + // For qsimage URLs, try to use a combination that's unique per user + if (image.startsWith("image://qsimage/")) { + // Try to use app name + summary for uniqueness (summary often contains username) + const key = (notification.appName || "") + "|" + (notification.summary || "") + const hash = Checksum.sha256(key) + return hash + } + + const hash = Checksum.sha256(image) + return hash + } + return "" + } + + // Public API + function dismissActiveNotification(id) { + activeMap[id]?.dismiss() + removeActive(id) + } + + function dismissAllActive() { + Object.values(activeMap).forEach(n => n.dismiss()) + activeNotifications.clear() + activeMap = {} + } + + function invokeAction(id, actionId) { + const n = activeMap[id] + if (!n?.actions) + return false + + for (const action of n.actions) { + if (action.identifier === actionId && action.invoke) { + action.invoke() return true } } @@ -440,128 +343,15 @@ Singleton { } function clearHistory() { - // Remove all images, yay! - try { - Quickshell.execDetached(["sh", "-c", `rm -rf "${Settings.cacheDirImagesNotifications}"*`]) - } catch (e) { - Logger.error("Notifications", "Failed to clear cache directory:", e) - } - + Quickshell.execDetached(["rm", "-rf", Settings.cacheDirImagesNotifications + "*"]) notificationHistory.clear() saveHistory() } - function loadHistoryFromFile() { - try { - notificationHistory.clear() - const items = historyAdapter.notifications || [] - - for (const item of items) { - // Ensure timestamp is properly converted - let timestamp = item.timestamp - if (typeof timestamp === "number") { - if (timestamp < 1e12) - timestamp *= 1000 // Convert seconds to ms - timestamp = new Date(timestamp) - } else if (!(timestamp instanceof Date)) { - timestamp = new Date() - } - - notificationHistory.append({ - "id": item.id || generateNotificationId(item, timestamp), - "summary": item.summary || "", - "body": item.body || "", - "appName": item.appName || "", - "desktopEntry": item.desktopEntry || "", - "urgency": item.urgency || 1, - "timestamp": timestamp, - "originalImage": item.originalImage || "", - "cachedImage": item.cachedImage || "" - }) - } - } catch (e) { - Logger.error("Notifications", "Failed to load history:", e) - } - } - - function saveHistory() { - saveHistoryTimer.restart() // Debounce multiple saves - } - - function performHistorySave() { - try { - const notifications = [] - - for (var i = 0; i < notificationHistory.count; i++) { - const notif = notificationHistory.get(i) - - // Create a shallow copy and fix the timestamp - const copy = Object.assign({}, notif) - copy.timestamp = notif.timestamp.getTime() // Convert Date to milliseconds - notifications.push(copy) - } - - historyAdapter.notifications = notifications - historyAdapter.lastSaved = Date.now() - - historyFileView.writeAdapter() - - Logger.log("Notifications", "Saved", notifications.length, "notifications to history") - } catch (e) { - Logger.error("Notifications", "Failed to save history:", e) - } - } - - // ===== Helper functions ===== - function resolveAppName(notification) { - const appName = notification.appName || "" - - if (!appName.includes(".") || appName.length < 10) { - return appName - } - - // Try desktop entry lookup - const desktopEntries = DesktopEntries.byId(appName) - if (desktopEntries?.length > 0) { - return desktopEntries[0].name || desktopEntries[0].genericName || appName - } - - // Clean up reverse domain notation - const parts = appName.split(".") - if (parts.length > 1) { - const lastPart = parts[parts.length - 1] - return lastPart.charAt(0).toUpperCase() + lastPart.slice(1) - } - - return appName - } - - function resolveNotificationImage(notification) { - const image = notification?.image || "" - if (image) { - return image - } - - const icon = notification?.appIcon || "" - if (!icon) - return "" - - // Handle absolute paths and file URLs - if (icon.startsWith("/")) - return icon - if (icon.startsWith("file://")) - return icon.substring(7) - - // Resolve the icon - return AppIcons.iconFromName(icon) - } - function formatTimestamp(timestamp) { if (!timestamp) return "" - const diff = Date.now() - timestamp.getTime() - if (diff < 60000) return "now" if (diff < 3600000) @@ -571,46 +361,14 @@ Singleton { return `${Math.floor(diff / 86400000)}d ago` } - function strip_tags_regex(text) { - return text.replace(/<[^>]*>?/gm, '') - } - - // ===== Signals ===== + // Signals & connections signal animateAndRemove(string notificationId, int index) - // ===== Public API ===== - function dismissActiveNotification(notificationId) { - dismissNotification(notificationId) - } - - function dismissAllActive() { - while (activeNotifications.count > 0) { - const notif = activeNotifications.get(0) - dismissNotification(notif.id) - } - } - - function invokeAction(notificationId, actionIdentifier) { - const rawNotification = activeNotificationMap[notificationId] - if (rawNotification && rawNotification.actions) { - for (let action of rawNotification.actions) { - if (action.identifier === actionIdentifier && action.invoke) { - action.invoke() - return true - } - } - } - return false - } - - // ===== Do Not Disturb handler ===== Connections { target: Settings.data.notifications function onDoNotDisturbChanged() { const enabled = Settings.data.notifications.doNotDisturb - const label = enabled ? "'Do not disturb' enabled" : "'Do not disturb' disabled" - const description = enabled ? "You'll find these notifications in your history." : "Showing all notifications." - ToastService.showNotice(label, description) + ToastService.showNotice(enabled ? "'Do not disturb' enabled" : "'Do not disturb' disabled", enabled ? "You'll find these notifications in your history." : "Showing all notifications.") } } } From 71ce858b32746a13675a29a9aad4300828826ea2 Mon Sep 17 00:00:00 2001 From: Ly-sec Date: Sun, 21 Sep 2025 10:59:44 +0200 Subject: [PATCH 05/10] Notification: fix saving/deleting notifications --- Services/NotificationService.qml | 41 +++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/Services/NotificationService.qml b/Services/NotificationService.qml index a88e281a..ebea1087 100644 --- a/Services/NotificationService.qml +++ b/Services/NotificationService.qml @@ -209,12 +209,13 @@ Singleton { // Persistence FileView { + id: historyFileView path: historyFile printErrors: false onLoaded: loadHistory() onLoadFailed: error => { if (error === 2) - writeAdapter() + writeAdapter() } JsonAdapter { @@ -243,6 +244,8 @@ Singleton { items.push(copy) } adapter.notifications = items + // Actually write the file + historyFileView.writeAdapter() } catch (e) { Logger.error("Notifications", "Save failed:", e) } @@ -261,6 +264,16 @@ Singleton { time = new Date() } + // Check if we have a cached image and try to use it + let cachedImage = item.cachedImage || "" + if (item.originalImage && item.originalImage.startsWith("image://") && !cachedImage) { + // Try to generate the expected cached path + const imageId = generateImageId(item, item.originalImage) + if (imageId) { + cachedImage = Settings.cacheDirImagesNotifications + imageId + ".png" + } + } + notificationHistory.append({ "id": item.id || "", "summary": item.summary || "", @@ -269,7 +282,7 @@ Singleton { "urgency": item.urgency || 1, "timestamp": time, "originalImage": item.originalImage || "", - "cachedImage": item.cachedImage || "" + "cachedImage": cachedImage }) } } catch (e) { @@ -342,8 +355,30 @@ Singleton { return false } + function removeFromHistory(notificationId) { + for (let i = 0; i < notificationHistory.count; i++) { + const notif = notificationHistory.get(i) + if (notif.id === notificationId) { + // Delete cached image if it exists + if (notif.cachedImage && !notif.cachedImage.startsWith("image://")) { + Quickshell.execDetached(["rm", "-f", notif.cachedImage]) + } + notificationHistory.remove(i) + saveHistory() + return true + } + } + return false + } + function clearHistory() { - Quickshell.execDetached(["rm", "-rf", Settings.cacheDirImagesNotifications + "*"]) + // Remove all cached images + try { + Quickshell.execDetached(["sh", "-c", `rm -rf "${Settings.cacheDirImagesNotifications}"*`]) + } catch (e) { + Logger.error("Notifications", "Failed to clear cache directory:", e) + } + notificationHistory.clear() saveHistory() } From fac9b8f54c0680e7e58f3bea31f25edd50894821 Mon Sep 17 00:00:00 2001 From: Ly-sec Date: Sun, 21 Sep 2025 11:12:18 +0200 Subject: [PATCH 06/10] NotificationService: fix width/height warning --- Services/NotificationService.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Services/NotificationService.qml b/Services/NotificationService.qml index ebea1087..81b719e2 100644 --- a/Services/NotificationService.qml +++ b/Services/NotificationService.qml @@ -27,8 +27,8 @@ Singleton { // Simple image cacher PanelWindow { - width: 1 - height: 1 + implicitHeight: 1 + implicitWidth: 1 color: "transparent" mask: Region {} From 0279b5654a075999bc580bd7449fa57fc5af6e59 Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sun, 21 Sep 2025 10:24:47 -0400 Subject: [PATCH 07/10] Notifications: minor renaming + house keeping. Bring back the close history when clearing all notifications --- Commons/Time.qml | 110 ++++++++++-------- Modules/Bar/Widgets/NotificationHistory.qml | 2 +- Modules/Notification/Notification.qml | 4 +- .../Notification/NotificationHistoryPanel.qml | 14 ++- Services/NotificationService.qml | 94 +++++++-------- 5 files changed, 114 insertions(+), 110 deletions(-) diff --git a/Commons/Time.qml b/Commons/Time.qml index 009ec3e6..9c01cc96 100644 --- a/Commons/Time.qml +++ b/Commons/Time.qml @@ -8,6 +8,7 @@ import qs.Services Singleton { id: root + // Current date property var date: new Date() // Returns a Unix Timestamp (in seconds) @@ -15,62 +16,73 @@ Singleton { return Math.floor(date / 1000) } - - /** - * Formats a Date object into a YYYYMMDD-HHMMSS string. - * @param {Date} [date=new Date()] - The date to format. Defaults to the current date and time. - * @returns {string} The formatted date string. - */ - function getFormattedTimestamp(date = new Date()) { - const year = date.getFullYear() - - // getMonth() is zero-based, so we add 1 - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - - const hours = String(date.getHours()).padStart(2, '0') - const minutes = String(date.getMinutes()).padStart(2, '0') - const seconds = String(date.getSeconds()).padStart(2, '0') - - return `${year}${month}${day}-${hours}${minutes}${seconds}` -} - -// Format an easy to read approximate duration ex: 4h32m -// Used to display the time remaining on the Battery widget, computer uptime, etc.. -function formatVagueHumanReadableDuration(totalSeconds) { - if (typeof totalSeconds !== 'number' || totalSeconds < 0) { - return '0s' + Timer { + interval: 1000 + repeat: true + running: true + onTriggered: root.date = new Date() } - // Floor the input to handle decimal seconds - totalSeconds = Math.floor(totalSeconds) + // Formats a Date object into a YYYYMMDD-HHMMSS string. + function getFormattedTimestamp(date) { + if (!date) { + date = new Date() + } + const year = date.getFullYear() - const days = Math.floor(totalSeconds / 86400) - const hours = Math.floor((totalSeconds % 86400) / 3600) - const minutes = Math.floor((totalSeconds % 3600) / 60) - const seconds = totalSeconds % 60 + // getMonth() is zero-based, so we add 1 + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') - const parts = [] - if (days) - parts.push(`${days}d`) - if (hours) - parts.push(`${hours}h`) - if (minutes) - parts.push(`${minutes}m`) + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + const seconds = String(date.getSeconds()).padStart(2, '0') - // Only show seconds if no hours and no minutes - if (!hours && !minutes) { - parts.push(`${seconds}s`) + return `${year}${month}${day}-${hours}${minutes}${seconds}` } - return parts.join('') -} + // Format an easy to read approximate duration ex: 4h32m + // Used to display the time remaining on the Battery widget, computer uptime, etc.. + function formatVagueHumanReadableDuration(totalSeconds) { + if (typeof totalSeconds !== 'number' || totalSeconds < 0) { + return '0s' + } -Timer { - interval: 1000 - repeat: true - running: true + // Floor the input to handle decimal seconds + totalSeconds = Math.floor(totalSeconds) - onTriggered: root.date = new Date() -} + const days = Math.floor(totalSeconds / 86400) + const hours = Math.floor((totalSeconds % 86400) / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + + const parts = [] + if (days) + parts.push(`${days}d`) + if (hours) + parts.push(`${hours}h`) + if (minutes) + parts.push(`${minutes}m`) + + // Only show seconds if no hours and no minutes + if (!hours && !minutes) { + parts.push(`${seconds}s`) + } + + return parts.join('') + } + + // Format a date into + function formatRelativeTime(date) { + if (!date) + return "" + const diff = Date.now() - date.getTime() + if (diff < 60000) + return "now" + if (diff < 3600000) + return `${Math.floor(diff / 60000)}m ago` + if (diff < 86400000) + return `${Math.floor(diff / 3600000)}h ago` + return `${Math.floor(diff / 86400000)}d ago` + } } diff --git a/Modules/Bar/Widgets/NotificationHistory.qml b/Modules/Bar/Widgets/NotificationHistory.qml index 1fe5020e..6b2d69f2 100644 --- a/Modules/Bar/Widgets/NotificationHistory.qml +++ b/Modules/Bar/Widgets/NotificationHistory.qml @@ -39,7 +39,7 @@ NIconButton { function computeUnreadCount() { var since = lastSeenTs() var count = 0 - var model = NotificationService.notificationHistory + var model = NotificationService.historyList for (var i = 0; i < model.count; i++) { var item = model.get(i) var ts = item.timestamp instanceof Date ? item.timestamp.getTime() : item.timestamp diff --git a/Modules/Notification/Notification.qml b/Modules/Notification/Notification.qml index 3b2f2e75..2257b027 100644 --- a/Modules/Notification/Notification.qml +++ b/Modules/Notification/Notification.qml @@ -19,7 +19,7 @@ Variants { readonly property real scaling: ScalingService.getScreenScale(modelData) // Access the notification model from the service - UPDATED NAME - property ListModel notificationModel: NotificationService.activeNotifications + property ListModel notificationModel: NotificationService.activeList // If no notification display activated in settings, then show them all active: Settings.isLoaded && modelData && (notificationModel.count > 0) ? (Settings.data.notifications.monitors.includes(modelData.name) || (Settings.data.notifications.monitors.length === 0)) : false @@ -256,7 +256,7 @@ Variants { } NText { - text: `${model.appName || "Unknown App"} · ${NotificationService.formatTimestamp(model.timestamp)}` + text: `${model.appName || "Unknown App"} · ${Time.formatRelativeTime(model.timestamp)}` color: Color.mSecondary font.pointSize: Style.fontSizeXS * scaling } diff --git a/Modules/Notification/NotificationHistoryPanel.qml b/Modules/Notification/NotificationHistoryPanel.qml index 2367ed72..1fe59c93 100644 --- a/Modules/Notification/NotificationHistoryPanel.qml +++ b/Modules/Notification/NotificationHistoryPanel.qml @@ -55,7 +55,11 @@ NPanel { icon: "trash" tooltipText: "Clear history" baseSize: Style.baseWidgetSize * 0.8 - onClicked: NotificationService.clearHistory() + onClicked: { + NotificationService.clearHistory() + // Close panel as there is nothing more to see. + root.close() + } } NIconButton { @@ -75,7 +79,7 @@ NPanel { Layout.fillWidth: true Layout.fillHeight: true Layout.alignment: Qt.AlignHCenter - visible: NotificationService.notificationHistory.count === 0 + visible: NotificationService.historyList.count === 0 spacing: Style.marginL * scaling Item { @@ -119,11 +123,11 @@ NPanel { horizontalPolicy: ScrollBar.AlwaysOff verticalPolicy: ScrollBar.AsNeeded - model: NotificationService.notificationHistory + model: NotificationService.historyList spacing: Style.marginM * scaling clip: true boundsBehavior: Flickable.StopAtBounds - visible: NotificationService.notificationHistory.count > 0 + visible: NotificationService.historyList.count > 0 delegate: Rectangle { property string notificationId: model.id @@ -200,7 +204,7 @@ NPanel { } NText { - text: NotificationService.formatTimestamp(model.timestamp) + text: Time.formatRelativeTime(model.timestamp) font.pointSize: Style.fontSizeXS * scaling color: Color.mSecondary } diff --git a/Services/NotificationService.qml b/Services/NotificationService.qml index 81b719e2..007bfd1e 100644 --- a/Services/NotificationService.qml +++ b/Services/NotificationService.qml @@ -18,8 +18,8 @@ Singleton { property string historyFile: Quickshell.env("NOCTALIA_NOTIF_HISTORY_FILE") || (Settings.cacheDir + "notifications.json") // Models - property ListModel activeNotifications: ListModel {} - property ListModel notificationHistory: ListModel {} + property ListModel activeList: ListModel {} + property ListModel historyList: ListModel {} // Internal state property var activeMap: ({}) @@ -39,6 +39,8 @@ Singleton { visible: true cache: false asynchronous: true + mipmap: true + antialiasing: true onStatusChanged: { if (imageQueue.length === 0) @@ -88,11 +90,11 @@ Singleton { notification.tracked = true notification.closed.connect(() => removeActive(data.id)) - activeNotifications.insert(0, data) - while (activeNotifications.count > maxVisible) { - const last = activeNotifications.get(activeNotifications.count - 1) + activeList.insert(0, data) + while (activeList.count > maxVisible) { + const last = activeList.get(activeList.count - 1) activeMap[last.id]?.dismiss() - activeNotifications.remove(activeNotifications.count - 1) + activeList.remove(activeList.count - 1) } } @@ -147,8 +149,8 @@ Singleton { } function updateImagePath(id, path) { - updateModel(activeNotifications, id, "cachedImage", path) - updateModel(notificationHistory, id, "cachedImage", path) + updateModel(activeList, id, "cachedImage", path) + updateModel(historyList, id, "cachedImage", path) saveHistory() } @@ -162,9 +164,9 @@ Singleton { } function removeActive(id) { - for (var i = 0; i < activeNotifications.count; i++) { - if (activeNotifications.get(i).id === id) { - activeNotifications.remove(i) + for (var i = 0; i < activeList.count; i++) { + if (activeList.get(i).id === id) { + activeList.remove(i) delete activeMap[id] break } @@ -175,13 +177,13 @@ Singleton { Timer { interval: 1000 repeat: true - running: activeNotifications.count > 0 + running: activeList.count > 0 onTriggered: { const now = Date.now() const durations = [3000, 8000, 15000] // low, normal, critical - for (var i = activeNotifications.count - 1; i >= 0; i--) { - const notif = activeNotifications.get(i) + for (var i = activeList.count - 1; i >= 0; i--) { + const notif = activeList.get(i) const elapsed = now - notif.timestamp.getTime() if (elapsed >= durations[notif.urgency] || elapsed >= 8000) { @@ -194,16 +196,15 @@ Singleton { // History management function addToHistory(data) { - notificationHistory.insert(0, data) + historyList.insert(0, data) - while (notificationHistory.count > maxHistory) { - const old = notificationHistory.get(notificationHistory.count - 1) + while (historyList.count > maxHistory) { + const old = historyList.get(historyList.count - 1) if (old.cachedImage && !old.cachedImage.startsWith("image://")) { Quickshell.execDetached(["rm", "-f", old.cachedImage]) } - notificationHistory.remove(notificationHistory.count - 1) + historyList.remove(historyList.count - 1) } - saveHistory() } @@ -215,7 +216,7 @@ Singleton { onLoaded: loadHistory() onLoadFailed: error => { if (error === 2) - writeAdapter() + writeAdapter() } JsonAdapter { @@ -237,8 +238,8 @@ Singleton { function performSave() { try { const items = [] - for (var i = 0; i < notificationHistory.count; i++) { - const n = notificationHistory.get(i) + for (var i = 0; i < historyList.count; i++) { + const n = historyList.get(i) const copy = Object.assign({}, n) copy.timestamp = n.timestamp.getTime() items.push(copy) @@ -253,7 +254,7 @@ Singleton { function loadHistory() { try { - notificationHistory.clear() + historyList.clear() for (const item of adapter.notifications || []) { let time = item.timestamp if (typeof time === "number") { @@ -273,17 +274,17 @@ Singleton { cachedImage = Settings.cacheDirImagesNotifications + imageId + ".png" } } - - notificationHistory.append({ - "id": item.id || "", - "summary": item.summary || "", - "body": item.body || "", - "appName": item.appName || "", - "urgency": item.urgency || 1, - "timestamp": time, - "originalImage": item.originalImage || "", - "cachedImage": cachedImage - }) + + historyList.append({ + "id": item.id || "", + "summary": item.summary || "", + "body": item.body || "", + "appName": item.appName || "", + "urgency": item.urgency || 1, + "timestamp": time, + "originalImage": item.originalImage || "", + "cachedImage": cachedImage + }) } } catch (e) { Logger.error("Notifications", "Load failed:", e) @@ -337,7 +338,7 @@ Singleton { function dismissAllActive() { Object.values(activeMap).forEach(n => n.dismiss()) - activeNotifications.clear() + activeList.clear() activeMap = {} } @@ -356,14 +357,14 @@ Singleton { } function removeFromHistory(notificationId) { - for (let i = 0; i < notificationHistory.count; i++) { - const notif = notificationHistory.get(i) + for (var i = 0; i < historyList.count; i++) { + const notif = historyList.get(i) if (notif.id === notificationId) { // Delete cached image if it exists if (notif.cachedImage && !notif.cachedImage.startsWith("image://")) { Quickshell.execDetached(["rm", "-f", notif.cachedImage]) } - notificationHistory.remove(i) + historyList.remove(i) saveHistory() return true } @@ -378,22 +379,9 @@ Singleton { } catch (e) { Logger.error("Notifications", "Failed to clear cache directory:", e) } - - notificationHistory.clear() - saveHistory() - } - function formatTimestamp(timestamp) { - if (!timestamp) - return "" - const diff = Date.now() - timestamp.getTime() - if (diff < 60000) - return "now" - if (diff < 3600000) - return `${Math.floor(diff / 60000)}m ago` - if (diff < 86400000) - return `${Math.floor(diff / 3600000)}h ago` - return `${Math.floor(diff / 86400000)}d ago` + historyList.clear() + saveHistory() } // Signals & connections From fbd431164ba1d080e54a367a28c8c3312276dd4b Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sun, 21 Sep 2025 10:45:50 -0400 Subject: [PATCH 08/10] Notifications: minor renaming for clarity --- Services/NotificationService.qml | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/Services/NotificationService.qml b/Services/NotificationService.qml index 007bfd1e..af9c16c8 100644 --- a/Services/NotificationService.qml +++ b/Services/NotificationService.qml @@ -228,14 +228,14 @@ Singleton { Timer { id: saveTimer interval: 200 - onTriggered: performSave() + onTriggered: performSaveHistory() } function saveHistory() { saveTimer.restart() } - function performSave() { + function performSaveHistory() { try { const items = [] for (var i = 0; i < historyList.count; i++) { @@ -248,7 +248,7 @@ Singleton { // Actually write the file historyFileView.writeAdapter() } catch (e) { - Logger.error("Notifications", "Save failed:", e) + Logger.error("Notifications", "Save history failed:", e) } } @@ -256,14 +256,7 @@ Singleton { try { historyList.clear() for (const item of adapter.notifications || []) { - let time = item.timestamp - if (typeof time === "number") { - if (time < 1e12) - time *= 1000 - time = new Date(time) - } else { - time = new Date() - } + const time = new Date(item.timestamp) // Check if we have a cached image and try to use it let cachedImage = item.cachedImage || "" From 794853b7bdbe56f28acbdb4079556d01bdd1b829 Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sun, 21 Sep 2025 10:56:27 -0400 Subject: [PATCH 09/10] Notifications: removed hard limit to 100 characters. --- Bin/notifications-test.sh | 2 +- Services/NotificationService.qml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Bin/notifications-test.sh b/Bin/notifications-test.sh index 6b6a1458..32ecd9d2 100755 --- a/Bin/notifications-test.sh +++ b/Bin/notifications-test.sh @@ -4,7 +4,7 @@ echo "Sending test notifications..." # Send a bunch of notifications with numbers for i in {1..4}; do - notify-send "Notification $i" "This is test notification number $i with a very long text that will probably break the layout or maybe not? Who knows?" + notify-send "Notification $i" "This is test notification number $i with a very long text that will probably break the layout or maybe not? Who knows? Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." sleep 1 done diff --git a/Services/NotificationService.qml b/Services/NotificationService.qml index af9c16c8..f92d3b8d 100644 --- a/Services/NotificationService.qml +++ b/Services/NotificationService.qml @@ -113,8 +113,8 @@ Singleton { return { "id": id, - "summary": (n.summary || "").substring(0, 100), - "body": stripTags(n.body || "").substring(0, 100), + "summary": (n.summary || ""), + "body": stripTags(n.body || ""), "appName": getAppName(n.appName), "urgency": n.urgency || 1, "timestamp": time, From 052bdefaab87e2a8d49325b7545ae2453b8c18b6 Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Sun, 21 Sep 2025 12:28:42 -0400 Subject: [PATCH 10/10] Notification: finalization before merge --- Services/NotificationService.qml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Services/NotificationService.qml b/Services/NotificationService.qml index f92d3b8d..be537f22 100644 --- a/Services/NotificationService.qml +++ b/Services/NotificationService.qml @@ -48,6 +48,7 @@ Singleton { const req = imageQueue[0] if (status === Image.Ready) { + Logger.log("Notification", "Caching image to:", req.dest) Quickshell.execDetached(["mkdir", "-p", Settings.cacheDirImagesNotifications]) grabToImage(result => { if (result.saveToFile(req.dest)) @@ -144,6 +145,8 @@ Singleton { "dest": dest, "imageId": imageId }) + + // If we have a single item in the queue, process it immediately if (imageQueue.length === 1) cacher.source = path } @@ -157,6 +160,7 @@ Singleton { function updateModel(model, id, prop, value) { for (var i = 0; i < model.count; i++) { if (model.get(i).id === id) { + model.setProperty(i, prop, "") model.setProperty(i, prop, value) break } @@ -313,12 +317,10 @@ Singleton { if (image.startsWith("image://qsimage/")) { // Try to use app name + summary for uniqueness (summary often contains username) const key = (notification.appName || "") + "|" + (notification.summary || "") - const hash = Checksum.sha256(key) - return hash + return Checksum.sha256(key) } - const hash = Checksum.sha256(image) - return hash + return Checksum.sha256(image) } return "" }