mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2025-12-11 07:52:50 -05:00
2557 lines
81 KiB
Markdown
2557 lines
81 KiB
Markdown
# Desktop Notifications API Documentation
|
|
|
|
This document describes the Desktop Notifications API available in Quickshell QML for implementing a complete notification daemon that complies with the [Desktop Notifications Specification](https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html).
|
|
|
|
## Import Statement
|
|
|
|
```qml
|
|
import Quickshell.Services.Notifications
|
|
```
|
|
|
|
## Prerequisites
|
|
|
|
- D-Bus service must be available
|
|
- Notifications feature must be enabled during build (`-DSERVICE_NOTIFICATIONS=ON`, default)
|
|
- Your shell must register as a notification daemon to receive notifications
|
|
|
|
## Core Concepts
|
|
|
|
### Desktop Notifications Protocol
|
|
The notifications service implements the complete `org.freedesktop.Notifications` D-Bus interface, allowing your shell to receive notifications from any application that follows the Desktop Notifications Specification. This includes web browsers, email clients, media players, system services, and more.
|
|
|
|
### Capability-Based Architecture
|
|
The notification server operates on an opt-in basis. Most capabilities are disabled by default and must be explicitly enabled based on what your notification UI can support. This ensures applications receive accurate information about what features are available.
|
|
|
|
### Notification Lifecycle
|
|
1. **Reception** - Applications send notifications via D-Bus
|
|
2. **Tracking** - You must explicitly track notifications you want to keep
|
|
3. **Display** - Show notification UI based on properties and capabilities
|
|
4. **Interaction** - Handle user actions like clicking or dismissing
|
|
5. **Closure** - Notifications are closed via expiration, dismissal, or application request
|
|
|
|
## Main Components
|
|
|
|
### 1. NotificationServer
|
|
|
|
The main server that receives and manages notifications from external applications.
|
|
|
|
```qml
|
|
NotificationServer {
|
|
id: notificationServer
|
|
|
|
// Enable capabilities your UI supports
|
|
actionsSupported: true
|
|
imageSupported: true
|
|
bodyMarkupSupported: true
|
|
|
|
onNotification: function(notification) {
|
|
// Must set tracked to true to keep the notification
|
|
notification.tracked = true
|
|
|
|
// Handle the notification in your UI
|
|
showNotification(notification)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Server Capabilities (Properties):**
|
|
- `keepOnReload: bool` - Whether notifications persist across quickshell reloads (default: true)
|
|
- `persistenceSupported: bool` - Whether server advertises persistence capability (default: false)
|
|
- `bodySupported: bool` - Whether body text is supported (default: true)
|
|
- `bodyMarkupSupported: bool` - Whether body markup is supported (default: false)
|
|
- `bodyHyperlinksSupported: bool` - Whether body hyperlinks are supported (default: false)
|
|
- `bodyImagesSupported: bool` - Whether body images are supported (default: false)
|
|
- `actionsSupported: bool` - Whether notification actions are supported (default: false)
|
|
- `actionIconsSupported: bool` - Whether action icons are supported (default: false)
|
|
- `imageSupported: bool` - Whether notification images are supported (default: false)
|
|
- `inlineReplySupported: bool` - Whether inline reply is supported (default: false)
|
|
- `trackedNotifications: ObjectModel<Notification>` - All currently tracked notifications
|
|
- `extraHints: QVector<QString>` - Additional hints to expose to clients
|
|
|
|
**Signals:**
|
|
- `notification(Notification* notification)` - Emitted when a new notification is received
|
|
|
|
**Example:**
|
|
```qml
|
|
NotificationServer {
|
|
// Enable features your notification UI supports
|
|
actionsSupported: true
|
|
imageSupported: true
|
|
bodyMarkupSupported: true
|
|
|
|
onNotification: function(notification) {
|
|
// Track the notification to prevent automatic cleanup
|
|
notification.tracked = true
|
|
|
|
// Connect to closure signal for cleanup
|
|
notification.closed.connect(function(reason) {
|
|
console.log("Notification closed:", NotificationCloseReason.toString(reason))
|
|
})
|
|
|
|
// Show notification popup
|
|
showNotificationPopup(notification)
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. Notification
|
|
|
|
Represents a single notification with all its properties and available actions.
|
|
|
|
**Properties:**
|
|
- `id: quint32` - Unique notification ID (read-only)
|
|
- `tracked: bool` - Whether notification is tracked by the server
|
|
- `lastGeneration: bool` - Whether notification was carried over from previous quickshell generation (read-only)
|
|
- `expireTimeout: qreal` - Timeout in seconds for the notification
|
|
- `appName: QString` - Name of the sending application
|
|
- `appIcon: QString` - Application icon (fallback to desktop entry icon if not provided)
|
|
- `summary: QString` - Main notification text (title)
|
|
- `body: QString` - Detailed notification body
|
|
- `urgency: NotificationUrgency.Enum` - Urgency level (Low, Normal, Critical)
|
|
- `actions: QList<NotificationAction*>` - Available actions
|
|
- `hasActionIcons: bool` - Whether actions have icons
|
|
- `resident: bool` - Whether notification persists after action invocation
|
|
- `transient: bool` - Whether notification should skip persistence
|
|
- `desktopEntry: QString` - Associated desktop entry name
|
|
- `image: QString` - Associated image
|
|
- `hints: QVariantMap` - All raw hints from the client
|
|
- `hasInlineReply: bool` - Whether notification supports inline reply (read-only)
|
|
- `inlineReplyPlaceholder: QString` - Placeholder text for inline reply input (read-only)
|
|
|
|
**Methods:**
|
|
- `expire()` - Close notification as expired
|
|
- `dismiss()` - Close notification as dismissed by user
|
|
- `sendInlineReply(QString replyText)` - Send an inline reply (only if hasInlineReply is true)
|
|
|
|
**Signals:**
|
|
- `closed(NotificationCloseReason.Enum reason)` - Emitted when notification is closed
|
|
|
|
**Example:**
|
|
```qml
|
|
// In your notification UI component
|
|
Rectangle {
|
|
property Notification notification
|
|
|
|
Column {
|
|
Text {
|
|
text: notification.appName
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
text: notification.summary
|
|
font.pixelSize: 16
|
|
}
|
|
|
|
Text {
|
|
text: notification.body
|
|
wrapMode: Text.WordWrap
|
|
visible: notification.body.length > 0
|
|
}
|
|
|
|
// Show notification image if available
|
|
Image {
|
|
source: notification.image
|
|
visible: notification.image.length > 0
|
|
}
|
|
|
|
// Show actions if available
|
|
Row {
|
|
Repeater {
|
|
model: notification.actions
|
|
delegate: Button {
|
|
text: modelData.text
|
|
onClicked: {
|
|
modelData.invoke()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-expire after timeout
|
|
Timer {
|
|
running: notification.expireTimeout > 0
|
|
interval: notification.expireTimeout * 1000
|
|
onTriggered: notification.expire()
|
|
}
|
|
|
|
// Handle user dismissal
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: notification.dismiss()
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. NotificationAction
|
|
|
|
Represents an action that can be taken on a notification.
|
|
|
|
**Properties:**
|
|
- `identifier: QString` - Action identifier (icon name when hasActionIcons is true)
|
|
- `text: QString` - Localized display text for the action
|
|
|
|
**Methods:**
|
|
- `invoke()` - Invoke the action (automatically dismisses non-resident notifications)
|
|
|
|
**Example:**
|
|
```qml
|
|
// Action button in notification
|
|
Button {
|
|
property NotificationAction action
|
|
|
|
text: action.text
|
|
|
|
// Show icon if actions support icons
|
|
icon.name: notificationServer.actionIconsSupported ? action.identifier : ""
|
|
|
|
onClicked: {
|
|
action.invoke()
|
|
// Action automatically handles notification dismissal for non-resident notifications
|
|
}
|
|
}
|
|
```
|
|
|
|
## Enum Types
|
|
|
|
### NotificationUrgency
|
|
|
|
Urgency levels for notifications.
|
|
|
|
**Values:**
|
|
- `NotificationUrgency.Low` - Low priority (value: 0)
|
|
- `NotificationUrgency.Normal` - Normal priority (value: 1)
|
|
- `NotificationUrgency.Critical` - High priority (value: 2)
|
|
|
|
**Methods:**
|
|
- `NotificationUrgency.toString(urgency)` - Convert urgency to string
|
|
|
|
### NotificationCloseReason
|
|
|
|
Reasons why a notification was closed.
|
|
|
|
**Values:**
|
|
- `NotificationCloseReason.Expired` - Notification timed out (value: 1)
|
|
- `NotificationCloseReason.Dismissed` - User explicitly dismissed (value: 2)
|
|
- `NotificationCloseReason.CloseRequested` - Application requested closure (value: 3)
|
|
|
|
**Methods:**
|
|
- `NotificationCloseReason.toString(reason)` - Convert reason to string
|
|
|
|
## Usage Examples
|
|
|
|
### Basic Notification Daemon
|
|
|
|
```qml
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import Quickshell.Services.Notifications
|
|
|
|
ApplicationWindow {
|
|
visible: true
|
|
|
|
NotificationServer {
|
|
id: notificationServer
|
|
|
|
// Enable capabilities based on your UI
|
|
actionsSupported: true
|
|
imageSupported: true
|
|
bodyMarkupSupported: false
|
|
|
|
onNotification: function(notification) {
|
|
// Track notification to prevent cleanup
|
|
notification.tracked = true
|
|
|
|
// Add to notification list
|
|
notificationList.append(notification)
|
|
|
|
// Show popup for urgent notifications
|
|
if (notification.urgency === NotificationUrgency.Critical) {
|
|
showUrgentPopup(notification)
|
|
}
|
|
}
|
|
}
|
|
|
|
ListView {
|
|
id: notificationListView
|
|
anchors.fill: parent
|
|
|
|
model: notificationServer.trackedNotifications
|
|
|
|
delegate: Rectangle {
|
|
width: parent.width
|
|
height: 100
|
|
border.color: getUrgencyColor(modelData.urgency)
|
|
|
|
function getUrgencyColor(urgency) {
|
|
switch (urgency) {
|
|
case NotificationUrgency.Low: return "gray"
|
|
case NotificationUrgency.Normal: return "blue"
|
|
case NotificationUrgency.Critical: return "red"
|
|
default: return "black"
|
|
}
|
|
}
|
|
|
|
Column {
|
|
anchors.margins: 10
|
|
anchors.fill: parent
|
|
|
|
Text {
|
|
text: modelData.appName
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
text: modelData.summary
|
|
font.pixelSize: 14
|
|
}
|
|
|
|
Text {
|
|
text: modelData.body
|
|
wrapMode: Text.WordWrap
|
|
visible: modelData.body.length > 0
|
|
}
|
|
|
|
Row {
|
|
spacing: 10
|
|
|
|
Button {
|
|
text: "Dismiss"
|
|
onClicked: modelData.dismiss()
|
|
}
|
|
|
|
Repeater {
|
|
model: modelData.actions
|
|
delegate: Button {
|
|
text: modelData.text
|
|
onClicked: modelData.invoke()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Notification Popup with Auto-Dismiss
|
|
|
|
```qml
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import Quickshell.Services.Notifications
|
|
|
|
Popup {
|
|
id: notificationPopup
|
|
|
|
property Notification notification
|
|
|
|
width: 300
|
|
height: contentColumn.height + 20
|
|
|
|
// Position in top-right corner
|
|
x: parent.width - width - 20
|
|
y: 20
|
|
|
|
Column {
|
|
id: contentColumn
|
|
anchors.margins: 10
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
spacing: 10
|
|
|
|
Row {
|
|
spacing: 10
|
|
|
|
Image {
|
|
width: 48
|
|
height: 48
|
|
source: notification.image || notification.appIcon
|
|
fillMode: Image.PreserveAspectFit
|
|
}
|
|
|
|
Column {
|
|
Text {
|
|
text: notification.appName
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
text: notification.summary
|
|
font.pixelSize: 16
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: notification.body
|
|
wrapMode: Text.WordWrap
|
|
visible: notification.body.length > 0
|
|
width: parent.width
|
|
}
|
|
|
|
Row {
|
|
spacing: 10
|
|
|
|
Repeater {
|
|
model: notification.actions
|
|
delegate: Button {
|
|
text: modelData.text
|
|
onClicked: {
|
|
modelData.invoke()
|
|
notificationPopup.close()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-close timer
|
|
Timer {
|
|
running: notificationPopup.visible
|
|
interval: notification.expireTimeout > 0 ? notification.expireTimeout * 1000 : 5000
|
|
onTriggered: {
|
|
notification.expire()
|
|
notificationPopup.close()
|
|
}
|
|
}
|
|
|
|
// Dismiss on click
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: {
|
|
notification.dismiss()
|
|
notificationPopup.close()
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Notification History Manager
|
|
|
|
```qml
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import Quickshell.Services.Notifications
|
|
|
|
QtObject {
|
|
id: notificationHistory
|
|
|
|
property var notifications: []
|
|
property int maxNotifications: 100
|
|
|
|
Component.onCompleted: {
|
|
// Connect to notification server
|
|
notificationServer.notification.connect(handleNotification)
|
|
}
|
|
|
|
function handleNotification(notification) {
|
|
// Track notification
|
|
notification.tracked = true
|
|
|
|
// Add to history
|
|
notifications.unshift(notification)
|
|
|
|
// Limit history size
|
|
if (notifications.length > maxNotifications) {
|
|
notifications.pop()
|
|
}
|
|
|
|
// Connect to closure signal
|
|
notification.closed.connect(function(reason) {
|
|
console.log("Notification closed:",
|
|
NotificationCloseReason.toString(reason))
|
|
})
|
|
|
|
// Show notification popup
|
|
showNotificationPopup(notification)
|
|
}
|
|
|
|
function clearHistory() {
|
|
notifications.forEach(function(notification) {
|
|
if (notification.tracked) {
|
|
notification.dismiss()
|
|
}
|
|
})
|
|
notifications = []
|
|
}
|
|
|
|
function getNotificationsByApp(appName) {
|
|
return notifications.filter(function(notification) {
|
|
return notification.appName === appName
|
|
})
|
|
}
|
|
|
|
function getUrgentNotifications() {
|
|
return notifications.filter(function(notification) {
|
|
return notification.urgency === NotificationUrgency.Critical
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
### Android 16-Style Grouped Notifications with Inline Reply
|
|
|
|
This example demonstrates how to implement modern Android 16-style notification grouping with expandable groups, inline reply support, and smart conversation handling.
|
|
|
|
```qml
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import Quickshell.Services.Notifications
|
|
|
|
ApplicationWindow {
|
|
visible: true
|
|
width: 420
|
|
height: 700
|
|
|
|
NotificationServer {
|
|
id: notificationServer
|
|
|
|
// Enable all modern capabilities for Android 16-style notifications
|
|
actionsSupported: true
|
|
imageSupported: true
|
|
bodyMarkupSupported: true
|
|
inlineReplySupported: true
|
|
bodyHyperlinksSupported: true
|
|
|
|
onNotification: function(notification) {
|
|
notification.tracked = true
|
|
notificationManager.addNotification(notification)
|
|
}
|
|
}
|
|
|
|
QtObject {
|
|
id: notificationManager
|
|
|
|
property var groupedNotifications: ({})
|
|
property var expandedGroups: ({})
|
|
|
|
function addNotification(notification) {
|
|
let groupKey = getGroupKey(notification)
|
|
|
|
if (!groupedNotifications[groupKey]) {
|
|
groupedNotifications[groupKey] = {
|
|
key: groupKey,
|
|
appName: notification.appName,
|
|
notifications: [],
|
|
latestNotification: null,
|
|
count: 0,
|
|
hasInlineReply: false,
|
|
isConversation: isConversationApp(notification),
|
|
isMedia: isMediaApp(notification)
|
|
}
|
|
}
|
|
|
|
let group = groupedNotifications[groupKey]
|
|
group.notifications.unshift(notification)
|
|
group.latestNotification = notification
|
|
group.count = group.notifications.length
|
|
|
|
// Check if any notification in group supports inline reply
|
|
if (notification.hasInlineReply) {
|
|
group.hasInlineReply = true
|
|
}
|
|
|
|
// Auto-expand conversation groups with new messages
|
|
if (group.isConversation && group.count > 1) {
|
|
expandedGroups[groupKey] = true
|
|
}
|
|
|
|
// Limit notifications per group
|
|
if (group.notifications.length > 20) {
|
|
let oldNotification = group.notifications.pop()
|
|
oldNotification.dismiss()
|
|
}
|
|
|
|
// Trigger UI update
|
|
updateGroupModel()
|
|
}
|
|
|
|
function getGroupKey(notification) {
|
|
let appName = notification.appName.toLowerCase()
|
|
|
|
// For messaging apps, group by conversation/channel
|
|
if (isConversationApp(notification)) {
|
|
let summary = notification.summary.toLowerCase()
|
|
// Discord channels: "#channel-name"
|
|
if (summary.startsWith("#")) {
|
|
return appName + ":" + summary
|
|
}
|
|
// Direct messages: group by sender name
|
|
if (summary && !summary.includes("new message")) {
|
|
return appName + ":" + summary
|
|
}
|
|
return appName + ":conversation"
|
|
}
|
|
|
|
// Media apps: group all together
|
|
if (isMediaApp(notification)) {
|
|
return appName + ":media"
|
|
}
|
|
|
|
// System notifications: group by type
|
|
if (appName.includes("system") || appName.includes("update")) {
|
|
return "system"
|
|
}
|
|
|
|
// Default: group by app
|
|
return appName
|
|
}
|
|
|
|
function isConversationApp(notification) {
|
|
let appName = notification.appName.toLowerCase()
|
|
return appName.includes("discord") ||
|
|
appName.includes("telegram") ||
|
|
appName.includes("signal") ||
|
|
appName.includes("whatsapp") ||
|
|
appName.includes("slack") ||
|
|
appName.includes("message")
|
|
}
|
|
|
|
function isMediaApp(notification) {
|
|
let appName = notification.appName.toLowerCase()
|
|
return appName.includes("spotify") ||
|
|
appName.includes("music") ||
|
|
appName.includes("player") ||
|
|
appName.includes("vlc")
|
|
}
|
|
|
|
function toggleGroupExpansion(groupKey) {
|
|
expandedGroups[groupKey] = !expandedGroups[groupKey]
|
|
updateGroupModel()
|
|
}
|
|
|
|
function updateGroupModel() {
|
|
let sortedGroups = Object.values(groupedNotifications)
|
|
.sort((a, b) => b.latestNotification.timestamp - a.latestNotification.timestamp)
|
|
notificationRepeater.model = sortedGroups
|
|
}
|
|
|
|
function dismissGroup(groupKey) {
|
|
let group = groupedNotifications[groupKey]
|
|
if (group) {
|
|
group.notifications.forEach(notif => notif.dismiss())
|
|
delete groupedNotifications[groupKey]
|
|
delete expandedGroups[groupKey]
|
|
updateGroupModel()
|
|
}
|
|
}
|
|
|
|
function getGroupSummary(group) {
|
|
if (group.count === 1) {
|
|
return group.latestNotification.summary
|
|
}
|
|
|
|
if (group.isConversation) {
|
|
return `${group.count} new messages`
|
|
} else if (group.isMedia) {
|
|
return "Now playing"
|
|
} else {
|
|
return `${group.count} notifications`
|
|
}
|
|
}
|
|
|
|
function getGroupBody(group) {
|
|
if (group.count === 1) {
|
|
return group.latestNotification.body
|
|
}
|
|
|
|
// For conversations, show latest message preview
|
|
if (group.isConversation) {
|
|
return group.latestNotification.body || "Tap to view messages"
|
|
}
|
|
|
|
return `Latest: ${group.latestNotification.summary}`
|
|
}
|
|
}
|
|
|
|
ScrollView {
|
|
anchors.fill: parent
|
|
anchors.margins: 8
|
|
|
|
Column {
|
|
width: parent.width - 16
|
|
spacing: 8
|
|
|
|
Repeater {
|
|
id: notificationRepeater
|
|
|
|
delegate: GroupedNotificationCard {
|
|
width: parent.width
|
|
group: modelData
|
|
expanded: notificationManager.expandedGroups[modelData.key] || false
|
|
|
|
onToggleExpansion: notificationManager.toggleGroupExpansion(group.key)
|
|
onDismissGroup: notificationManager.dismissGroup(group.key)
|
|
onReplyToLatest: function(replyText) {
|
|
if (group.latestNotification.hasInlineReply) {
|
|
group.latestNotification.sendInlineReply(replyText)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Android 16-style grouped notification card component
|
|
component GroupedNotificationCard: Rectangle {
|
|
id: root
|
|
|
|
property var group
|
|
property bool expanded: false
|
|
|
|
signal toggleExpansion()
|
|
signal dismissGroup()
|
|
signal replyToLatest(string replyText)
|
|
|
|
height: expanded ? expandedContent.height + 32 : collapsedContent.height + 32
|
|
radius: 16
|
|
color: "#1a1a1a"
|
|
border.color: group && group.latestNotification.urgency === NotificationUrgency.Critical ?
|
|
"#ff4444" : "#333333"
|
|
border.width: 1
|
|
|
|
Behavior on height {
|
|
NumberAnimation {
|
|
duration: 200
|
|
easing.type: Easing.OutCubic
|
|
}
|
|
}
|
|
|
|
// Collapsed view - shows summary of the group
|
|
Column {
|
|
id: collapsedContent
|
|
anchors.top: parent.top
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.margins: 16
|
|
spacing: 8
|
|
visible: !expanded
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: 12
|
|
|
|
// App icon or conversation avatar
|
|
Rectangle {
|
|
width: 48
|
|
height: 48
|
|
radius: group && group.isConversation ? 24 : 8
|
|
color: "#333333"
|
|
|
|
Image {
|
|
anchors.fill: parent
|
|
anchors.margins: group && group.isConversation ? 0 : 8
|
|
source: group ? (group.latestNotification.image || group.latestNotification.appIcon) : ""
|
|
fillMode: Image.PreserveAspectCrop
|
|
radius: parent.radius
|
|
}
|
|
}
|
|
|
|
Column {
|
|
width: parent.width - 48 - 12 - 60
|
|
spacing: 4
|
|
|
|
Row {
|
|
width: parent.width
|
|
|
|
Text {
|
|
text: group ? group.appName : ""
|
|
color: "#888888"
|
|
font.pixelSize: 12
|
|
font.weight: Font.Medium
|
|
}
|
|
|
|
Item { width: 8; height: 1 }
|
|
|
|
// Count badge for grouped notifications
|
|
Rectangle {
|
|
width: countText.width + 12
|
|
height: 20
|
|
radius: 10
|
|
color: "#444444"
|
|
visible: group && group.count > 1
|
|
|
|
Text {
|
|
id: countText
|
|
anchors.centerIn: parent
|
|
text: group ? group.count : "0"
|
|
color: "#ffffff"
|
|
font.pixelSize: 11
|
|
font.weight: Font.Bold
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: group ? notificationManager.getGroupSummary(group) : ""
|
|
color: "#ffffff"
|
|
font.pixelSize: 15
|
|
font.weight: Font.Medium
|
|
width: parent.width
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Text {
|
|
text: group ? notificationManager.getGroupBody(group) : ""
|
|
color: "#cccccc"
|
|
font.pixelSize: 13
|
|
width: parent.width
|
|
elide: Text.ElideRight
|
|
maximumLineCount: 1
|
|
}
|
|
}
|
|
|
|
// Expand/dismiss controls
|
|
Column {
|
|
width: 60
|
|
spacing: 4
|
|
|
|
Button {
|
|
width: 32
|
|
height: 32
|
|
text: expanded ? "↑" : "↓"
|
|
visible: group && group.count > 1
|
|
onClicked: toggleExpansion()
|
|
}
|
|
|
|
Button {
|
|
width: 32
|
|
height: 32
|
|
text: "✕"
|
|
onClicked: dismissGroup()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Quick reply for conversations
|
|
Row {
|
|
width: parent.width
|
|
spacing: 8
|
|
visible: group && group.hasInlineReply && !expanded
|
|
|
|
TextField {
|
|
id: quickReplyField
|
|
width: parent.width - 60
|
|
height: 36
|
|
placeholderText: "Reply..."
|
|
background: Rectangle {
|
|
color: "#2a2a2a"
|
|
radius: 18
|
|
border.color: parent.activeFocus ? "#4a9eff" : "#444444"
|
|
}
|
|
color: "#ffffff"
|
|
|
|
onAccepted: {
|
|
if (text.length > 0) {
|
|
replyToLatest(text)
|
|
text = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
Button {
|
|
width: 52
|
|
height: 36
|
|
text: "Send"
|
|
enabled: quickReplyField.text.length > 0
|
|
onClicked: {
|
|
replyToLatest(quickReplyField.text)
|
|
quickReplyField.text = ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Expanded view - shows all notifications in group
|
|
Column {
|
|
id: expandedContent
|
|
anchors.top: parent.top
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.margins: 16
|
|
spacing: 8
|
|
visible: expanded
|
|
|
|
// Group header
|
|
Row {
|
|
width: parent.width
|
|
spacing: 12
|
|
|
|
Rectangle {
|
|
width: 32
|
|
height: 32
|
|
radius: group && group.isConversation ? 16 : 4
|
|
color: "#333333"
|
|
|
|
Image {
|
|
anchors.fill: parent
|
|
anchors.margins: group && group.isConversation ? 0 : 4
|
|
source: group ? group.latestNotification.appIcon : ""
|
|
fillMode: Image.PreserveAspectCrop
|
|
radius: parent.radius
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: group ? `${group.appName} (${group.count})` : ""
|
|
color: "#ffffff"
|
|
font.pixelSize: 16
|
|
font.weight: Font.Bold
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
|
|
Item { Layout.fillWidth: true }
|
|
|
|
Button {
|
|
text: "↑"
|
|
width: 32
|
|
height: 32
|
|
onClicked: toggleExpansion()
|
|
}
|
|
|
|
Button {
|
|
text: "✕"
|
|
width: 32
|
|
height: 32
|
|
onClicked: dismissGroup()
|
|
}
|
|
}
|
|
|
|
// Individual notifications
|
|
Repeater {
|
|
model: group ? group.notifications.slice(0, 10) : [] // Show max 10 expanded
|
|
|
|
delegate: Rectangle {
|
|
width: parent.width
|
|
height: notifContent.height + 16
|
|
radius: 8
|
|
color: "#2a2a2a"
|
|
border.color: modelData.urgency === NotificationUrgency.Critical ?
|
|
"#ff4444" : "transparent"
|
|
|
|
Column {
|
|
id: notifContent
|
|
anchors.top: parent.top
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.margins: 12
|
|
spacing: 6
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: 8
|
|
|
|
Image {
|
|
width: 24
|
|
height: 24
|
|
source: modelData.image || modelData.appIcon
|
|
fillMode: Image.PreserveAspectCrop
|
|
radius: group && group.isConversation ? 12 : 4
|
|
}
|
|
|
|
Column {
|
|
width: parent.width - 32
|
|
spacing: 2
|
|
|
|
Text {
|
|
text: modelData.summary
|
|
color: "#ffffff"
|
|
font.pixelSize: 14
|
|
font.weight: Font.Medium
|
|
width: parent.width
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Text {
|
|
text: modelData.body
|
|
color: "#cccccc"
|
|
font.pixelSize: 13
|
|
width: parent.width
|
|
wrapMode: Text.WordWrap
|
|
maximumLineCount: 3
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
}
|
|
|
|
// Individual notification inline reply
|
|
Row {
|
|
width: parent.width
|
|
spacing: 8
|
|
visible: modelData.hasInlineReply
|
|
|
|
TextField {
|
|
id: replyField
|
|
width: parent.width - 60
|
|
height: 32
|
|
placeholderText: modelData.inlineReplyPlaceholder || "Reply..."
|
|
background: Rectangle {
|
|
color: "#1a1a1a"
|
|
radius: 16
|
|
border.color: parent.activeFocus ? "#4a9eff" : "#444444"
|
|
}
|
|
color: "#ffffff"
|
|
font.pixelSize: 12
|
|
|
|
onAccepted: {
|
|
if (text.length > 0) {
|
|
modelData.sendInlineReply(text)
|
|
text = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
Button {
|
|
width: 52
|
|
height: 32
|
|
text: "Send"
|
|
enabled: replyField.text.length > 0
|
|
onClicked: {
|
|
modelData.sendInlineReply(replyField.text)
|
|
replyField.text = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// Actions
|
|
Row {
|
|
spacing: 8
|
|
visible: modelData.actions && modelData.actions.length > 0
|
|
|
|
Repeater {
|
|
model: modelData.actions
|
|
delegate: Button {
|
|
text: modelData.text
|
|
height: 28
|
|
onClicked: modelData.invoke()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// "Show more" if there are many notifications
|
|
Button {
|
|
text: `Show ${group.count - 10} more notifications...`
|
|
visible: group && group.count > 10
|
|
onClicked: {
|
|
// Implement pagination or full expansion
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tap to expand (only for collapsed state)
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
visible: !expanded && group && group.count > 1
|
|
onClicked: toggleExpansion()
|
|
}
|
|
}
|
|
```
|
|
|
|
### Media Notification Handler
|
|
|
|
```qml
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import Quickshell.Services.Notifications
|
|
|
|
QtObject {
|
|
id: mediaNotificationHandler
|
|
|
|
property var currentMediaNotification: null
|
|
|
|
Component.onCompleted: {
|
|
notificationServer.notification.connect(handleNotification)
|
|
}
|
|
|
|
function handleNotification(notification) {
|
|
notification.tracked = true
|
|
|
|
// Check if this is a media notification
|
|
if (isMediaNotification(notification)) {
|
|
// Replace current media notification
|
|
if (currentMediaNotification) {
|
|
currentMediaNotification.dismiss()
|
|
}
|
|
|
|
currentMediaNotification = notification
|
|
showMediaControls(notification)
|
|
} else {
|
|
// Handle as regular notification
|
|
showRegularNotification(notification)
|
|
}
|
|
}
|
|
|
|
function isMediaNotification(notification) {
|
|
// Check for media-related hints or app names
|
|
return notification.appName.toLowerCase().includes("music") ||
|
|
notification.appName.toLowerCase().includes("player") ||
|
|
notification.hints.hasOwnProperty("x-kde-media-notification") ||
|
|
notification.actions.some(function(action) {
|
|
return action.identifier.includes("media-")
|
|
})
|
|
}
|
|
|
|
function showMediaControls(notification) {
|
|
// Create persistent media control UI
|
|
mediaControlsPopup.notification = notification
|
|
mediaControlsPopup.open()
|
|
}
|
|
|
|
function showRegularNotification(notification) {
|
|
// Show regular notification popup
|
|
regularNotificationPopup.notification = notification
|
|
regularNotificationPopup.open()
|
|
}
|
|
}
|
|
```
|
|
|
|
### Inline Reply Support
|
|
|
|
The notification system now supports inline replies, allowing users to quickly respond to messages directly from the notification without opening the source application.
|
|
|
|
```qml
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import Quickshell.Services.Notifications
|
|
|
|
Popup {
|
|
id: replyableNotificationPopup
|
|
|
|
property Notification notification
|
|
|
|
width: 400
|
|
height: contentColumn.height + 20
|
|
|
|
Column {
|
|
id: contentColumn
|
|
anchors.margins: 10
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
spacing: 10
|
|
|
|
// Notification header
|
|
Row {
|
|
spacing: 10
|
|
|
|
Image {
|
|
width: 48
|
|
height: 48
|
|
source: notification.appIcon
|
|
fillMode: Image.PreserveAspectFit
|
|
}
|
|
|
|
Column {
|
|
Text {
|
|
text: notification.appName
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
text: notification.summary
|
|
font.pixelSize: 16
|
|
}
|
|
}
|
|
}
|
|
|
|
// Notification body
|
|
Text {
|
|
text: notification.body
|
|
wrapMode: Text.WordWrap
|
|
width: parent.width
|
|
visible: notification.body.length > 0
|
|
}
|
|
|
|
// Inline reply input (only shown if supported)
|
|
Row {
|
|
width: parent.width
|
|
spacing: 10
|
|
visible: notification.hasInlineReply
|
|
|
|
TextField {
|
|
id: replyField
|
|
width: parent.width - sendButton.width - 10
|
|
placeholderText: notification.inlineReplyPlaceholder || "Type a reply..."
|
|
|
|
onAccepted: sendReply()
|
|
}
|
|
|
|
Button {
|
|
id: sendButton
|
|
text: "Send"
|
|
enabled: replyField.text.length > 0
|
|
|
|
onClicked: sendReply()
|
|
}
|
|
}
|
|
|
|
// Regular actions
|
|
Row {
|
|
spacing: 10
|
|
visible: notification.actions.length > 0 && !notification.hasInlineReply
|
|
|
|
Repeater {
|
|
model: notification.actions
|
|
delegate: Button {
|
|
text: modelData.text
|
|
onClicked: {
|
|
modelData.invoke()
|
|
replyableNotificationPopup.close()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function sendReply() {
|
|
if (replyField.text.length > 0) {
|
|
notification.sendInlineReply(replyField.text)
|
|
replyableNotificationPopup.close()
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Advanced Inline Reply Implementation
|
|
|
|
```qml
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import Quickshell.Services.Notifications
|
|
|
|
ApplicationWindow {
|
|
visible: true
|
|
|
|
NotificationServer {
|
|
id: notificationServer
|
|
|
|
// Enable inline reply support
|
|
inlineReplySupported: true
|
|
actionsSupported: true
|
|
imageSupported: true
|
|
|
|
onNotification: function(notification) {
|
|
notification.tracked = true
|
|
|
|
// Create appropriate UI based on notification capabilities
|
|
if (notification.hasInlineReply) {
|
|
createReplyableNotification(notification)
|
|
} else {
|
|
createStandardNotification(notification)
|
|
}
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: replyableNotificationComponent
|
|
|
|
Rectangle {
|
|
property Notification notification
|
|
|
|
width: 350
|
|
height: contentColumn.implicitHeight + 20
|
|
radius: 10
|
|
color: "#2a2a2a"
|
|
border.color: notification.urgency === NotificationUrgency.Critical ?
|
|
"#ff4444" : "#444444"
|
|
|
|
Column {
|
|
id: contentColumn
|
|
anchors.margins: 15
|
|
anchors.fill: parent
|
|
spacing: 12
|
|
|
|
// Header with app info
|
|
Row {
|
|
width: parent.width
|
|
spacing: 10
|
|
|
|
Image {
|
|
width: 40
|
|
height: 40
|
|
source: notification.appIcon
|
|
fillMode: Image.PreserveAspectFit
|
|
}
|
|
|
|
Column {
|
|
width: parent.width - 50
|
|
|
|
Text {
|
|
text: notification.appName
|
|
color: "#888888"
|
|
font.pixelSize: 12
|
|
}
|
|
|
|
Text {
|
|
text: notification.summary
|
|
color: "#ffffff"
|
|
font.pixelSize: 14
|
|
font.bold: true
|
|
wrapMode: Text.WordWrap
|
|
width: parent.width
|
|
}
|
|
}
|
|
}
|
|
|
|
// Message body
|
|
Text {
|
|
text: notification.body
|
|
color: "#cccccc"
|
|
wrapMode: Text.WordWrap
|
|
width: parent.width
|
|
visible: notification.body.length > 0
|
|
}
|
|
|
|
// Inline reply section
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 40
|
|
radius: 5
|
|
color: "#1a1a1a"
|
|
border.color: replyField.activeFocus ? "#4488ff" : "#333333"
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.margins: 5
|
|
spacing: 5
|
|
|
|
TextField {
|
|
id: replyField
|
|
width: parent.width - 60
|
|
height: parent.height
|
|
placeholderText: notification.inlineReplyPlaceholder
|
|
color: "#ffffff"
|
|
background: Rectangle { color: "transparent" }
|
|
|
|
onAccepted: {
|
|
if (text.length > 0) {
|
|
notification.sendInlineReply(text)
|
|
notificationItem.destroy()
|
|
}
|
|
}
|
|
}
|
|
|
|
Button {
|
|
width: 50
|
|
height: parent.height
|
|
text: "↵"
|
|
enabled: replyField.text.length > 0
|
|
|
|
onClicked: {
|
|
notification.sendInlineReply(replyField.text)
|
|
notificationItem.destroy()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Dismiss button
|
|
Text {
|
|
text: "✕"
|
|
color: "#666666"
|
|
font.pixelSize: 16
|
|
anchors.right: parent.right
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: {
|
|
notification.dismiss()
|
|
notificationItem.destroy()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-dismiss timer
|
|
Timer {
|
|
running: notification.expireTimeout > 0 && !replyField.activeFocus
|
|
interval: notification.expireTimeout * 1000
|
|
onTriggered: {
|
|
notification.expire()
|
|
notificationItem.destroy()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function createReplyableNotification(notification) {
|
|
let notificationItem = replyableNotificationComponent.createObject(
|
|
notificationContainer,
|
|
{ notification: notification }
|
|
)
|
|
}
|
|
}
|
|
```
|
|
|
|
## Common Patterns
|
|
|
|
### Android 16-Style Notification Grouping
|
|
|
|
```qml
|
|
// Smart grouping by conversation and app
|
|
function getSmartGroupKey(notification) {
|
|
const appName = notification.appName.toLowerCase()
|
|
|
|
// Messaging apps: group by conversation/channel
|
|
if (isMessagingApp(appName)) {
|
|
const summary = notification.summary.toLowerCase()
|
|
|
|
// Discord channels: "#general", "#announcements"
|
|
if (summary.startsWith("#")) {
|
|
return `${appName}:${summary}`
|
|
}
|
|
|
|
// Direct messages: group by sender name
|
|
if (summary && !summary.includes("new message")) {
|
|
return `${appName}:dm:${summary}`
|
|
}
|
|
|
|
// Fallback to app-level grouping
|
|
return `${appName}:messages`
|
|
}
|
|
|
|
// Media: replace previous media notification
|
|
if (isMediaApp(appName)) {
|
|
return `${appName}:nowplaying`
|
|
}
|
|
|
|
// System notifications: group by category
|
|
if (appName.includes("system")) {
|
|
if (notification.summary.toLowerCase().includes("update")) {
|
|
return "system:updates"
|
|
}
|
|
if (notification.summary.toLowerCase().includes("battery")) {
|
|
return "system:battery"
|
|
}
|
|
return "system:general"
|
|
}
|
|
|
|
// Default: group by app
|
|
return appName
|
|
}
|
|
|
|
function isMessagingApp(appName) {
|
|
return ["discord", "telegram", "signal", "whatsapp", "slack", "vesktop"].some(
|
|
app => appName.includes(app)
|
|
)
|
|
}
|
|
|
|
function isMediaApp(appName) {
|
|
return ["spotify", "vlc", "mpv", "music", "player"].some(
|
|
app => appName.includes(app)
|
|
)
|
|
}
|
|
```
|
|
|
|
### Collapsible Notification Groups with Inline Reply
|
|
|
|
```qml
|
|
component AndroidStyleNotificationGroup: Rectangle {
|
|
id: root
|
|
|
|
property var notificationGroup
|
|
property bool expanded: false
|
|
property bool hasUnread: notificationGroup.notifications.some(n => !n.read)
|
|
|
|
height: expanded ? expandedHeight : collapsedHeight
|
|
radius: 16
|
|
color: "#1e1e1e"
|
|
border.color: hasUnread ? "#4a9eff" : "#333333"
|
|
border.width: hasUnread ? 2 : 1
|
|
|
|
readonly property int collapsedHeight: 80
|
|
readonly property int expandedHeight: Math.min(400, 80 + (notificationGroup.notifications.length * 60))
|
|
|
|
Behavior on height {
|
|
NumberAnimation {
|
|
duration: 250
|
|
easing.type: Easing.OutCubic
|
|
}
|
|
}
|
|
|
|
// Collapsed view - shows latest notification + count
|
|
Item {
|
|
anchors.fill: parent
|
|
anchors.margins: 16
|
|
visible: !expanded
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
spacing: 12
|
|
|
|
// Avatar/Icon
|
|
Rectangle {
|
|
width: 48
|
|
height: 48
|
|
radius: notificationGroup.isConversation ? 24 : 8
|
|
color: "#333333"
|
|
|
|
Image {
|
|
anchors.fill: parent
|
|
anchors.margins: notificationGroup.isConversation ? 0 : 8
|
|
source: notificationGroup.latestNotification.image ||
|
|
notificationGroup.latestNotification.appIcon
|
|
fillMode: Image.PreserveAspectCrop
|
|
radius: parent.radius
|
|
}
|
|
|
|
// Unread indicator
|
|
Rectangle {
|
|
width: 12
|
|
height: 12
|
|
radius: 6
|
|
color: "#4a9eff"
|
|
anchors.top: parent.top
|
|
anchors.right: parent.right
|
|
anchors.margins: -2
|
|
visible: hasUnread
|
|
}
|
|
}
|
|
|
|
// Content
|
|
Column {
|
|
width: parent.width - 48 - 12 - 80
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 4
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: notificationGroup.appName
|
|
color: "#888888"
|
|
font.pixelSize: 12
|
|
font.weight: Font.Medium
|
|
}
|
|
|
|
// Count badge
|
|
Rectangle {
|
|
width: Math.max(20, countText.width + 8)
|
|
height: 16
|
|
radius: 8
|
|
color: "#555555"
|
|
visible: notificationGroup.count > 1
|
|
|
|
Text {
|
|
id: countText
|
|
anchors.centerIn: parent
|
|
text: notificationGroup.count
|
|
color: "#ffffff"
|
|
font.pixelSize: 10
|
|
font.weight: Font.Bold
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: getGroupTitle(notificationGroup)
|
|
color: "#ffffff"
|
|
font.pixelSize: 15
|
|
font.weight: Font.Medium
|
|
width: parent.width
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Text {
|
|
text: notificationGroup.latestNotification.body
|
|
color: "#cccccc"
|
|
font.pixelSize: 13
|
|
width: parent.width
|
|
elide: Text.ElideRight
|
|
maximumLineCount: 1
|
|
}
|
|
}
|
|
|
|
// Controls
|
|
Column {
|
|
width: 80
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 4
|
|
|
|
Button {
|
|
width: 36
|
|
height: 36
|
|
text: "↓"
|
|
visible: notificationGroup.count > 1
|
|
onClicked: expanded = true
|
|
}
|
|
|
|
Button {
|
|
width: 36
|
|
height: 36
|
|
text: "✕"
|
|
onClicked: dismissGroup()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Quick reply for conversations
|
|
Rectangle {
|
|
anchors.bottom: parent.bottom
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.margins: 8
|
|
height: 40
|
|
radius: 20
|
|
color: "#2a2a2a"
|
|
border.color: "#444444"
|
|
visible: notificationGroup.hasInlineReply
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.margins: 8
|
|
spacing: 8
|
|
|
|
TextField {
|
|
id: quickReply
|
|
width: parent.width - 50
|
|
height: parent.height
|
|
placeholderText: "Quick reply..."
|
|
background: Item {}
|
|
color: "#ffffff"
|
|
font.pixelSize: 14
|
|
|
|
onAccepted: sendQuickReply()
|
|
}
|
|
|
|
Button {
|
|
width: 42
|
|
height: parent.height
|
|
text: "→"
|
|
enabled: quickReply.text.length > 0
|
|
onClicked: sendQuickReply()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Expanded view - shows all notifications
|
|
ScrollView {
|
|
anchors.fill: parent
|
|
anchors.margins: 16
|
|
visible: expanded
|
|
|
|
Column {
|
|
width: parent.width
|
|
spacing: 8
|
|
|
|
// Group header
|
|
Row {
|
|
width: parent.width
|
|
spacing: 12
|
|
|
|
Rectangle {
|
|
width: 32
|
|
height: 32
|
|
radius: notificationGroup.isConversation ? 16 : 4
|
|
color: "#333333"
|
|
|
|
Image {
|
|
anchors.fill: parent
|
|
anchors.margins: notificationGroup.isConversation ? 0 : 4
|
|
source: notificationGroup.latestNotification.appIcon
|
|
fillMode: Image.PreserveAspectCrop
|
|
radius: parent.radius
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: `${notificationGroup.appName} (${notificationGroup.count})`
|
|
color: "#ffffff"
|
|
font.pixelSize: 16
|
|
font.weight: Font.Bold
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
|
|
Item { Layout.fillWidth: true }
|
|
|
|
Button {
|
|
text: "↑"
|
|
width: 32
|
|
height: 32
|
|
onClicked: expanded = false
|
|
}
|
|
|
|
Button {
|
|
text: "✕"
|
|
width: 32
|
|
height: 32
|
|
onClicked: dismissGroup()
|
|
}
|
|
}
|
|
|
|
// Individual notifications in conversation style
|
|
Repeater {
|
|
model: notificationGroup.notifications.slice(0, 15) // Show recent 15
|
|
|
|
delegate: Rectangle {
|
|
width: parent.width
|
|
height: messageContent.height + 16
|
|
radius: 8
|
|
color: "#2a2a2a"
|
|
|
|
Column {
|
|
id: messageContent
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
anchors.margins: 12
|
|
spacing: 6
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: 8
|
|
|
|
Rectangle {
|
|
width: 24
|
|
height: 24
|
|
radius: notificationGroup.isConversation ? 12 : 4
|
|
color: "#444444"
|
|
|
|
Image {
|
|
anchors.fill: parent
|
|
source: modelData.image || modelData.appIcon
|
|
fillMode: Image.PreserveAspectCrop
|
|
radius: parent.radius
|
|
}
|
|
}
|
|
|
|
Column {
|
|
width: parent.width - 32
|
|
spacing: 2
|
|
|
|
Row {
|
|
width: parent.width
|
|
|
|
Text {
|
|
text: modelData.summary
|
|
color: "#ffffff"
|
|
font.pixelSize: 14
|
|
font.weight: Font.Medium
|
|
elide: Text.ElideRight
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
Text {
|
|
text: formatTime(modelData.timestamp)
|
|
color: "#888888"
|
|
font.pixelSize: 11
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: modelData.body
|
|
color: "#cccccc"
|
|
font.pixelSize: 13
|
|
width: parent.width
|
|
wrapMode: Text.WordWrap
|
|
maximumLineCount: 4
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
}
|
|
|
|
// Individual inline reply
|
|
Rectangle {
|
|
width: parent.width
|
|
height: 36
|
|
radius: 18
|
|
color: "#1a1a1a"
|
|
border.color: "#444444"
|
|
visible: modelData.hasInlineReply
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
anchors.margins: 6
|
|
spacing: 6
|
|
|
|
TextField {
|
|
id: replyField
|
|
width: parent.width - 40
|
|
height: parent.height
|
|
placeholderText: modelData.inlineReplyPlaceholder || "Reply..."
|
|
background: Item {}
|
|
color: "#ffffff"
|
|
font.pixelSize: 12
|
|
|
|
onAccepted: {
|
|
if (text.length > 0) {
|
|
modelData.sendInlineReply(text)
|
|
text = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
Button {
|
|
width: 34
|
|
height: parent.height
|
|
text: "→"
|
|
enabled: replyField.text.length > 0
|
|
onClicked: {
|
|
modelData.sendInlineReply(replyField.text)
|
|
replyField.text = ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Functions
|
|
function getGroupTitle(group) {
|
|
if (group.count === 1) {
|
|
return group.latestNotification.summary
|
|
}
|
|
|
|
if (group.isConversation) {
|
|
return `${group.count} new messages`
|
|
}
|
|
|
|
return `${group.count} notifications`
|
|
}
|
|
|
|
function sendQuickReply() {
|
|
if (quickReply.text.length > 0 && notificationGroup.hasInlineReply) {
|
|
notificationGroup.latestNotification.sendInlineReply(quickReply.text)
|
|
quickReply.text = ""
|
|
}
|
|
}
|
|
|
|
function dismissGroup() {
|
|
notificationGroup.notifications.forEach(notification => {
|
|
notification.dismiss()
|
|
})
|
|
}
|
|
|
|
function formatTime(timestamp) {
|
|
const now = new Date()
|
|
const diff = now.getTime() - timestamp.getTime()
|
|
const minutes = Math.floor(diff / 60000)
|
|
const hours = Math.floor(minutes / 60)
|
|
|
|
if (hours > 0) return `${hours}h`
|
|
if (minutes > 0) return `${minutes}m`
|
|
return "now"
|
|
}
|
|
|
|
// Tap to expand
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
visible: !expanded && notificationGroup.count > 1
|
|
onClicked: expanded = true
|
|
}
|
|
}
|
|
```
|
|
|
|
### Filtering Notifications by Urgency
|
|
|
|
```qml
|
|
// High priority notifications only
|
|
model: notificationServer.trackedNotifications.filter(function(notification) {
|
|
return notification.urgency === NotificationUrgency.Critical
|
|
})
|
|
```
|
|
|
|
### Auto-dismiss Timer
|
|
|
|
```qml
|
|
Timer {
|
|
property Notification notification
|
|
|
|
running: notification && notification.expireTimeout > 0
|
|
interval: notification.expireTimeout * 1000
|
|
|
|
onTriggered: {
|
|
if (notification) {
|
|
notification.expire()
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Persistent Notification Storage
|
|
|
|
```qml
|
|
QtObject {
|
|
property var persistentNotifications: []
|
|
|
|
function addPersistentNotification(notification) {
|
|
if (!notification.transient) {
|
|
persistentNotifications.push({
|
|
appName: notification.appName,
|
|
summary: notification.summary,
|
|
body: notification.body,
|
|
timestamp: new Date()
|
|
})
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### Capability Management
|
|
- Only enable capabilities your UI can properly handle
|
|
- Test with different notification sources to ensure compatibility
|
|
- Consider performance implications of advanced features
|
|
|
|
### Memory Management
|
|
- Always set `tracked: true` for notifications you want to keep
|
|
- Clean up notification references when no longer needed
|
|
- Use object pools for frequent notification creation/destruction
|
|
|
|
### User Experience for Android 16-Style Notifications
|
|
- **Progressive Disclosure**: Show summary first, expand for details
|
|
- **Smart Grouping**: Group conversations by channel/sender, media by app
|
|
- **Quick Actions**: Provide inline reply for conversations, media controls for audio
|
|
- **Visual Hierarchy**: Use conversation avatars vs app icons appropriately
|
|
- **Count Badges**: Show notification count for groups clearly
|
|
- **Auto-Expansion**: Expand conversation groups when new messages arrive
|
|
- **Smooth Animations**: Use easing transitions for expand/collapse
|
|
- **Contextual UI**: Adapt interface based on notification type (conversation, media, system)
|
|
|
|
### Performance
|
|
- Use efficient data structures for notification storage
|
|
- Implement proper cleanup for dismissed notifications
|
|
- Consider virtualization for large notification lists
|
|
|
|
## Notes
|
|
|
|
- **D-Bus Integration** - The service automatically handles D-Bus registration and interface implementation
|
|
- **Hot Reloading** - Notifications can optionally persist across quickshell reloads
|
|
- **Thread Safety** - All operations are thread-safe and properly synchronized
|
|
- **Specification Compliance** - Fully implements the Desktop Notifications Specification
|
|
- **Image Support** - Handles both file paths and embedded D-Bus image data
|
|
- **Action Icons** - Supports action icons when `actionIconsSupported` is enabled
|
|
- **Markup Support** - Can handle HTML-like markup in notification body when enabled
|
|
- **Inline Reply** - Supports quick replies for messaging applications when enabled
|
|
- You must explicitly track notifications by setting `tracked: true`
|
|
- The server doesn't advertise capabilities by default - you must enable them
|
|
- Actions automatically dismiss non-resident notifications when invoked
|
|
- Notification IDs are unique within the current session
|
|
- Image paths can be local files or embedded D-Bus image data
|
|
|
|
## Migration Strategy
|
|
|
|
### Overview
|
|
This migration strategy helps you transition from other notification systems to Quickshell's native notification implementation, including support for the new inline reply feature.
|
|
|
|
### Phase 1: Assessment
|
|
1. **Inventory Current Features**
|
|
- List all notification features your current setup uses
|
|
- Document custom behaviors and UI elements
|
|
- Note any application-specific handling
|
|
|
|
2. **Capability Mapping**
|
|
- Map your features to Quickshell capabilities:
|
|
- Basic text → `bodySupported` (enabled by default)
|
|
- HTML/Markup → `bodyMarkupSupported`
|
|
- Clickable links → `bodyHyperlinksSupported`
|
|
- Images → `imageSupported`
|
|
- Action buttons → `actionsSupported`
|
|
- Icon buttons → `actionIconsSupported`
|
|
- **Quick replies → `inlineReplySupported`** (NEW)
|
|
- Persistence → `persistenceSupported`
|
|
|
|
### Phase 2: Basic Implementation
|
|
1. **Create Notification Server**
|
|
```qml
|
|
NotificationServer {
|
|
id: notificationServer
|
|
|
|
// Start with minimal capabilities
|
|
actionsSupported: false
|
|
imageSupported: false
|
|
inlineReplySupported: false
|
|
|
|
onNotification: function(notification) {
|
|
notification.tracked = true
|
|
// Basic notification display
|
|
}
|
|
}
|
|
```
|
|
|
|
2. **Test Core Functionality**
|
|
- Send test notifications: `notify-send "Test" "Basic notification"`
|
|
- Verify reception and display
|
|
- Check notification lifecycle
|
|
|
|
### Phase 3: Progressive Enhancement
|
|
1. **Enable Features Incrementally**
|
|
```qml
|
|
NotificationServer {
|
|
// Phase 3.1: Add images
|
|
imageSupported: true
|
|
|
|
// Phase 3.2: Add actions
|
|
actionsSupported: true
|
|
|
|
// Phase 3.3: Add inline replies
|
|
inlineReplySupported: true
|
|
|
|
// Phase 3.4: Add markup
|
|
bodyMarkupSupported: true
|
|
}
|
|
```
|
|
|
|
2. **Implement UI for Each Feature**
|
|
- Images: Add Image component with fallback
|
|
- Actions: Create button row with action handling
|
|
- **Inline Reply: Add TextField with send button** (NEW)
|
|
- Markup: Use Text component with textFormat
|
|
|
|
### Phase 4: Inline Reply Implementation (NEW)
|
|
|
|
1. **Detection and UI Creation**
|
|
```qml
|
|
onNotification: function(notification) {
|
|
notification.tracked = true
|
|
|
|
if (notification.hasInlineReply) {
|
|
// Create UI with reply field
|
|
createReplyableNotification(notification)
|
|
} else {
|
|
// Standard notification UI
|
|
createStandardNotification(notification)
|
|
}
|
|
}
|
|
```
|
|
|
|
2. **Reply UI Component**
|
|
```qml
|
|
// Minimal inline reply UI
|
|
Row {
|
|
visible: notification.hasInlineReply
|
|
|
|
TextField {
|
|
id: replyInput
|
|
placeholderText: notification.inlineReplyPlaceholder
|
|
onAccepted: {
|
|
if (text) notification.sendInlineReply(text)
|
|
}
|
|
}
|
|
|
|
Button {
|
|
text: "Send"
|
|
enabled: replyInput.text.length > 0
|
|
onClicked: {
|
|
notification.sendInlineReply(replyInput.text)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
3. **Testing Inline Reply**
|
|
- Test with messaging apps (Telegram, Discord, etc.)
|
|
- Verify reply delivery
|
|
- Check notification dismissal behavior
|
|
|
|
### Phase 5: Advanced Android 16-Style Features
|
|
|
|
1. **Smart Notification Grouping**
|
|
- Group by application and conversation
|
|
- Implement automatic conversation detection
|
|
- Handle channel-based grouping (Discord, Slack)
|
|
- Smart media notification replacement
|
|
|
|
2. **Interactive Inline Reply**
|
|
- Implement conversation threading for inline replies
|
|
- Auto-expand conversation groups with new messages
|
|
- Quick reply from collapsed notifications
|
|
- Reply persistence and history
|
|
|
|
3. **Android 16-Style UI Elements**
|
|
- Collapsible notification cards with smooth animations
|
|
- Count badges for grouped notifications
|
|
- Conversation avatars vs app icons
|
|
- Progressive disclosure (show latest, expand for more)
|
|
|
|
4. **Advanced Behaviors**
|
|
- Auto-expand conversations with new messages
|
|
- Smart notification replacement for media
|
|
- Context-aware grouping algorithms
|
|
- Adaptive UI based on notification type
|
|
|
|
### Phase 6: Migration Completion
|
|
|
|
1. **Feature Parity Checklist**
|
|
- [ ] All notifications display correctly
|
|
- [ ] Actions work as expected
|
|
- [ ] Images render properly
|
|
- [ ] **Inline replies function correctly** (NEW)
|
|
- [ ] Performance is acceptable
|
|
- [ ] No missing notifications
|
|
|
|
2. **Cleanup**
|
|
- Remove old notification daemon
|
|
- Update system configuration
|
|
- Document any custom behaviors
|
|
|
|
### Common Migration Issues
|
|
|
|
1. **Missing Notifications**
|
|
- Ensure D-Bus service is registered
|
|
- Check that old daemon is stopped
|
|
- Verify no other notification handlers
|
|
|
|
2. **Inline Reply Not Working**
|
|
- Confirm `inlineReplySupported: true`
|
|
- Check application supports inline reply
|
|
- Verify D-Bus communication
|
|
|
|
3. **Performance Issues**
|
|
- Limit tracked notifications
|
|
- Implement notification cleanup
|
|
- Use efficient data structures
|
|
|
|
### Testing Applications
|
|
|
|
Test with various applications to ensure compatibility:
|
|
- **Basic**: `notify-send`, system notifications
|
|
- **Media**: Spotify, VLC, music players
|
|
- **Messaging**: Telegram, Discord, Signal (inline reply)
|
|
- **Email**: Thunderbird, Evolution
|
|
- **Development**: IDE notifications, build status
|
|
|
|
### Rollback Plan
|
|
|
|
Keep your old configuration available:
|
|
1. Document old notification daemon setup
|
|
2. Keep configuration files backed up
|
|
3. Test rollback procedure
|
|
4. Have quick switch mechanism ready
|
|
|
|
## Android 16-Style Implementation Demos
|
|
|
|
### Demo 1: Basic Grouped Popup Notifications
|
|
|
|
```qml
|
|
// Replace your existing NotificationInit.qml content
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import Quickshell
|
|
import Quickshell.Services.Notifications
|
|
|
|
PanelWindow {
|
|
id: notificationPopup
|
|
|
|
visible: NotificationService.groupedPopups.length > 0
|
|
|
|
WlrLayershell.layer: WlrLayershell.Overlay
|
|
WlrLayershell.exclusiveZone: -1
|
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
|
|
|
color: "transparent"
|
|
|
|
anchors {
|
|
top: true
|
|
right: true
|
|
}
|
|
|
|
margins {
|
|
top: Theme.barHeight
|
|
right: 16
|
|
}
|
|
|
|
implicitWidth: 420
|
|
implicitHeight: groupedNotificationsList.height + 32
|
|
|
|
Column {
|
|
id: groupedNotificationsList
|
|
anchors.top: parent.top
|
|
anchors.right: parent.right
|
|
anchors.topMargin: 16
|
|
anchors.rightMargin: 16
|
|
spacing: 12
|
|
width: 400
|
|
|
|
Repeater {
|
|
model: NotificationService.groupedPopups
|
|
|
|
delegate: AndroidStyleGroupedNotificationCard {
|
|
required property var modelData
|
|
group: modelData
|
|
width: parent.width
|
|
|
|
// Auto-dismiss single notifications
|
|
Timer {
|
|
running: group.count === 1 && group.latestNotification.popup
|
|
interval: group.latestNotification.notification.expireTimeout > 0 ?
|
|
group.latestNotification.notification.expireTimeout : 5000
|
|
onTriggered: {
|
|
group.latestNotification.popup = false
|
|
}
|
|
}
|
|
|
|
// Don't auto-dismiss conversation groups - let user interact
|
|
property bool isConversationGroup: group.isConversation && group.count > 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
component AndroidStyleGroupedNotificationCard: Rectangle {
|
|
id: root
|
|
|
|
property var group
|
|
property bool autoExpanded: group.isConversation && group.count > 1
|
|
|
|
height: contentColumn.height + 24
|
|
radius: 16
|
|
color: "#1a1a1a"
|
|
border.color: group.latestNotification.urgency === 2 ? "#ff4444" : "#333333"
|
|
border.width: 1
|
|
|
|
Column {
|
|
id: contentColumn
|
|
anchors.top: parent.top
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.margins: 16
|
|
spacing: 12
|
|
|
|
// Header row
|
|
Row {
|
|
width: parent.width
|
|
spacing: 12
|
|
|
|
Rectangle {
|
|
width: 48
|
|
height: 48
|
|
radius: group.isConversation ? 24 : 8
|
|
color: "#333333"
|
|
|
|
Image {
|
|
anchors.fill: parent
|
|
anchors.margins: group.isConversation ? 0 : 8
|
|
source: group.latestNotification.image || group.latestNotification.appIcon
|
|
fillMode: Image.PreserveAspectCrop
|
|
radius: parent.radius
|
|
}
|
|
}
|
|
|
|
Column {
|
|
width: parent.width - 60 - 60
|
|
spacing: 4
|
|
|
|
Row {
|
|
width: parent.width
|
|
spacing: 8
|
|
|
|
Text {
|
|
text: group.appName
|
|
color: "#888888"
|
|
font.pixelSize: 12
|
|
font.weight: Font.Medium
|
|
}
|
|
|
|
Rectangle {
|
|
width: Math.max(20, countText.width + 8)
|
|
height: 16
|
|
radius: 8
|
|
color: "#4a9eff"
|
|
visible: group.count > 1
|
|
|
|
Text {
|
|
id: countText
|
|
anchors.centerIn: parent
|
|
text: group.count
|
|
color: "#ffffff"
|
|
font.pixelSize: 10
|
|
font.weight: Font.Bold
|
|
}
|
|
}
|
|
}
|
|
|
|
Text {
|
|
text: getGroupTitle()
|
|
color: "#ffffff"
|
|
font.pixelSize: 15
|
|
font.weight: Font.Medium
|
|
width: parent.width
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Text {
|
|
text: group.latestNotification.body
|
|
color: "#cccccc"
|
|
font.pixelSize: 13
|
|
width: parent.width
|
|
wrapMode: Text.WordWrap
|
|
maximumLineCount: autoExpanded ? -1 : 2
|
|
elide: Text.ElideRight
|
|
}
|
|
}
|
|
|
|
Button {
|
|
width: 32
|
|
height: 32
|
|
text: "✕"
|
|
onClicked: NotificationService.dismissGroup(group.key)
|
|
}
|
|
}
|
|
|
|
// Inline reply for conversations
|
|
Row {
|
|
width: parent.width
|
|
spacing: 8
|
|
visible: group.hasInlineReply
|
|
|
|
TextField {
|
|
id: replyField
|
|
width: parent.width - 60
|
|
height: 36
|
|
placeholderText: "Reply..."
|
|
background: Rectangle {
|
|
color: "#2a2a2a"
|
|
radius: 18
|
|
border.color: parent.activeFocus ? "#4a9eff" : "#444444"
|
|
}
|
|
color: "#ffffff"
|
|
|
|
onAccepted: {
|
|
if (text.length > 0) {
|
|
group.latestNotification.notification.sendInlineReply(text)
|
|
text = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
Button {
|
|
width: 52
|
|
height: 36
|
|
text: "Send"
|
|
enabled: replyField.text.length > 0
|
|
onClicked: {
|
|
group.latestNotification.notification.sendInlineReply(replyField.text)
|
|
replyField.text = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// Actions row
|
|
Row {
|
|
spacing: 8
|
|
visible: group.latestNotification.actions && group.latestNotification.actions.length > 0
|
|
|
|
Repeater {
|
|
model: group.latestNotification.actions || []
|
|
delegate: Button {
|
|
text: modelData.text
|
|
height: 32
|
|
onClicked: modelData.invoke()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function getGroupTitle() {
|
|
if (group.count === 1) {
|
|
return group.latestNotification.summary
|
|
}
|
|
|
|
if (group.isConversation) {
|
|
return `${group.count} new messages`
|
|
}
|
|
|
|
if (group.isMedia) {
|
|
return "Now playing"
|
|
}
|
|
|
|
return `${group.count} notifications`
|
|
}
|
|
}
|
|
```
|
|
|
|
### Demo 2: Notification History with Grouping
|
|
|
|
```qml
|
|
// Update your NotificationCenter.qml to use grouped notifications
|
|
ListView {
|
|
model: NotificationService.groupedNotifications
|
|
spacing: 12
|
|
|
|
delegate: AndroidStyleGroupedNotificationCard {
|
|
width: ListView.view.width
|
|
group: modelData
|
|
|
|
// History mode - always show expanded view for better browsing
|
|
autoExpanded: true
|
|
showAllNotifications: true
|
|
|
|
property bool showAllNotifications: false
|
|
|
|
// Override content to show more notifications
|
|
// ... (extend the component to show paginated history)
|
|
}
|
|
}
|
|
```
|
|
|
|
### Demo 3: Service Integration
|
|
|
|
```qml
|
|
// Update your NotificationService.qml to add grouping capabilities
|
|
pragma Singleton
|
|
import QtQuick
|
|
import Quickshell.Services.Notifications
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
readonly property list<NotifWrapper> notifications: []
|
|
readonly property list<NotifWrapper> popups: notifications.filter(n => n.popup)
|
|
|
|
// New grouped properties
|
|
readonly property var groupedNotifications: getGroupedNotifications()
|
|
readonly property var groupedPopups: getGroupedPopups()
|
|
|
|
NotificationServer {
|
|
id: server
|
|
|
|
keepOnReload: false
|
|
actionsSupported: true
|
|
bodyHyperlinksSupported: true
|
|
bodyImagesSupported: true
|
|
bodyMarkupSupported: true
|
|
imageSupported: true
|
|
inlineReplySupported: true // Enable inline reply
|
|
|
|
onNotification: notif => {
|
|
notif.tracked = true;
|
|
const wrapper = notifComponent.createObject(root, {
|
|
popup: true,
|
|
notification: notif
|
|
});
|
|
root.notifications.push(wrapper);
|
|
}
|
|
}
|
|
|
|
// ... (rest of your existing NotifWrapper and helper functions)
|
|
|
|
// New grouping functions
|
|
function getGroupKey(wrapper) {
|
|
const appName = wrapper.appName || "Unknown";
|
|
|
|
if (wrapper.isConversation) {
|
|
const summary = wrapper.summary.toLowerCase();
|
|
if (summary.match(/^[#@]?[\w\s]+$/)) {
|
|
return appName + ":" + wrapper.summary;
|
|
}
|
|
return appName + ":conversation";
|
|
}
|
|
|
|
if (wrapper.isMedia) {
|
|
return appName + ":media";
|
|
}
|
|
|
|
if (wrapper.isSystem) {
|
|
return appName + ":system";
|
|
}
|
|
|
|
return appName;
|
|
}
|
|
|
|
function getGroupedNotifications() {
|
|
const groups = {};
|
|
|
|
for (const notif of notifications) {
|
|
const groupKey = getGroupKey(notif);
|
|
if (!groups[groupKey]) {
|
|
groups[groupKey] = {
|
|
key: groupKey,
|
|
appName: notif.appName,
|
|
notifications: [],
|
|
latestNotification: null,
|
|
count: 0,
|
|
hasInlineReply: false,
|
|
isConversation: notif.isConversation,
|
|
isMedia: notif.isMedia,
|
|
isSystem: notif.isSystem
|
|
};
|
|
}
|
|
|
|
groups[groupKey].notifications.unshift(notif);
|
|
groups[groupKey].latestNotification = groups[groupKey].notifications[0];
|
|
groups[groupKey].count = groups[groupKey].notifications.length;
|
|
|
|
if (notif.notification.hasInlineReply) {
|
|
groups[groupKey].hasInlineReply = true;
|
|
}
|
|
}
|
|
|
|
return Object.values(groups).sort((a, b) => {
|
|
return b.latestNotification.time.getTime() - a.latestNotification.time.getTime();
|
|
});
|
|
}
|
|
|
|
function getGroupedPopups() {
|
|
const groups = {};
|
|
|
|
for (const notif of popups) {
|
|
const groupKey = getGroupKey(notif);
|
|
if (!groups[groupKey]) {
|
|
groups[groupKey] = {
|
|
key: groupKey,
|
|
appName: notif.appName,
|
|
notifications: [],
|
|
latestNotification: null,
|
|
count: 0,
|
|
hasInlineReply: false,
|
|
isConversation: notif.isConversation,
|
|
isMedia: notif.isMedia,
|
|
isSystem: notif.isSystem
|
|
};
|
|
}
|
|
|
|
groups[groupKey].notifications.unshift(notif);
|
|
groups[groupKey].latestNotification = groups[groupKey].notifications[0];
|
|
groups[groupKey].count = groups[groupKey].notifications.length;
|
|
|
|
if (notif.notification.hasInlineReply) {
|
|
groups[groupKey].hasInlineReply = true;
|
|
}
|
|
}
|
|
|
|
return Object.values(groups).sort((a, b) => {
|
|
return b.latestNotification.time.getTime() - a.latestNotification.time.getTime();
|
|
});
|
|
}
|
|
|
|
function dismissGroup(groupKey) {
|
|
const notificationsCopy = [...notifications];
|
|
for (const notif of notificationsCopy) {
|
|
if (getGroupKey(notif) === groupKey) {
|
|
notif.notification.dismiss();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Demo 4: Testing Your Implementation
|
|
|
|
```bash
|
|
# Test basic notifications
|
|
notify-send "Test App" "Single notification"
|
|
|
|
# Test conversation grouping (Discord simulation)
|
|
notify-send "Discord" "#general" -i discord
|
|
notify-send "Discord" "#general" -i discord
|
|
notify-send "Discord" "john_doe" -i discord
|
|
|
|
# Test media notifications
|
|
notify-send "Spotify" "Now Playing" "Song Title - Artist" -i spotify
|
|
|
|
# Test inline reply (requires supporting app)
|
|
# This would come from messaging apps that support inline reply
|
|
``` |