349 lines
12 KiB
QML
349 lines
12 KiB
QML
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);
|
|
}
|
|
}
|
|
}
|