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 // Kept separate from the persisted map so this version can safely run // before the matching schema entry lands. A later accepted write folds the // complete map into DesktopPreferences and clears this fallback. property var fallbackAppRules: ({}) // Display metadata is intentionally session-only. The durable shape stays // just the per-application rule map, while a fresh notification gives the // settings page a human-readable name straight away. property var rememberedApplications: ({}) readonly property var persistedAppRules: { const stored = DesktopPreferences.get("notificationAppRules"); return stored && typeof stored === "object" && !Array.isArray(stored) ? stored : {}; } readonly property var appRules: Object.assign({}, root.persistedAppRules, root.fallbackAppRules) readonly property var applications: { const remembered = root.rememberedApplications; return Object.keys(root.appRules).map(appId => ({ id: appId, name: remembered[appId]?.name || appId })).sort((a, b) => a.name.localeCompare(b.name)); } // 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 function notificationAppId(notification: Notification): string { const desktopEntry = String(notification.desktopEntry ?? "").trim(); return desktopEntry || String(notification.appName ?? "").trim() || "Notifications"; } function normalizedAppRule(rule: var): var { const source = rule && typeof rule === "object" && !Array.isArray(rule) ? rule : {}; return { enabled: source.enabled !== false, showOnLockScreen: source.showOnLockScreen !== false, showContentOnLockScreen: source.showContentOnLockScreen !== false }; } function appRule(appId: string): var { return root.normalizedAppRule(root.appRules[appId]); } function setAppRule(appId: string, patch: var): bool { if (!appId) return false; const current = root.appRule(appId); const next = {}; for (const knownAppId of Object.keys(root.appRules)) next[knownAppId] = root.appRule(knownAppId); next[appId] = { enabled: patch.enabled === undefined ? current.enabled : patch.enabled === true, showOnLockScreen: patch.showOnLockScreen === undefined ? current.showOnLockScreen : patch.showOnLockScreen === true, showContentOnLockScreen: patch.showContentOnLockScreen === undefined ? current.showContentOnLockScreen : patch.showContentOnLockScreen === true }; if (DesktopPreferences.set("notificationAppRules", next)) root.fallbackAppRules = {}; else root.fallbackAppRules = next; return true; } function rememberApplication(notification: Notification): string { const appId = root.notificationAppId(notification); const next = Object.assign({}, root.rememberedApplications); next[appId] = { name: String(notification.appName ?? "").trim() || appId }; root.rememberedApplications = next; if (root.appRules[appId] === undefined) root.setAppRule(appId, {}); return appId; } // These policy getters deliberately accept Notification objects, so a lock // screen can use the same source of truth without duplicating app matching. function shouldShowOnLockScreen(notification: Notification): bool { const rule = root.appRule(root.notificationAppId(notification)); return rule.enabled && rule.showOnLockScreen; } function shouldShowContentOnLockScreen(notification: Notification): bool { const rule = root.appRule(root.notificationAppId(notification)); return rule.enabled && rule.showOnLockScreen && rule.showContentOnLockScreen; } // 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; const appId = root.rememberApplication(notification); if (!root.appRule(appId).enabled) 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); } }