Notification service: Full refactoring to support image caching for history.

This commit is contained in:
LemmyCook
2025-09-20 18:04:34 -04:00
parent aed7440c5b
commit 1ad6969d9b
12 changed files with 731 additions and 423 deletions
@@ -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
+2
View File
@@ -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
+1 -1
View File
@@ -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
+108 -87
View File
@@ -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
}
}
}
@@ -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
}
}
}
}
@@ -241,7 +241,6 @@ ColumnLayout {
Layout.bottomMargin: Style.marginM * scaling
}
NDateTimeTokens {
Layout.fillWidth: true
height: 200 * scaling
+520 -281
View File
@@ -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)
}
}
}
+2 -4
View File
@@ -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
-4
View File
@@ -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()
+1 -1
View File
@@ -64,7 +64,7 @@ Rectangle {
}
}
//Border
// Border
Rectangle {
anchors.fill: parent
radius: parent.radius
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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