From cf5c26522b488aeef85873ecf30189da88cec9f0 Mon Sep 17 00:00:00 2001 From: purian23 Date: Mon, 14 Jul 2025 10:50:48 -0400 Subject: [PATCH] Initial implementation of grouped notifications --- Services/NotificationGroupingService.qml | 270 +++++++++++ Services/qmldir | 3 +- Widgets/NotificationHistoryPopup.qml | 588 +++++++++++++++-------- shell.qml | 9 +- 4 files changed, 654 insertions(+), 216 deletions(-) create mode 100644 Services/NotificationGroupingService.qml diff --git a/Services/NotificationGroupingService.qml b/Services/NotificationGroupingService.qml new file mode 100644 index 00000000..66202368 --- /dev/null +++ b/Services/NotificationGroupingService.qml @@ -0,0 +1,270 @@ +import QtQuick +import Quickshell +pragma Singleton +pragma ComponentBehavior: Bound + +Singleton { + id: root + + // Grouped notifications model + property var 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: ({}) + + // Configuration + property int maxNotificationsPerGroup: 10 + property int maxGroups: 20 + + Component.onCompleted: { + groupedNotifications = Qt.createQmlObject(` + import QtQuick + ListModel {} + `, root) + } + + // Add a new notification to the appropriate group + function addNotification(notificationObj) { + if (!notificationObj || !notificationObj.appName) { + console.warn("Invalid notification object:", notificationObj) + return + } + + 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) + + groupedNotifications.append({ + "appName": appName, + "appIcon": notificationObj.appIcon || "", + "notifications": notificationsList, + "totalCount": 1, + "latestNotification": notificationObj, + "expanded": false, + "timestamp": notificationObj.timestamp + }) + + 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) + + // Update group metadata + groupedNotifications.setProperty(groupIndex, "totalCount", group.totalCount + 1) + groupedNotifications.setProperty(groupIndex, "latestNotification", notificationObj) + groupedNotifications.setProperty(groupIndex, "timestamp", notificationObj.timestamp) + + // Keep only max notifications per group + while (group.notifications.count > maxNotificationsPerGroup) { + group.notifications.remove(group.notifications.count - 1) + } + + // Move group to front (most recent activity) + moveGroupToFront(groupIndex) + } + + // Move a group to the front of the list + function moveGroupToFront(groupIndex) { + if (groupIndex === 0) return // Already at front + + const group = groupedNotifications.get(groupIndex) + if (!group) return + + // Remove from current position + groupedNotifications.remove(groupIndex) + + // Insert at front + groupedNotifications.insert(0, group) + + // Update group map + 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) + groupedNotifications.setProperty(groupIndex, "latestNotification", newLatest) + } + } + + 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() + 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 + } + + // 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 + } + } +} \ No newline at end of file diff --git a/Services/qmldir b/Services/qmldir index 264ba80c..39d8eccd 100644 --- a/Services/qmldir +++ b/Services/qmldir @@ -14,4 +14,5 @@ singleton LauncherService 1.0 LauncherService.qml singleton NiriWorkspaceService 1.0 NiriWorkspaceService.qml singleton CalendarService 1.0 CalendarService.qml singleton UserInfoService 1.0 UserInfoService.qml -singleton FocusedWindowService 1.0 FocusedWindowService.qml \ No newline at end of file +singleton FocusedWindowService 1.0 FocusedWindowService.qml +singleton NotificationGroupingService 1.0 NotificationGroupingService.qml diff --git a/Widgets/NotificationHistoryPopup.qml b/Widgets/NotificationHistoryPopup.qml index 7ef6c20f..8f6942a8 100644 --- a/Widgets/NotificationHistoryPopup.qml +++ b/Widgets/NotificationHistoryPopup.qml @@ -5,6 +5,7 @@ import Quickshell import Quickshell.Widgets import Quickshell.Wayland import "../Common" +import "../Services" PanelWindow { id: notificationHistoryPopup @@ -89,7 +90,7 @@ PanelWindow { height: 28 radius: Theme.cornerRadius anchors.verticalCenter: parent.verticalCenter - visible: notificationHistory.count > 0 + visible: NotificationGroupingService.totalCount > 0 color: clearArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : @@ -128,6 +129,7 @@ PanelWindow { cursorShape: Qt.PointingHandCursor onClicked: { + NotificationGroupingService.clearAllNotifications() notificationHistory.clear() } } @@ -149,48 +151,193 @@ PanelWindow { } } - // Notification List + // Grouped Notification List ScrollView { width: parent.width height: parent.height - 120 clip: true ListView { - id: notificationListView - model: notificationHistory - spacing: Theme.spacingS + id: groupedNotificationListView + model: NotificationGroupingService.groupedNotifications + spacing: Theme.spacingM - delegate: Rectangle { - width: notificationListView.width - height: 80 - radius: Theme.cornerRadius - color: notifArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08) + delegate: Column { + width: groupedNotificationListView.width + spacing: Theme.spacingXS - // Close button for individual notification + property var groupData: model + property bool isExpanded: model.expanded || false + + // Group Header Rectangle { - width: 24 - height: 24 - radius: 12 - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: 8 - color: closeNotifArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" + width: parent.width + height: 56 + radius: Theme.cornerRadius + color: groupHeaderArea.containsMouse ? + Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : + Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08) - Text { - anchors.centerIn: parent - text: "close" - font.family: Theme.iconFont - font.pixelSize: 14 - color: closeNotifArea.containsMouse ? Theme.primary : Theme.surfaceText + Row { + anchors.fill: parent + anchors.margins: Theme.spacingM + spacing: Theme.spacingM + + // App Icon + Rectangle { + width: 32 + height: 32 + radius: width / 2 + color: Theme.primaryContainer + anchors.verticalCenter: parent.verticalCenter + + // Material icon fallback + Loader { + active: !model.appIcon || model.appIcon === "" + anchors.fill: parent + sourceComponent: Text { + anchors.centerIn: parent + text: "apps" + font.family: Theme.iconFont + font.pixelSize: 16 + color: Theme.primaryText + } + } + + // App icon + Loader { + active: model.appIcon && model.appIcon !== "" + anchors.centerIn: parent + sourceComponent: IconImage { + width: 24 + height: 24 + asynchronous: true + source: { + if (!model.appIcon) return "" + if (model.appIcon.startsWith("file://") || model.appIcon.startsWith("/")) { + return model.appIcon + } + return Quickshell.iconPath(model.appIcon, "image-missing") + } + } + } + } + + // App Name and Summary + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 100 + spacing: 2 + + Row { + width: parent.width + spacing: Theme.spacingS + + Text { + text: model.appName || "App" + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + font.weight: Font.Medium + } + + // Notification count badge + Rectangle { + width: Math.max(countText.width + 8, 20) + height: 20 + radius: 10 + color: Theme.primary + visible: model.totalCount > 1 + anchors.verticalCenter: parent.verticalCenter + + Text { + id: countText + anchors.centerIn: parent + text: model.totalCount.toString() + font.pixelSize: Theme.fontSizeSmall + color: Theme.primaryText + font.weight: Font.Medium + } + } + } + + Text { + text: model.latestNotification ? + (model.latestNotification.summary || model.latestNotification.body || "") : "" + font.pixelSize: Theme.fontSizeSmall + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) + width: parent.width + elide: Text.ElideRight + visible: text.length > 0 + } + } + + // Expand/Collapse Icon + Rectangle { + width: 32 + height: 32 + radius: 16 + anchors.verticalCenter: parent.verticalCenter + color: groupHeaderArea.containsMouse ? + Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : + "transparent" + + Text { + anchors.centerIn: parent + text: isExpanded ? "expand_less" : "expand_more" + font.family: Theme.iconFont + font.pixelSize: 20 + color: groupHeaderArea.containsMouse ? Theme.primary : Theme.surfaceText + + Behavior on rotation { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } + } + } + + // Close group button + Rectangle { + width: 24 + height: 24 + radius: 12 + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 6 + color: closeGroupArea.containsMouse ? + Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : + "transparent" + + Text { + anchors.centerIn: parent + text: "close" + font.family: Theme.iconFont + font.pixelSize: 14 + color: closeGroupArea.containsMouse ? Theme.primary : Theme.surfaceText + } + + MouseArea { + id: closeGroupArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + NotificationGroupingService.removeGroup(index) + } + } } MouseArea { - id: closeNotifArea + id: groupHeaderArea anchors.fill: parent + anchors.rightMargin: 32 hoverEnabled: true cursorShape: Qt.PointingHandCursor + onClicked: { - notificationHistory.remove(index) + NotificationGroupingService.toggleGroupExpansion(index) } } @@ -202,202 +349,219 @@ PanelWindow { } } - Row { - anchors.fill: parent - anchors.margins: Theme.spacingM - anchors.rightMargin: 36 // Don't overlap with close button - spacing: Theme.spacingM + // Expanded Notifications List + Loader { + width: parent.width + active: isExpanded - // Notification icon based on EXAMPLE NotificationAppIcon pattern - Rectangle { - width: 48 - height: 48 - radius: width / 2 // Fully rounded like EXAMPLE - color: Theme.primaryContainer - anchors.verticalCenter: parent.verticalCenter - - // Material icon fallback (when no app icon) - Loader { - active: !model.appIcon || model.appIcon === "" - anchors.fill: parent - sourceComponent: Text { - anchors.centerIn: parent - text: "notifications" - font.family: Theme.iconFont - font.pixelSize: 20 - color: Theme.primaryText - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - } - - // App icon (when no notification image) - Loader { - active: model.appIcon && model.appIcon !== "" && (!model.image || model.image === "") - anchors.centerIn: parent - sourceComponent: IconImage { - width: 32 - height: 32 - asynchronous: true - source: { - if (!model.appIcon) return "" - // Handle file:// URLs directly - if (model.appIcon.startsWith("file://") || model.appIcon.startsWith("/")) { - return model.appIcon - } - // Otherwise treat as icon name - return Quickshell.iconPath(model.appIcon, "image-missing") - } - } - } - - // Notification image (like Discord user avatar) - PRIORITY - Loader { - active: model.image && model.image !== "" - anchors.fill: parent - sourceComponent: Item { - anchors.fill: parent - - Image { - id: historyNotifImage - anchors.fill: parent - - source: model.image || "" - fillMode: Image.PreserveAspectCrop - cache: true // Enable caching for history - antialiasing: true - asynchronous: true - smooth: true - - // Use the parent size for optimization - sourceSize.width: parent.width - sourceSize.height: parent.height - - layer.enabled: true - layer.effect: MultiEffect { - maskEnabled: true - maskSource: Rectangle { - width: 48 - height: 48 - radius: 24 // Fully rounded - } - } - - onStatusChanged: { - if (status === Image.Error) { - console.warn("Failed to load notification history image:", source) - } else if (status === Image.Ready) { - console.log("Notification history image loaded:", source, "size:", sourceSize.width + "x" + sourceSize.height) - } - } - } - - // Fallback to app icon when primary image fails - Loader { - active: model.appIcon && model.appIcon !== "" && historyNotifImage.status === Image.Error - anchors.centerIn: parent - sourceComponent: IconImage { - width: 32 - height: 32 - asynchronous: true - source: { - if (!model.appIcon) return "" - if (model.appIcon.startsWith("file://") || model.appIcon.startsWith("/")) { - return model.appIcon - } - return Quickshell.iconPath(model.appIcon, "image-missing") - } - } - } - - // Small app icon overlay when showing notification image - Loader { - active: model.appIcon && model.appIcon !== "" && historyNotifImage.status === Image.Ready - anchors.bottom: parent.bottom - anchors.right: parent.right - sourceComponent: IconImage { - width: 16 - height: 16 - asynchronous: true - source: { - if (!model.appIcon) return "" - if (model.appIcon.startsWith("file://") || model.appIcon.startsWith("/")) { - return model.appIcon - } - return Quickshell.iconPath(model.appIcon, "image-missing") - } - } - } - } - } - } - - // Content - Column { - anchors.verticalCenter: parent.verticalCenter - width: parent.width - 80 + sourceComponent: Column { + width: parent.width spacing: Theme.spacingXS - Text { - text: model.appName || "App" - font.pixelSize: Theme.fontSizeSmall - color: Theme.primary - font.weight: Font.Medium + Repeater { + model: groupData.notifications + + delegate: Rectangle { + width: parent.width + height: 80 + radius: Theme.cornerRadius + color: notifArea.containsMouse ? + Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : + Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08) + + // Individual notification close button + Rectangle { + width: 24 + height: 24 + radius: 12 + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 8 + color: closeNotifArea.containsMouse ? + Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : + "transparent" + + Text { + anchors.centerIn: parent + text: "close" + font.family: Theme.iconFont + font.pixelSize: 14 + color: closeNotifArea.containsMouse ? Theme.primary : Theme.surfaceText + } + + MouseArea { + id: closeNotifArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + // Use the parent ListView's index to get the group index + let groupIndex = parent.parent.parent.parent.parent.index + NotificationGroupingService.removeNotification(groupIndex, model.index) + } + } + } + + Row { + anchors.fill: parent + anchors.margins: Theme.spacingM + anchors.rightMargin: 36 + spacing: Theme.spacingM + + // Notification icon + Rectangle { + width: 48 + height: 48 + radius: width / 2 + color: Theme.primaryContainer + anchors.verticalCenter: parent.verticalCenter + + // Material icon fallback + Loader { + active: !model.appIcon || model.appIcon === "" + anchors.fill: parent + sourceComponent: Text { + anchors.centerIn: parent + text: "notifications" + font.family: Theme.iconFont + font.pixelSize: 20 + color: Theme.primaryText + } + } + + // App icon (when no notification image) + Loader { + active: model.appIcon && model.appIcon !== "" && (!model.image || model.image === "") + anchors.centerIn: parent + sourceComponent: IconImage { + width: 32 + height: 32 + asynchronous: true + source: { + if (!model.appIcon) return "" + if (model.appIcon.startsWith("file://") || model.appIcon.startsWith("/")) { + return model.appIcon + } + return Quickshell.iconPath(model.appIcon, "image-missing") + } + } + } + + // Notification image (priority) + Loader { + active: model.image && model.image !== "" + anchors.fill: parent + sourceComponent: Item { + anchors.fill: parent + + Image { + id: notifImage + anchors.fill: parent + source: model.image || "" + fillMode: Image.PreserveAspectCrop + cache: true + antialiasing: true + asynchronous: true + smooth: true + sourceSize.width: parent.width + sourceSize.height: parent.height + + layer.enabled: true + layer.effect: MultiEffect { + maskEnabled: true + maskSource: Rectangle { + width: 48 + height: 48 + radius: 24 + } + } + } + + // Small app icon overlay + Loader { + active: model.appIcon && model.appIcon !== "" && notifImage.status === Image.Ready + anchors.bottom: parent.bottom + anchors.right: parent.right + sourceComponent: IconImage { + width: 16 + height: 16 + asynchronous: true + source: { + if (!model.appIcon) return "" + if (model.appIcon.startsWith("file://") || model.appIcon.startsWith("/")) { + return model.appIcon + } + return Quickshell.iconPath(model.appIcon, "image-missing") + } + } + } + } + } + } + + // Notification content + Column { + anchors.verticalCenter: parent.verticalCenter + width: parent.width - 80 + spacing: Theme.spacingXS + + Text { + text: model.summary || "" + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + font.weight: Font.Medium + width: parent.width + elide: Text.ElideRight + visible: text.length > 0 + } + + Text { + text: model.body || "" + font.pixelSize: Theme.fontSizeSmall + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) + width: parent.width + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + visible: text.length > 0 + } + } + } + + MouseArea { + id: notifArea + anchors.fill: parent + anchors.rightMargin: 32 + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + if (model && root.handleNotificationClick) { + root.handleNotificationClick(model) + } + // Use the parent ListView's index to get the group index + let groupIndex = parent.parent.parent.parent.parent.index + NotificationGroupingService.removeNotification(groupIndex, model.index) + } + } + + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } } - - Text { - text: model.summary || "" - font.pixelSize: Theme.fontSizeMedium - color: Theme.surfaceText - font.weight: Font.Medium - width: parent.width - elide: Text.ElideRight - visible: text.length > 0 - } - - Text { - text: model.body || "" - font.pixelSize: Theme.fontSizeSmall - color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) - width: parent.width - wrapMode: Text.WordWrap - maximumLineCount: 2 - elide: Text.ElideRight - visible: text.length > 0 - } - } - } - - MouseArea { - id: notifArea - anchors.fill: parent - anchors.rightMargin: 32 // Don't overlap with close button area - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - - onClicked: { - // Try to handle notification click if it has actions - if (model && root.handleNotificationClick) { - root.handleNotificationClick(model) - } - // Remove from history after handling - notificationHistory.remove(index) - } - } - - Behavior on color { - ColorAnimation { - duration: Theme.shortDuration - easing.type: Theme.standardEasing } } } } - // Empty state - properly centered + // Empty state Item { anchors.fill: parent - visible: notificationHistory.count === 0 + visible: NotificationGroupingService.totalCount === 0 Column { anchors.centerIn: parent @@ -424,7 +588,7 @@ PanelWindow { Text { anchors.horizontalCenter: parent.horizontalCenter - text: "Notifications will appear here" + text: "Notifications will appear here grouped by app" font.pixelSize: Theme.fontSizeMedium color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.4) horizontalAlignment: Text.AlignHCenter diff --git a/shell.qml b/shell.qml index 053653fb..f7f33724 100644 --- a/shell.qml +++ b/shell.qml @@ -245,10 +245,13 @@ ShellRoot { "notification": notification // Keep reference for action handling } - // Add to history (prepend to show newest first) + // Add to grouped notifications + NotificationGroupingService.addNotification(notifObj) + + // Also add to legacy flat history for backwards compatibility notificationHistory.insert(0, notifObj) - // Keep only last 50 notifications + // Keep only last 50 notifications in flat history while (notificationHistory.count > 50) { notificationHistory.remove(notificationHistory.count - 1) } @@ -303,7 +306,7 @@ ShellRoot { bluetoothAvailable: root.bluetoothAvailable bluetoothEnabled: root.bluetoothEnabled shellRoot: root - notificationCount: notificationHistory.count + notificationCount: NotificationGroupingService.totalCount processDropdown: processListDropdown // Connect tray menu properties