mirror of
https://github.com/zoriya/noctalia-shell.git
synced 2026-08-15 18:43:59 +00:00
Merge branch 'notification-history-improved'
This commit is contained in:
Executable
+46
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user