Merge branch 'notification-history-improved'

This commit is contained in:
LemmyCook
2025-09-21 12:28:55 -04:00
14 changed files with 656 additions and 559 deletions
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env -S bash
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? 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
echo "All notifications sent!"
# Additional tests for icon/image handling
if command -v notify-send >/dev/null 2>&1; then
echo "Sending icon/image tests..."
# 1) Themed icon name
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/steam.png"
if [ -f "$SAMPLE_IMG" ]; then
notify-send -i "$SAMPLE_IMG" "Absolute path test" "Should show the provided image path"
fi
# 3) file:// URL form
if [ -f "$SAMPLE_IMG" ]; then
notify-send -i "file://$SAMPLE_IMG" "file:// URL test" "Should display after stripping scheme"
fi
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
-32
View File
@@ -1,32 +0,0 @@
#!/usr/bin/env -S bash
echo "Sending 8 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"
sleep 1
done
echo "All notifications sent!"
# Additional tests for icon/image handling
if command -v notify-send >/dev/null 2>&1; then
echo "Sending icon/image tests..."
# 1) Themed icon name
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"
if [ -f "$SAMPLE_IMG" ]; then
notify-send -i "$SAMPLE_IMG" "Absolute path test" "Should show the provided image path"
fi
# 3) file:// URL form
if [ -f "$SAMPLE_IMG" ]; then
notify-send -i "file://$SAMPLE_IMG" "file:// URL test" "Should display after stripping scheme"
fi
echo "Icon/image tests sent!"
fi
+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
+61 -49
View File
@@ -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`
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ NIconButton {
function computeUnreadCount() {
var since = lastSeenTs()
var count = 0
var model = NotificationService.historyModel
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
+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.activeList
// 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.dismissActiveNotification(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"} · ${Time.formatRelativeTime(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
}
}
}
@@ -12,8 +12,8 @@ import qs.Widgets
NPanel {
id: root
preferredWidth: 380
preferredHeight: 500
preferredWidth: 360
preferredHeight: 480
panelKeyboardFocus: true
panelContent: Rectangle {
@@ -49,7 +49,6 @@ 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 {
@@ -58,6 +57,7 @@ NPanel {
baseSize: Style.baseWidgetSize * 0.8
onClicked: {
NotificationService.clearHistory()
// Close panel as there is nothing more to see.
root.close()
}
}
@@ -66,9 +66,7 @@ NPanel {
icon: "close"
tooltipText: "Close"
baseSize: Style.baseWidgetSize * 0.8
onClicked: {
root.close()
}
onClicked: root.close()
}
}
@@ -81,7 +79,7 @@ NPanel {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.alignment: Qt.AlignHCenter
visible: NotificationService.historyModel.count === 0
visible: NotificationService.historyList.count === 0
spacing: Style.marginL * scaling
Item {
@@ -125,13 +123,15 @@ NPanel {
horizontalPolicy: ScrollBar.AlwaysOff
verticalPolicy: ScrollBar.AsNeeded
model: NotificationService.historyModel
model: NotificationService.historyList
spacing: Style.marginM * scaling
clip: true
boundsBehavior: Flickable.StopAtBounds
visible: NotificationService.historyModel.count > 0
visible: NotificationService.historyList.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 +139,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: Time.formatRelativeTime(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 +227,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 +239,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 +249,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
}
}
}
}
+350 -335
View File
@@ -1,377 +1,392 @@
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
property NotificationServer server: NotificationServer {
id: notificationServer
// Configuration
property int maxVisible: 5
property int maxHistory: 100
property string historyFile: Quickshell.env("NOCTALIA_NOTIF_HISTORY_FILE") || (Settings.cacheDir + "notifications.json")
// Server capabilities
// Models
property ListModel activeList: ListModel {}
property ListModel historyList: ListModel {}
// Internal state
property var activeMap: ({})
property var imageQueue: []
// Simple image cacher
PanelWindow {
implicitHeight: 1
implicitWidth: 1
color: "transparent"
mask: Region {}
Image {
id: cacher
width: 64
height: 64
visible: true
cache: false
asynchronous: true
mipmap: true
antialiasing: true
onStatusChanged: {
if (imageQueue.length === 0)
return
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))
updateImagePath(req.imageId, req.dest)
processNextImage()
})
} else if (status === Image.Error) {
processNextImage()
}
}
function processNextImage() {
imageQueue.shift()
if (imageQueue.length > 0) {
source = imageQueue[0].src
} else {
source = ""
}
}
}
}
// Notification server
NotificationServer {
keepOnReload: false
imageSupported: true
actionsSupported: true
actionIconsSupported: true
bodyMarkupSupported: true
bodySupported: true
persistenceSupported: true
inlineReplySupported: true
bodyHyperlinksSupported: true
bodyImagesSupported: true
onNotification: notification => handleNotification(notification)
}
// Signal when notification is received
onNotification: function (notification) {
// Always add notification to history
root.addToHistory(notification)
// Main handler
function handleNotification(notification) {
const data = createData(notification)
addToHistory(data)
// Check if do-not-disturb is enabled
if (Settings.data.notifications && Settings.data.notifications.doNotDisturb) {
if (Settings.data.notifications?.doNotDisturb)
return
activeMap[data.id] = notification
notification.tracked = true
notification.closed.connect(() => removeActive(data.id))
activeList.insert(0, data)
while (activeList.count > maxVisible) {
const last = activeList.get(activeList.count - 1)
activeMap[last.id]?.dismiss()
activeList.remove(activeList.count - 1)
}
}
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()
}))
const image = n.image || getIcon(n.appIcon)
const imageId = generateImageId(n, image)
queueImage(image, imageId)
return {
"id": id,
"summary": (n.summary || ""),
"body": stripTags(n.body || ""),
"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 we have a single item in the queue, process it immediately
if (imageQueue.length === 1)
cacher.source = path
}
function updateImagePath(id, path) {
updateModel(activeList, id, "cachedImage", path)
updateModel(historyList, 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, "")
model.setProperty(i, prop, value)
break
}
// 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)
}
}
// List model to hold notifications
property ListModel notificationModel: ListModel {}
// Persistent history of notifications (most recent first)
property ListModel historyModel: ListModel {}
property int maxHistory: 100
// Cached history file path
property string historyFile: Quickshell.env("NOCTALIA_NOTIF_HISTORY_FILE") || (Settings.cacheDir + "notifications.json")
// Persisted storage for history
property FileView historyFileView: FileView {
id: historyFileView
objectName: "notificationHistoryFileView"
path: historyFile
printErrors: false
watchChanges: true
onFileChanged: reload()
onAdapterUpdated: writeAdapter()
Component.onCompleted: reload()
onLoaded: loadFromHistory()
onLoadFailed: function (error) {
// Create file on first use
if (error.toString().includes("No such file") || error === 2) {
writeAdapter()
function removeActive(id) {
for (var i = 0; i < activeList.count; i++) {
if (activeList.get(i).id === id) {
activeList.remove(i)
delete activeMap[id]
break
}
}
JsonAdapter {
id: historyAdapter
property var history: []
property real timestamp: 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
}
}
// Auto-hide timer
property Timer hideTimer: Timer {
interval: 1000 // Check every second
Timer {
interval: 1000
repeat: true
running: notificationModel.count > 0
running: activeList.count > 0
onTriggered: {
if (notificationModel.count === 0) {
return
}
const now = Date.now()
const durations = [3000, 8000, 15000] // low, normal, critical
// 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
for (var i = activeList.count - 1; i >= 0; i--) {
const notif = activeList.get(i)
const elapsed = now - notif.timestamp.getTime()
// 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
}
if (elapsed >= durations[notif.urgency] || elapsed >= 8000) {
animateAndRemove(notif.id, i)
break
}
}
}
}
// History management
function addToHistory(data) {
historyList.insert(0, data)
while (historyList.count > maxHistory) {
const old = historyList.get(historyList.count - 1)
if (old.cachedImage && !old.cachedImage.startsWith("image://")) {
Quickshell.execDetached(["rm", "-f", old.cachedImage])
}
historyList.remove(historyList.count - 1)
}
saveHistory()
}
// Persistence
FileView {
id: historyFileView
path: historyFile
printErrors: false
onLoaded: loadHistory()
onLoadFailed: error => {
if (error === 2)
writeAdapter()
}
JsonAdapter {
id: adapter
property var notifications: []
}
}
Timer {
id: saveTimer
interval: 200
onTriggered: performSaveHistory()
}
function saveHistory() {
saveTimer.restart()
}
function performSaveHistory() {
try {
const items = []
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)
}
adapter.notifications = items
// Actually write the file
historyFileView.writeAdapter()
} catch (e) {
Logger.error("Notifications", "Save history failed:", e)
}
}
function loadHistory() {
try {
historyList.clear()
for (const item of adapter.notifications || []) {
const time = new Date(item.timestamp)
// 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"
}
}
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)
}
}
// 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 || "")
return Checksum.sha256(key)
}
return Checksum.sha256(image)
}
return ""
}
// Public API
function dismissActiveNotification(id) {
activeMap[id]?.dismiss()
removeActive(id)
}
function dismissAllActive() {
Object.values(activeMap).forEach(n => n.dismiss())
activeList.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
}
}
return false
}
function removeFromHistory(notificationId) {
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])
}
historyList.remove(i)
saveHistory()
return true
}
}
return false
}
function clearHistory() {
// Remove all cached images
try {
Quickshell.execDetached(["sh", "-c", `rm -rf "${Settings.cacheDirImagesNotifications}"*`])
} catch (e) {
Logger.error("Notifications", "Failed to clear cache directory:", e)
}
historyList.clear()
saveHistory()
}
// Signals & connections
signal animateAndRemove(string notificationId, int index)
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()
}
function clearHistory() {
historyModel.clear()
saveHistory()
}
function loadFromHistory() {
// Populate in-memory model from adapter
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
}
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()
})
}
} catch (e) {
Logger.error("Notifications", "Failed to load history:", e)
}
}
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
Qt.callLater(function () {
historyFileView.writeAdapter()
})
} catch (e) {
Logger.error("Notifications", "Failed to save history:", e)
}
}
// Signal to trigger animation before removal
signal animateAndRemove(var notification, int index)
// 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
}
}
}
// 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 to format timestamp
function formatTimestamp(timestamp) {
if (!timestamp)
return ""
const now = new Date()
const diff = now - timestamp
// Less than 1 minute
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`
const enabled = Settings.data.notifications.doNotDisturb
ToastService.showNotice(enabled ? "'Do not disturb' enabled" : "'Do not disturb' disabled", enabled ? "You'll find these notifications in your history." : "Showing all notifications.")
}
}
}
+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()
+2 -1
View File
@@ -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
@@ -63,7 +64,7 @@ Rectangle {
}
}
//Border
// Border
Rectangle {
anchors.fill: parent
radius: parent.radius
+1
View File
@@ -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
+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