import QtQuick import Quickshell.Services.Notifications QtObject { id: root property ListModel notifications: ListModel {} property var groupedNotifications: [] property var groupExpansion: ({}) property int versionCounter: 0 function nextVersion() { versionCounter += 1; return versionCounter; } function indexOfId(notificationId) { const wantedId = String(notificationId); for (let i = 0; i < notifications.count; i++) { const row = notifications.get(i); if (String(row.rowNotificationId) === wantedId) return i; } return -1; } function indexOfIdAndObject(notificationId, notificationObject) { const wantedId = String(notificationId); for (let i = 0; i < notifications.count; i++) { const row = notifications.get(i); if (String(row.rowNotificationId) === wantedId && row.rowObject === notificationObject) return i; } return -1; } function removeRowsById(notificationId, replacementObject) { const wantedId = String(notificationId); for (let i = notifications.count - 1; i >= 0; i--) { const row = notifications.get(i); if (String(row.rowNotificationId) !== wantedId) continue; if (row.rowObject && row.rowObject !== replacementObject) row.rowObject.dismiss(); notifications.remove(i); } } function normalizedString(value) { if (value === undefined || value === null) return ""; return String(value); } function hintString(notificationObject, name) { if (!notificationObject || !notificationObject.hints) return ""; const value = notificationObject.hints[name]; return value === undefined || value === null ? "" : String(value); } function isNotifyToolName(value) { const normalized = String(value || "").trim().toLowerCase(); if (normalized.length === 0) return false; return normalized === "notify-send" || normalized === "dunstify" || normalized === "notify-send.desktop" || normalized === "dunstify.desktop"; } function isNotifyToolNotification(notificationObject) { if (!notificationObject) return false; return isNotifyToolName(notificationObject.appName) || isNotifyToolName(hintString(notificationObject, "desktop-entry")); } function removeOldNotifyToolRows(replacementObject) { if (!isNotifyToolNotification(replacementObject)) return; for (let i = notifications.count - 1; i >= 0; i--) { const row = notifications.get(i); const rowObject = row.rowObject; if (!rowObject || rowObject === replacementObject) continue; if (!isNotifyToolNotification(rowObject)) continue; rowObject.dismiss(); notifications.remove(i); } } function replacementFallbackKeyFor(notificationObject) { if (!notificationObject) return ""; const idValue = Number(notificationObject.id); if (Number.isFinite(idValue) && idValue > 0) return "id:" + String(Math.trunc(idValue)); return ""; } function removeRowsByFallbackReplacementKey(replacementObject) { const fallbackKey = replacementFallbackKeyFor(replacementObject); if (fallbackKey.length === 0) return; for (let i = notifications.count - 1; i >= 0; i--) { const row = notifications.get(i); if (row.rowObject === replacementObject) continue; if (String(row.rowReplacementFallbackKey || "") !== fallbackKey) continue; if (row.rowObject) row.rowObject.dismiss(); notifications.remove(i); } } function groupKeyFor(notificationObject) { const desktopEntry = normalizedString(hintString(notificationObject, "desktop-entry")).trim().toLowerCase(); if (desktopEntry.length > 0) return "desktop:" + desktopEntry; const appName = normalizedString(notificationObject ? notificationObject.appName : "").trim().toLowerCase(); if (appName.length > 0) return "app:" + appName; const appIcon = normalizedString(notificationObject ? notificationObject.appIcon : "").trim().toLowerCase(); if (appIcon.length > 0) return "icon:" + appIcon; const image = normalizedString(notificationObject ? notificationObject.image : "").trim().toLowerCase(); if (image.length > 0) return "image:" + image; return "id:" + String(notificationObject ? notificationObject.id : Date.now()); } function sortRowsNewestFirst(left, right) { return (right.createdAt || 0) - (left.createdAt || 0); } function groupSortNewestFirst(left, right) { return (right.newestAt || 0) - (left.newestAt || 0); } function rebuildGroups() { const grouped = ({}); for (let i = 0; i < notifications.count; i++) { const row = notifications.get(i); const key = row.rowGroupKey; if (!grouped[key]) { grouped[key] = { groupKey: key, appName: row.rowAppName, appIcon: row.rowAppIcon, desktopEntry: row.rowDesktopEntry, newestAt: row.rowCreatedAt, notifications: [] }; } grouped[key].notifications.push({ notificationId: row.rowNotificationId, notifObject: row.rowObject, notifVersion: row.rowVersion, createdAt: row.rowCreatedAt, appName: row.rowAppName, appIcon: row.rowAppIcon, image: row.rowImage }); if (row.rowCreatedAt > grouped[key].newestAt) grouped[key].newestAt = row.rowCreatedAt; } const groups = []; for (const groupKey in grouped) { if (!grouped.hasOwnProperty(groupKey)) continue; const group = grouped[groupKey]; group.notifications.sort(sortRowsNewestFirst); groups.push(group); } groups.sort(groupSortNewestFirst); groupedNotifications = groups; } function groupIsExpanded(groupKey) { return Boolean(groupExpansion[groupKey]); } function setGroupExpanded(groupKey, expanded) { const next = Object.assign({}, groupExpansion); next[groupKey] = Boolean(expanded); groupExpansion = next; } function toggleGroupExpanded(groupKey) { setGroupExpanded(groupKey, !groupIsExpanded(groupKey)); } function rowDataFor(notificationObject, createdAtMsOverride) { return { rowNotificationId: notificationObject.id, rowObject: notificationObject, rowVersion: nextVersion(), rowCreatedAt: createdAtMsOverride !== undefined ? createdAtMsOverride : Date.now(), rowGroupKey: groupKeyFor(notificationObject), rowAppName: normalizedString(notificationObject.appName), rowAppIcon: normalizedString(notificationObject.appIcon), rowImage: normalizedString(notificationObject.image), rowDesktopEntry: hintString(notificationObject, "desktop-entry"), rowReplacementFallbackKey: replacementFallbackKeyFor(notificationObject) }; } function bumpNotificationToTop(notificationObject) { const rowIndex = indexOfIdAndObject(notificationObject.id, notificationObject); if (rowIndex < 0) return; if (rowIndex === 0) { notifications.set(0, rowDataFor(notificationObject)); rebuildGroups(); return; } removeRowsById(notificationObject.id, notificationObject); notifications.insert(0, rowDataFor(notificationObject)); rebuildGroups(); } function addNotification(notificationObject) { if (notificationObject.lastGeneration) { notificationObject.dismiss(); return; } notificationObject.tracked = true; const groupKey = groupKeyFor(notificationObject); const idCopy = notificationObject.id; const objectCopy = notificationObject; // Guard against reconnecting signals when the same QObject is seen again. // Replacement is strict freedesktop id semantics: // `id=$(notify-send -p ...)`, then `notify-send -r "$id" ...`. if (!notificationObject.__dqModelWired) { notificationObject.__dqModelWired = true; notificationObject.closed.connect(function () { let rowIndex = indexOfIdAndObject(idCopy, objectCopy); if (rowIndex < 0) { const idOnlyIndex = indexOfId(idCopy); if (idOnlyIndex >= 0 && notifications.get(idOnlyIndex).rowObject === objectCopy) rowIndex = idOnlyIndex; } if (rowIndex >= 0) { notifications.remove(rowIndex); setGroupExpanded(groupKey, false); rebuildGroups(); } }); const onUpdated = function () { root.bumpNotificationToTop(objectCopy); }; notificationObject.expireTimeoutChanged.connect(onUpdated); notificationObject.appNameChanged.connect(onUpdated); notificationObject.appIconChanged.connect(onUpdated); notificationObject.summaryChanged.connect(onUpdated); notificationObject.bodyChanged.connect(onUpdated); notificationObject.actionsChanged.connect(onUpdated); notificationObject.desktopEntryChanged.connect(onUpdated); notificationObject.imageChanged.connect(onUpdated); notificationObject.hintsChanged.connect(onUpdated); } const existingIndex = indexOfId(notificationObject.id); if (existingIndex >= 0 && notifications.get(existingIndex).rowObject === notificationObject) { const currentRow = notifications.get(existingIndex); notifications.set(existingIndex, rowDataFor(notificationObject, currentRow.rowCreatedAt)); rebuildGroups(); return; } removeRowsById(notificationObject.id, notificationObject); // Quickshell currently doesn't expose client replaces_id in QML, so keep // notify-send/dunstify entries collapsed to newest to match -r user intent. removeOldNotifyToolRows(notificationObject); // Fallback replacement key uses id only. removeRowsByFallbackReplacementKey(notificationObject); notifications.insert(0, rowDataFor(notificationObject)); // setGroupExpanded(groupKey, true); rebuildGroups(); } function closeById(notificationId, reason) { const rowIndex = indexOfId(notificationId); if (rowIndex < 0) return; const row = notifications.get(rowIndex); const notifObject = row.rowObject; if (notifObject) { if (reason === "timeout") { notifObject.expire(); } else if (reason === "click" || reason === "dismiss" || reason === "drag") { notifObject.dismiss(); } } const remainingIndex = indexOfId(notificationId); if (remainingIndex >= 0) { const remainingRow = notifications.get(remainingIndex); // Avoid removing a newly inserted replacement row with the same id. if (!notifObject || remainingRow.rowObject === notifObject) { notifications.remove(remainingIndex); // setGroupExpanded(groupKey, true); rebuildGroups(); } } } property NotificationServer server: NotificationServer { keepOnReload: false bodySupported: true bodyMarkupSupported: false bodyHyperlinksSupported: false actionsSupported: true // NotificationServer is Quickshell's DBus implementation for // org.freedesktop.Notifications, so this is the notification source. onNotification: function (notificationObject) { root.addNotification(notificationObject); } } }