368 lines
15 KiB
QML
368 lines
15 KiB
QML
|
|
// System notification manager
|
||
|
|
import QtQuick
|
||
|
|
import QtQuick.Controls
|
||
|
|
import Quickshell.Services.Notifications
|
||
|
|
import "root:/Data" as Data
|
||
|
|
import "root:/Core" as Core
|
||
|
|
|
||
|
|
Item {
|
||
|
|
id: root
|
||
|
|
required property var shell
|
||
|
|
required property var notificationServer
|
||
|
|
|
||
|
|
// Dynamic height based on visible notifications
|
||
|
|
property int calculatedHeight: Math.min(notifications.length, maxNotifications) * 100 + 100 // Add 100px for bottom margin
|
||
|
|
|
||
|
|
// Simple array to store notifications with tracking
|
||
|
|
property var notifications: []
|
||
|
|
property int maxNotifications: 5
|
||
|
|
property var animatedNotificationIds: ({}) // Track which notifications have been animated
|
||
|
|
|
||
|
|
// Handle new notifications
|
||
|
|
Connections {
|
||
|
|
target: notificationServer
|
||
|
|
function onNotification(notification) {
|
||
|
|
if (!notification || !notification.id) return
|
||
|
|
|
||
|
|
// Filter empty notifications
|
||
|
|
if (!notification.appName && !notification.summary && !notification.body) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Filter ignored applications (case-insensitive) - same logic as NotificationService
|
||
|
|
var shouldIgnore = false
|
||
|
|
if (notification.appName && Data.Settings.ignoredApps && Data.Settings.ignoredApps.length > 0) {
|
||
|
|
for (var i = 0; i < Data.Settings.ignoredApps.length; i++) {
|
||
|
|
if (Data.Settings.ignoredApps[i].toLowerCase() === notification.appName.toLowerCase()) {
|
||
|
|
shouldIgnore = true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (shouldIgnore) {
|
||
|
|
return // Don't display ignored notifications
|
||
|
|
}
|
||
|
|
|
||
|
|
// Create simple notification object
|
||
|
|
let newNotification = {
|
||
|
|
"id": notification.id,
|
||
|
|
"appName": notification.appName || "App",
|
||
|
|
"summary": notification.summary || "",
|
||
|
|
"body": notification.body || "",
|
||
|
|
"timestamp": Date.now(),
|
||
|
|
"shouldSlideOut": false,
|
||
|
|
"icon": notification.icon || notification.image || notification.appIcon || "",
|
||
|
|
"rawNotification": notification // Keep reference to original
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add to beginning
|
||
|
|
notifications.unshift(newNotification)
|
||
|
|
|
||
|
|
// Trigger model update first to let new notification animate
|
||
|
|
notificationsChanged()
|
||
|
|
|
||
|
|
// Delay trimming to let new notification animate
|
||
|
|
if (notifications.length > maxNotifications) {
|
||
|
|
trimTimer.restart()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Timer to delay trimming notifications (let new ones animate first)
|
||
|
|
Timer {
|
||
|
|
id: trimTimer
|
||
|
|
interval: 500 // Wait 500ms before trimming
|
||
|
|
running: false
|
||
|
|
repeat: false
|
||
|
|
onTriggered: {
|
||
|
|
if (notifications.length > maxNotifications) {
|
||
|
|
notifications = notifications.slice(0, maxNotifications)
|
||
|
|
notificationsChanged()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Global timer to check for expired notifications
|
||
|
|
Timer {
|
||
|
|
id: cleanupTimer
|
||
|
|
interval: Math.min(500, Data.Settings.displayTime / 10) // Check every 500ms or 1/10th of display time, whichever is shorter
|
||
|
|
running: true
|
||
|
|
repeat: true
|
||
|
|
onTriggered: {
|
||
|
|
let currentTime = Date.now()
|
||
|
|
let hasExpiredNotifications = false
|
||
|
|
|
||
|
|
// Mark notifications older than displayTime setting for slide-out
|
||
|
|
for (let i = 0; i < notifications.length; i++) {
|
||
|
|
let notification = notifications[i]
|
||
|
|
let age = currentTime - notification.timestamp
|
||
|
|
if (age >= Data.Settings.displayTime && !notification.shouldSlideOut) {
|
||
|
|
notification.shouldSlideOut = true
|
||
|
|
hasExpiredNotifications = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Trigger update if any notifications were marked for slide-out
|
||
|
|
if (hasExpiredNotifications) {
|
||
|
|
notificationsChanged()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function removeNotification(notificationId) {
|
||
|
|
let initialLength = notifications.length
|
||
|
|
notifications = notifications.filter(function(n) { return n.id !== notificationId })
|
||
|
|
if (notifications.length !== initialLength) {
|
||
|
|
// Remove from animated tracking
|
||
|
|
delete animatedNotificationIds[notificationId]
|
||
|
|
notificationsChanged()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Simple Column with Repeater
|
||
|
|
Column {
|
||
|
|
anchors.right: parent.right
|
||
|
|
anchors.left: parent.left
|
||
|
|
anchors.top: parent.top
|
||
|
|
anchors.bottom: parent.bottom
|
||
|
|
anchors.leftMargin: 40 // Create space on left for top-left corner
|
||
|
|
anchors.rightMargin: Data.Settings.borderWidth + 20 // Border width plus corner space
|
||
|
|
anchors.bottomMargin: 100 // Create more space at bottom for bottom corner
|
||
|
|
spacing: 0
|
||
|
|
|
||
|
|
Repeater {
|
||
|
|
model: notifications.length // Show all notifications, not just maxNotifications
|
||
|
|
|
||
|
|
delegate: Rectangle {
|
||
|
|
id: notificationRect
|
||
|
|
property var notification: index < notifications.length ? notifications[index] : null
|
||
|
|
|
||
|
|
width: 400
|
||
|
|
height: 100
|
||
|
|
color: Data.ThemeManager.bgColor
|
||
|
|
|
||
|
|
// Only bottom visible notification gets bottom-left radius
|
||
|
|
radius: 0
|
||
|
|
bottomLeftRadius: index === Math.min(notifications.length, maxNotifications) - 1 && index < maxNotifications ? 15 : 0
|
||
|
|
|
||
|
|
// Only show if within maxNotifications limit
|
||
|
|
visible: index < maxNotifications
|
||
|
|
|
||
|
|
// Animation state
|
||
|
|
property bool hasSlideIn: false
|
||
|
|
|
||
|
|
// Check for expiration and start slide-out if needed
|
||
|
|
onNotificationChanged: {
|
||
|
|
if (notification && notification.shouldSlideOut && !slideOutAnimation.running) {
|
||
|
|
slideOutAnimation.start()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Start off-screen for new notifications
|
||
|
|
Component.onCompleted: {
|
||
|
|
if (notification) {
|
||
|
|
// Check if notification should slide out immediately
|
||
|
|
if (notification.shouldSlideOut) {
|
||
|
|
slideOutAnimation.start()
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if this notification is truly new (recently added)
|
||
|
|
let notificationAge = Date.now() - notification.timestamp
|
||
|
|
let shouldAnimate = !animatedNotificationIds[notification.id] && notificationAge < 1000 // Only animate if less than 1 second old
|
||
|
|
|
||
|
|
if (shouldAnimate) {
|
||
|
|
x = 420
|
||
|
|
opacity = 0
|
||
|
|
hasSlideIn = false
|
||
|
|
slideInAnimation.start()
|
||
|
|
// Mark as animated
|
||
|
|
animatedNotificationIds[notification.id] = true
|
||
|
|
} else {
|
||
|
|
x = 0
|
||
|
|
opacity = 1
|
||
|
|
hasSlideIn = true
|
||
|
|
// Mark as animated if not already
|
||
|
|
animatedNotificationIds[notification.id] = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Slide-in animation
|
||
|
|
ParallelAnimation {
|
||
|
|
id: slideInAnimation
|
||
|
|
NumberAnimation {
|
||
|
|
target: notificationRect
|
||
|
|
property: "x"
|
||
|
|
to: 0
|
||
|
|
duration: 300
|
||
|
|
easing.type: Easing.OutCubic
|
||
|
|
}
|
||
|
|
NumberAnimation {
|
||
|
|
target: notificationRect
|
||
|
|
property: "opacity"
|
||
|
|
to: 1
|
||
|
|
duration: 300
|
||
|
|
easing.type: Easing.OutCubic
|
||
|
|
}
|
||
|
|
onFinished: {
|
||
|
|
hasSlideIn = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Slide-out animation
|
||
|
|
ParallelAnimation {
|
||
|
|
id: slideOutAnimation
|
||
|
|
NumberAnimation {
|
||
|
|
target: notificationRect
|
||
|
|
property: "x"
|
||
|
|
to: 420
|
||
|
|
duration: 250
|
||
|
|
easing.type: Easing.InCubic
|
||
|
|
}
|
||
|
|
NumberAnimation {
|
||
|
|
target: notificationRect
|
||
|
|
property: "opacity"
|
||
|
|
to: 0
|
||
|
|
duration: 250
|
||
|
|
easing.type: Easing.InCubic
|
||
|
|
}
|
||
|
|
onFinished: {
|
||
|
|
if (notification) {
|
||
|
|
removeNotification(notification.id)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Click to dismiss
|
||
|
|
MouseArea {
|
||
|
|
anchors.fill: parent
|
||
|
|
onClicked: slideOutAnimation.start()
|
||
|
|
}
|
||
|
|
|
||
|
|
// Content
|
||
|
|
Row {
|
||
|
|
anchors.fill: parent
|
||
|
|
anchors.margins: 15
|
||
|
|
spacing: 12
|
||
|
|
|
||
|
|
// App icon
|
||
|
|
Rectangle {
|
||
|
|
width: 32
|
||
|
|
height: 32
|
||
|
|
radius: 16
|
||
|
|
color: Qt.rgba(255, 255, 255, 0.1)
|
||
|
|
anchors.verticalCenter: parent.verticalCenter
|
||
|
|
|
||
|
|
// Application icon (if available)
|
||
|
|
Image {
|
||
|
|
id: appIcon
|
||
|
|
source: {
|
||
|
|
if (!notification || !notification.icon) return ""
|
||
|
|
|
||
|
|
let icon = notification.icon
|
||
|
|
|
||
|
|
// Apply same processing as tray system
|
||
|
|
if (icon.includes("?path=")) {
|
||
|
|
const [name, path] = icon.split("?path=");
|
||
|
|
const fileName = name.substring(name.lastIndexOf("/") + 1);
|
||
|
|
return `file://${path}/${fileName}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Handle file paths properly
|
||
|
|
if (icon.startsWith('/')) {
|
||
|
|
return "file://" + icon
|
||
|
|
}
|
||
|
|
|
||
|
|
return icon
|
||
|
|
}
|
||
|
|
anchors.fill: parent
|
||
|
|
anchors.margins: 2
|
||
|
|
fillMode: Image.PreserveAspectFit
|
||
|
|
smooth: true
|
||
|
|
visible: source.toString() !== ""
|
||
|
|
|
||
|
|
onStatusChanged: {
|
||
|
|
// Icon status handling can be added here if needed
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fallback text (first letter of app name)
|
||
|
|
Text {
|
||
|
|
anchors.centerIn: parent
|
||
|
|
text: notification && notification.appName ? notification.appName.charAt(0).toUpperCase() : "!"
|
||
|
|
color: Data.ThemeManager.accentColor
|
||
|
|
font.family: "Roboto"
|
||
|
|
font.pixelSize: 16
|
||
|
|
font.bold: true
|
||
|
|
visible: !appIcon.visible
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Content
|
||
|
|
Column {
|
||
|
|
anchors.verticalCenter: parent.verticalCenter
|
||
|
|
width: parent.width - 60
|
||
|
|
spacing: 4
|
||
|
|
|
||
|
|
Text {
|
||
|
|
text: notification ? notification.appName : ""
|
||
|
|
color: Data.ThemeManager.accentColor
|
||
|
|
font.family: "Roboto"
|
||
|
|
font.bold: true
|
||
|
|
font.pixelSize: 15
|
||
|
|
width: Math.min(parent.width, 250) // Earlier line break
|
||
|
|
elide: Text.ElideRight
|
||
|
|
}
|
||
|
|
|
||
|
|
Text {
|
||
|
|
text: notification ? notification.summary : ""
|
||
|
|
color: Data.ThemeManager.fgColor
|
||
|
|
font.family: "Roboto"
|
||
|
|
font.pixelSize: 14
|
||
|
|
width: Math.min(parent.width, 250) // Earlier line break
|
||
|
|
wrapMode: Text.Wrap
|
||
|
|
maximumLineCount: 2
|
||
|
|
elide: Text.ElideRight
|
||
|
|
visible: text.length > 0
|
||
|
|
}
|
||
|
|
|
||
|
|
Text {
|
||
|
|
text: notification ? notification.body : ""
|
||
|
|
color: Qt.lighter(Data.ThemeManager.fgColor, 1.3)
|
||
|
|
font.family: "Roboto"
|
||
|
|
font.pixelSize: 13
|
||
|
|
width: Math.min(parent.width, 250) // Earlier line break
|
||
|
|
wrapMode: Text.Wrap
|
||
|
|
maximumLineCount: 2
|
||
|
|
elide: Text.ElideRight
|
||
|
|
visible: text.length > 0
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Top corner for first notification
|
||
|
|
Core.Corners {
|
||
|
|
position: "bottomright"
|
||
|
|
size: 1.3
|
||
|
|
fillColor: Data.ThemeManager.bgColor
|
||
|
|
offsetX: -361
|
||
|
|
offsetY: -13
|
||
|
|
visible: index === 0 && index < maxNotifications
|
||
|
|
}
|
||
|
|
|
||
|
|
// Bottom corner for last visible notification
|
||
|
|
Core.Corners {
|
||
|
|
position: "bottomright"
|
||
|
|
size: 1.3
|
||
|
|
fillColor: Data.ThemeManager.bgColor
|
||
|
|
offsetX: 39
|
||
|
|
offsetY: 78
|
||
|
|
visible: index === Math.min(notifications.length, maxNotifications) - 1 && index < maxNotifications
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|