pragma Singleton // ───────────────────────────────────────────────────────────────────────────── // The freedesktop notification server, plus the two lists the UI renders: // // popups — what Toasts.qml is currently showing (transient, timed) // history — GNOME's message tray, what NotificationCenter.qml shows // // A notification lives exactly as long as `tracked` is true, so history holds // the *live* objects rather than copies: that keeps actions and inline replies // working from the tray, which is what GNOME does. The cost is that a // notification an app closes itself (progress bars, "download finished" // replacing "downloading") disappears from history too — correct behaviour, // but the reason history is not append-only. // ───────────────────────────────────────────────────────────────────────────── import Quickshell import Quickshell.Services.Notifications import QtQuick import qs.config Singleton { id: root readonly property alias server: server // Live tracked set, straight from the server. Mostly useful for counting; // the UI wants `popups` / `history`, which are ordered newest-first. readonly property alias active: server.trackedNotifications property var history: [] property var popups: [] // Suppresses toasts entirely. Notifications still reach history. property bool doNotDisturb: false // Cleared when the notification centre is opened. The bar binds to this. property int unreadCount: 0 // Arrival times, keyed by notification id — the protocol carries no // timestamp. Deliberately formatted once at arrival rather than shown as // "5 minutes ago", which would need a clock ticking behind every card. readonly property var arrivals: ({}) function timeText(id: int): string { const at = root.arrivals[id]; return at ? Qt.formatDateTime(at, Settings.use24Hour ? "HH:mm" : "h:mm AP") : ""; } readonly property bool hasNotifications: root.history.length > 0 // history grouped by app, in most-recent-app-first order — the shape // NotificationCenter.qml renders directly. readonly property var groups: { const out = []; const byApp = {}; for (const n of root.history) { const key = n.appName || "Notifications"; let group = byApp[key]; if (!group) { group = { app: key, icon: n.appIcon, desktopEntry: n.desktopEntry, items: [] }; byApp[key] = group; out.push(group); } group.items.push(n); } return out; } NotificationServer { id: server keepOnReload: true persistenceSupported: true bodySupported: true bodyMarkupSupported: true bodyImagesSupported: true imageSupported: true actionsSupported: true actionIconsSupported: true inlineReplySupported: true onNotification: notification => { // Replayed from before a shell reload. Letting these through would // re-toast and re-list everything on every edit, so they are left // untracked and allowed to die. if (notification.lastGeneration) return; // Without this the object is destroyed the instant this returns. notification.tracked = true; root.arrivals[notification.id] = new Date(); // The object may go away at any time (app-side close, dismiss()). // Drop our references synchronously when it does. notification.closed.connect(() => root.forget(notification)); // `transient` is the volume-OSD case: show it, never file it. if (!notification.transient) { root.pushHistory(notification); root.unreadCount += 1; } if (!root.doNotDisturb) root.popups = [notification].concat(root.popups); } } // ── Mutation ──────────────────────────────────────────────────────────── function pushHistory(n: Notification): void { const next = [n].concat(root.history); // Anything past the cap is released, otherwise it stays tracked // forever and the server's set grows without bound. const evicted = next.splice(Settings.notificationHistoryLimit); root.history = next; for (const old of evicted) old.dismiss(); } // Hide a toast without filing the notification away as read. Mirrors // GNOME: closing a banner leaves it in the tray. function dropPopup(n: Notification): void { const next = root.popups.filter(x => x !== n); if (next.length === root.popups.length) return; root.popups = next; // Transients were never in history, so nothing else holds them. if (n.transient) n.dismiss(); } function dismiss(n: Notification): void { n.dismiss(); } function dismissAll(): void { // Copy first: dismiss() re-enters through forget() and rewrites both // lists while we iterate. const all = root.history.slice(); root.history = []; root.popups = []; for (const n of all) n.dismiss(); root.unreadCount = 0; } function dismissApp(appName: string): void { const doomed = root.history.filter(n => (n.appName || "Notifications") === appName); root.history = root.history.filter(n => doomed.indexOf(n) === -1); root.popups = root.popups.filter(n => doomed.indexOf(n) === -1); for (const n of doomed) n.dismiss(); } function markAllRead(): void { root.unreadCount = 0; } // Called from the `closed` signal — the object is on its way out, so this // only ever removes references, never touches the notification. function forget(n: Notification): void { delete root.arrivals[n.id]; if (root.history.indexOf(n) !== -1) root.history = root.history.filter(x => x !== n); if (root.popups.indexOf(n) !== -1) root.popups = root.popups.filter(x => x !== n); } }