1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2025-12-06 21:45:38 -05:00
Files
DankMaterialShell/Services/NotificationGroupingService.qml
2025-07-14 20:32:06 -04:00

530 lines
19 KiB
QML

import QtQuick
import Quickshell
pragma Singleton
pragma ComponentBehavior: Bound
Singleton {
id: root
// Grouped notifications model - initialize as ListModel directly
property ListModel groupedNotifications: ListModel {}
// Total count of all notifications across all groups
property int totalCount: 0
// Map to track group indices by app name for efficient lookups
property var appGroupMap: ({})
// Debounce timer for sorting
property bool _sortDirty: false
Timer {
id: sortTimer
interval: 50 // 50ms debounce interval
onTriggered: {
if (_sortDirty) {
sortGroupsByPriority()
_sortDirty = false
}
}
}
// Configuration
property int maxNotificationsPerGroup: 10
property int maxGroups: 20
// Priority constants for Android 16-style stacking
readonly property int priorityHigh: 2 // Conversations, calls, media
readonly property int priorityNormal: 1 // Regular notifications
readonly property int priorityLow: 0 // System, background updates
// Notification type constants
readonly property int typeConversation: 1
readonly property int typeMedia: 2
readonly property int typeSystem: 3
readonly property int typeNormal: 4
// Format timestamp for display
function formatTimestamp(timestamp) {
if (!timestamp) return ""
const now = new Date()
const notifTime = new Date(timestamp)
const diffMs = now.getTime() - notifTime.getTime()
const diffMinutes = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMinutes < 1) {
return "now"
} else if (diffMinutes < 60) {
return `${diffMinutes}m ago`
} else if (diffHours < 24) {
return `${diffHours}h ago`
} else if (diffDays < 7) {
return `${diffDays}d ago`
} else {
return notifTime.toLocaleDateString()
}
}
// Add a new notification to the appropriate group
function addNotification(notificationObj) {
if (!notificationObj || !notificationObj.appName) {
console.warn("Invalid notification object:", notificationObj)
return
}
// Enhance notification with priority and type detection
notificationObj = enhanceNotification(notificationObj)
const appName = notificationObj.appName
let groupIndex = appGroupMap[appName]
if (groupIndex === undefined) {
// Create new group
groupIndex = createNewGroup(appName, notificationObj)
} else {
// Add to existing group
addToExistingGroup(groupIndex, notificationObj)
}
updateTotalCount()
}
// Create a new notification group
function createNewGroup(appName, notificationObj) {
// Check if we need to remove oldest group
if (groupedNotifications.count >= maxGroups) {
removeOldestGroup()
}
const groupIndex = groupedNotifications.count
const notificationsList = Qt.createQmlObject(`
import QtQuick
ListModel {}
`, root)
notificationsList.append(notificationObj)
// Create properly structured latestNotification object
const latestNotificationData = {
"id": notificationObj.id || "",
"appName": notificationObj.appName || "",
"appIcon": notificationObj.appIcon || "",
"summary": notificationObj.summary || "",
"body": notificationObj.body || "",
"timestamp": notificationObj.timestamp || new Date(),
"priority": notificationObj.priority || priorityNormal,
"notificationType": notificationObj.notificationType || typeNormal,
"urgency": notificationObj.urgency || 1,
"image": notificationObj.image || ""
}
const groupData = {
"appName": appName,
"appIcon": notificationObj.appIcon || "",
"notifications": notificationsList,
"totalCount": 1,
"latestNotification": latestNotificationData,
"expanded": false,
"timestamp": notificationObj.timestamp || new Date(),
"priority": notificationObj.priority || priorityNormal,
"notificationType": notificationObj.notificationType || typeNormal
}
groupedNotifications.append(groupData)
// Sort groups by priority after adding
requestSort()
appGroupMap[appName] = groupIndex
updateGroupMap()
return groupIndex
}
// Add notification to existing group
function addToExistingGroup(groupIndex, notificationObj) {
if (groupIndex >= groupedNotifications.count) {
console.warn("Invalid group index:", groupIndex)
return
}
const group = groupedNotifications.get(groupIndex)
if (!group) return
// Add to front of group (newest first)
group.notifications.insert(0, notificationObj)
// Create a new object with proper property structure for latestNotification
const latestNotificationData = {
"id": notificationObj.id || "",
"appName": notificationObj.appName || "",
"appIcon": notificationObj.appIcon || "",
"summary": notificationObj.summary || "",
"body": notificationObj.body || "",
"timestamp": notificationObj.timestamp || new Date(),
"priority": notificationObj.priority || priorityNormal,
"notificationType": notificationObj.notificationType || typeNormal,
"urgency": notificationObj.urgency || 1,
"image": notificationObj.image || ""
}
// Update group metadata
groupedNotifications.setProperty(groupIndex, "totalCount", group.totalCount + 1)
groupedNotifications.setProperty(groupIndex, "latestNotification", latestNotificationData)
groupedNotifications.setProperty(groupIndex, "timestamp", notificationObj.timestamp || new Date())
// Update group priority if this notification has higher priority
const currentPriority = group.priority || priorityNormal
const newPriority = Math.max(currentPriority, notificationObj.priority || priorityNormal)
groupedNotifications.setProperty(groupIndex, "priority", newPriority)
// Update notification type if needed
if (notificationObj.notificationType === typeConversation ||
notificationObj.notificationType === typeMedia) {
groupedNotifications.setProperty(groupIndex, "notificationType", notificationObj.notificationType)
}
// Keep only max notifications per group
while (group.notifications.count > maxNotificationsPerGroup) {
group.notifications.remove(group.notifications.count - 1)
}
// Re-sort groups by priority after updating
requestSort()
}
// Request a debounced sort
function requestSort() {
_sortDirty = true
sortTimer.restart()
}
// Sort groups by priority and recency
function sortGroupsByPriority() {
if (groupedNotifications.count <= 1) return
for (let i = 0; i < groupedNotifications.count - 1; i++) {
for (let j = 0; j < groupedNotifications.count - i - 1; j++) {
const groupA = groupedNotifications.get(j)
const groupB = groupedNotifications.get(j + 1)
const priorityA = groupA.priority || priorityNormal
const priorityB = groupB.priority || priorityNormal
let shouldSwap = false
if (priorityA !== priorityB) {
if (priorityB > priorityA) {
shouldSwap = true
}
} else {
const timeA = new Date(groupA.timestamp || 0).getTime()
const timeB = new Date(groupB.timestamp || 0).getTime()
if (timeB > timeA) {
shouldSwap = true
}
}
if (shouldSwap) {
// Swap the elements at j and j + 1
groupedNotifications.move(j, j + 1, 1)
}
}
}
updateGroupMap()
}
// Remove the oldest group (least recent activity)
function removeOldestGroup() {
if (groupedNotifications.count === 0) return
const lastIndex = groupedNotifications.count - 1
const group = groupedNotifications.get(lastIndex)
if (group) {
delete appGroupMap[group.appName]
groupedNotifications.remove(lastIndex)
updateGroupMap()
}
}
// Update the app group map after structural changes
function updateGroupMap() {
appGroupMap = {}
for (let i = 0; i < groupedNotifications.count; i++) {
const group = groupedNotifications.get(i)
if (group) {
appGroupMap[group.appName] = i
}
}
}
// Toggle group expansion state
function toggleGroupExpansion(groupIndex) {
if (groupIndex >= groupedNotifications.count) return
const group = groupedNotifications.get(groupIndex)
if (group) {
groupedNotifications.setProperty(groupIndex, "expanded", !group.expanded)
}
}
// Remove a specific notification from a group
function removeNotification(groupIndex, notificationIndex) {
if (groupIndex >= groupedNotifications.count) return
const group = groupedNotifications.get(groupIndex)
if (!group || notificationIndex >= group.notifications.count) return
group.notifications.remove(notificationIndex)
// Update group count
const newCount = group.totalCount - 1
groupedNotifications.setProperty(groupIndex, "totalCount", newCount)
// If group is empty, remove it
if (newCount === 0) {
removeGroup(groupIndex)
} else {
// Update latest notification if we removed the latest one
if (notificationIndex === 0 && group.notifications.count > 0) {
const newLatest = group.notifications.get(0)
// Create a new object with the correct structure
const latestNotificationData = {
"id": newLatest.id || "",
"appName": newLatest.appName || "",
"appIcon": newLatest.appIcon || "",
"summary": newLatest.summary || "",
"body": newLatest.body || "",
"timestamp": newLatest.timestamp || new Date(),
"priority": newLatest.priority || priorityNormal,
"notificationType": newLatest.notificationType || typeNormal,
"urgency": newLatest.urgency || 1,
"image": newLatest.image || ""
}
groupedNotifications.setProperty(groupIndex, "latestNotification", latestNotificationData)
// Update group priority after removal
const newPriority = getGroupPriority(groupIndex)
groupedNotifications.setProperty(groupIndex, "priority", newPriority)
}
}
updateTotalCount()
}
// Remove an entire group
function removeGroup(groupIndex) {
if (groupIndex >= groupedNotifications.count) return
const group = groupedNotifications.get(groupIndex)
if (group) {
delete appGroupMap[group.appName]
groupedNotifications.remove(groupIndex)
updateGroupMap() // Re-map all group indices
updateTotalCount()
}
}
// Clear all notifications
function clearAllNotifications() {
groupedNotifications.clear()
appGroupMap = {}
totalCount = 0
}
// Update total count across all groups
function updateTotalCount() {
let count = 0
for (let i = 0; i < groupedNotifications.count; i++) {
const group = groupedNotifications.get(i)
if (group) {
count += group.totalCount
}
}
totalCount = count
}
// Enhance notification with priority and type detection
function enhanceNotification(notificationObj) {
const enhanced = Object.assign({}, notificationObj)
// Detect notification type and priority
enhanced.notificationType = detectNotificationType(enhanced)
enhanced.priority = detectPriority(enhanced)
return enhanced
}
// Detect notification type based on content and app
function detectNotificationType(notification) {
const appName = notification.appName?.toLowerCase() || ""
const summary = notification.summary?.toLowerCase() || ""
const body = notification.body?.toLowerCase() || ""
// Media notifications
if (appName.includes("music") || appName.includes("player") ||
appName.includes("spotify") || appName.includes("youtube") ||
summary.includes("now playing") || summary.includes("playing")) {
return typeMedia
}
// Conversation notifications
if (appName.includes("message") || appName.includes("chat") ||
appName.includes("telegram") || appName.includes("whatsapp") ||
appName.includes("discord") || appName.includes("slack") ||
summary.includes("message") || body.includes("message")) {
return typeConversation
}
// System notifications
if (appName.includes("system") || appName.includes("update") ||
summary.includes("update") || summary.includes("system")) {
return typeSystem
}
return typeNormal
}
// Detect priority based on type and urgency
function detectPriority(notification) {
const notificationType = notification.notificationType
const urgency = notification.urgency || 1 // Default to normal
// High priority for conversations and media
if (notificationType === typeConversation || notificationType === typeMedia) {
return priorityHigh
}
// Low priority for system notifications
if (notificationType === typeSystem) {
return priorityLow
}
// Use urgency for regular notifications
if (urgency >= 2) {
return priorityHigh
} else if (urgency >= 1) {
return priorityNormal
}
return priorityLow
}
// Get group priority (highest priority notification in group)
function getGroupPriority(groupIndex) {
if (groupIndex >= groupedNotifications.count) return priorityLow
const group = groupedNotifications.get(groupIndex)
if (!group) return priorityLow
let maxPriority = priorityLow
for (let i = 0; i < group.notifications.count; i++) {
const notification = group.notifications.get(i)
if (notification && notification.priority > maxPriority) {
maxPriority = notification.priority
}
}
return maxPriority
}
// Generate smart group summary for collapsed state
function generateGroupSummary(group) {
if (!group || !group.notifications || group.notifications.count === 0) {
return ""
}
const notificationCount = group.notifications.count
const latestNotification = group.notifications.get(0)
if (notificationCount === 1) {
return latestNotification.summary || latestNotification.body || ""
}
// For conversations, show sender names
if (latestNotification.notificationType === typeConversation) {
const senders = []
for (let i = 0; i < Math.min(3, notificationCount); i++) {
const notif = group.notifications.get(i)
if (notif && notif.summary && !senders.includes(notif.summary)) {
senders.push(notif.summary)
}
}
if (senders.length > 0) {
const remaining = notificationCount - senders.length
if (remaining > 0) {
return `${senders.join(", ")} and ${remaining} other${remaining > 1 ? "s" : ""}`
}
return senders.join(", ")
}
}
// For media, show current track info
if (latestNotification.notificationType === typeMedia) {
return latestNotification.summary || "Media playing"
}
// Generic summary for other types
return `${notificationCount} notification${notificationCount > 1 ? "s" : ""}`
}
// Get notification by ID across all groups
function getNotificationById(notificationId) {
for (let i = 0; i < groupedNotifications.count; i++) {
const group = groupedNotifications.get(i)
if (!group) continue
for (let j = 0; j < group.notifications.count; j++) {
const notification = group.notifications.get(j)
if (notification && notification.id === notificationId) {
return {
groupIndex: i,
notificationIndex: j,
notification: notification
}
}
}
}
return null
}
// Get group by app name
function getGroupByAppName(appName) {
const groupIndex = appGroupMap[appName]
if (groupIndex !== undefined) {
return {
groupIndex: groupIndex,
group: groupedNotifications.get(groupIndex)
}
}
return null
}
// Get visible notifications for a group (considering expansion state)
function getVisibleNotifications(groupIndex, maxVisible = 3) {
if (groupIndex >= groupedNotifications.count) return []
const group = groupedNotifications.get(groupIndex)
if (!group) return []
if (group.expanded) {
// Show all notifications when expanded
return group.notifications
} else {
// Show only the latest notification(s) when collapsed
const visibleCount = Math.min(maxVisible, group.notifications.count)
const visible = []
for (let i = 0; i < visibleCount; i++) {
visible.push(group.notifications.get(i))
}
return visible
}
}
}