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