pragma Singleton pragma ComponentBehavior: Bound import "root:/modules/common" import "root:/" import QtQuick import Quickshell import Quickshell.Io import Quickshell.Services.Notifications /** * Provides extra features not in Quickshell.Services.Notifications: * - Persistent storage * - Popup notifications, with timeout * - Notification groups by app */ Singleton { id: root component Notif: QtObject { required property int id property Notification notification property list actions: notification?.actions.map((action) => ({ "identifier": action.identifier, "text": action.text, })) ?? [] property bool popup: false property string appIcon: notification?.appIcon ?? "" property string appName: notification?.appName ?? "" property string body: notification?.body ?? "" property string image: notification?.image ?? "" property string summary: notification?.summary ?? "" property double time property string urgency: notification?.urgency.toString() ?? "normal" property Timer timer } function notifToJSON(notif) { return { "id": notif.id, "actions": notif.actions, "appIcon": notif.appIcon, "appName": notif.appName, "body": notif.body, "image": notif.image, "summary": notif.summary, "time": notif.time, "urgency": notif.urgency, } } function notifToString(notif) { return JSON.stringify(notifToJSON(notif), null, 2); } component NotifTimer: Timer { required property int id interval: 5000 running: true onTriggered: () => { root.timeoutNotification(id); destroy() } } property bool silent: false property var filePath: Directories.notificationsPath property list list: [] property var popupList: list.filter((notif) => notif.popup); property bool popupInhibited: (GlobalStates?.sidebarRightOpen ?? false) || silent property var latestTimeForApp: ({}) Component { id: notifComponent Notif {} } Component { id: notifTimerComponent NotifTimer {} } function stringifyList(list) { return JSON.stringify(list.map((notif) => notifToJSON(notif)), null, 2); } onListChanged: { // Update latest time for each app root.list.forEach((notif) => { if (!root.latestTimeForApp[notif.appName] || notif.time > root.latestTimeForApp[notif.appName]) { root.latestTimeForApp[notif.appName] = Math.max(root.latestTimeForApp[notif.appName] || 0, notif.time); } }); // Remove apps that no longer have notifications Object.keys(root.latestTimeForApp).forEach((appName) => { if (!root.list.some((notif) => notif.appName === appName)) { delete root.latestTimeForApp[appName]; } }); } function appNameListForGroups(groups) { return Object.keys(groups).sort((a, b) => { // Sort by time, descending return groups[b].time - groups[a].time; }); } function groupsForList(list) { const groups = {}; list.forEach((notif) => { if (!groups[notif.appName]) { groups[notif.appName] = { appName: notif.appName, appIcon: notif.appIcon, notifications: [], time: 0 }; } groups[notif.appName].notifications.push(notif); // Always set to the latest time in the group groups[notif.appName].time = latestTimeForApp[notif.appName] || notif.time; }); return groups; } property var groupsByAppName: groupsForList(root.list) property var popupGroupsByAppName: groupsForList(root.popupList) property var appNameList: appNameListForGroups(root.groupsByAppName) property var popupAppNameList: appNameListForGroups(root.popupGroupsByAppName) // Quickshell's notification IDs starts at 1 on each run, while saved notifications // can already contain higher IDs. This is for avoiding id collisions property int idOffset signal initDone(); signal notify(notification: var); signal discard(id: var); signal discardAll(); signal timeout(id: var); NotificationServer { id: notifServer // actionIconsSupported: true actionsSupported: true bodyHyperlinksSupported: true bodyImagesSupported: true bodyMarkupSupported: true bodySupported: true imageSupported: true keepOnReload: false persistenceSupported: true onNotification: (notification) => { notification.tracked = true const newNotifObject = notifComponent.createObject(root, { "id": notification.id + root.idOffset, "notification": notification, "time": Date.now(), }); root.list = [...root.list, newNotifObject]; // Popup if (!root.popupInhibited) { newNotifObject.popup = true; newNotifObject.timer = notifTimerComponent.createObject(root, { "id": newNotifObject.id, "interval": notification.expireTimeout < 0 ? 5000 : notification.expireTimeout, }); } root.notify(newNotifObject); // console.log(notifToString(newNotifObject)); notifFileView.setText(stringifyList(root.list)); } } function discardNotification(id) { const index = root.list.findIndex((notif) => notif.id === id); const notifServerIndex = notifServer.trackedNotifications.values.findIndex((notif) => notif.id + root.idOffset === id); if (index !== -1) { root.list.splice(index, 1); notifFileView.setText(stringifyList(root.list)); triggerListChange() } if (notifServerIndex !== -1) { notifServer.trackedNotifications.values[notifServerIndex].dismiss() } root.discard(id); } function discardAllNotifications() { root.list = [] triggerListChange() notifFileView.setText(stringifyList(root.list)); notifServer.trackedNotifications.values.forEach((notif) => { notif.dismiss() }) root.discardAll(); } function timeoutNotification(id) { const index = root.list.findIndex((notif) => notif.id === id); if (root.list[index] != null) root.list[index].popup = false; root.timeout(id); } function timeoutAll() { root.popupList.forEach((notif) => { root.timeout(notif.id); }) root.popupList.forEach((notif) => { notif.popup = false; }); } function attemptInvokeAction(id, notifIdentifier) { const notifServerIndex = notifServer.trackedNotifications.values.findIndex((notif) => notif.id + root.idOffset === id); if (notifServerIndex !== -1) { const notifServerNotif = notifServer.trackedNotifications.values[notifServerIndex]; const action = notifServerNotif.actions.find((action) => action.identifier === notifIdentifier); action.invoke() } else { console.log("Notification not found in server: " + id) root.discardNotification(id); } } function triggerListChange() { root.list = root.list.slice(0) } function refresh() { notifFileView.reload() } Component.onCompleted: { refresh() } FileView { id: notifFileView path: Qt.resolvedUrl(filePath) onLoaded: { const fileContents = notifFileView.text() root.list = JSON.parse(fileContents).map((notif) => { return notifComponent.createObject(root, { "id": notif.id, "actions": [], // Notification actions are meaningless if they're not tracked by the server or the sender is dead "appIcon": notif.appIcon, "appName": notif.appName, "body": notif.body, "image": notif.image, "summary": notif.summary, "time": notif.time, "urgency": notif.urgency, }); }); // Find largest id let maxId = 0 root.list.forEach((notif) => { maxId = Math.max(maxId, notif.id) }) console.log("[Notifications] File loaded") root.idOffset = maxId root.initDone() } onLoadFailed: (error) => { if(error == FileViewError.FileNotFound) { console.log("[Notifications] File not found, creating new file.") root.list = [] notifFileView.setText(stringifyList(root.list)); } else { console.log("[Notifications] Error loading file: " + error) } } } }