pragma Singleton import QtQuick import Quickshell import Quickshell.Io import Quickshell.Services.Notifications import "." as M QtObject { id: root property var list: [] property bool dnd: false readonly property var popups: list.filter(n => n.popup && n.state !== "dismissed") readonly property int count: list.filter(n => n.state !== "dismissed").length // O(1) lookup property var _byId: ({}) function dismiss(notifId) { const item = _byId[notifId]; if (!item) return; item.finishDismiss(); list = list.filter(n => n !== item); delete _byId[notifId]; item.destroy(); _saveTimer.restart(); } function dismissAll() { for (const item of list.slice()) { item.finishDismiss(); delete _byId[item.id]; item.destroy(); } list = []; _saveTimer.restart(); } function dismissPopup(notifId) { const item = _byId[notifId]; if (item) { item.popup = false; _changed(); } } function toggleDnd() { dnd = !dnd; } function _changed() { list = list.slice(); } // Signal popups to animate out before removal signal popupExpiring(var notifId) property NotificationServer _server: NotificationServer { actionsSupported: true bodyMarkupSupported: true imageSupported: true persistenceSupported: true keepOnReload: false onNotification: notif => { notif.tracked = true; const isCritical = notif.urgency === NotificationUrgency.Critical; const item = _itemComp.createObject(root, { notification: notif, id: notif.id, summary: notif.summary, body: notif.body, appName: notif.appName, appIcon: notif.appIcon, image: notif.image, hints: notif.hints, urgency: notif.urgency, actions: notif.actions ? notif.actions.map(a => ({ identifier: a.identifier, text: a.text, invoke: () => a.invoke() })) : [], time: Date.now(), popup: isCritical || !root.dnd }); root._byId[item.id] = item; root.list = [item, ...root.list]; // Trim excess popups const max = M.Modules.notifications.maxPopups || 4; const currentPopups = root.list.filter(n => n.popup); if (currentPopups.length > max) { for (let i = max; i < currentPopups.length; i++) currentPopups[i].popup = false; root._changed(); } // Auto-expire popup (skip for critical) if (item.popup && !isCritical) { const timeout = notif.expireTimeout > 0 ? notif.expireTimeout : (M.Modules.notifications.timeout || 3000); item._expireTimer.interval = timeout; item._expireTimer.running = true; } // Trim history const maxHistory = M.Modules.notifications.maxHistory || 50; while (root.list.length > maxHistory) { const old = root.list.pop(); old.finishDismiss(); delete root._byId[old.id]; old.destroy(); } } } property Component _itemComp: Component { NotifItem {} } // Persistence property Timer _saveTimer: Timer { interval: 1000 onTriggered: { const data = root.list.filter(n => n.state !== "dismissed").map(n => ({ id: n.id, summary: n.summary, body: n.body, appName: n.appName, appIcon: n.appIcon, image: n.image, urgency: n.urgency, time: n.time })); _storage.setText(JSON.stringify(data)); } } property FileView _storage: FileView { path: (Quickshell.env("XDG_STATE_HOME") || (Quickshell.env("HOME") + "/.local/state")) + "/nova-shell/notifs.json" onLoaded: { try { const data = JSON.parse(text()); const maxHistory = M.Modules.notifications.maxHistory || 50; for (let i = 0; i < Math.min(data.length, maxHistory); i++) { const n = data[i]; const item = _itemComp.createObject(root, { id: "p_" + (n.id ?? i) + "_" + n.time, summary: n.summary || "", body: n.body || "", appName: n.appName || "", appIcon: n.appIcon || "", image: n.image || "", urgency: n.urgency ?? 1, time: n.time || Date.now(), popup: false, actions: [] }); root._byId[item.id] = item; root.list.push(item); } root._changed(); } catch (e) {} } } }