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 NotificationList.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 behavior, // but the reason history is not append-only. // ───────────────────────────────────────────────────────────────────────────── import Quickshell import Quickshell.Io 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 center 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 : {}; } // The schema change is the persistence boundary. This branch keeps a // session fallback only so it remains usable while that companion change // is being integrated; it intentionally makes no restart guarantee then. readonly property bool appRulesSchemaAvailable: PreferenceSchema.has("notificationAppRules") readonly property var appRules: Object.assign({}, root.persistedAppRules, root.fallbackAppRules) readonly property var applications: { // byId()/heuristicLookup() do not make a binding by themselves. This // read updates persisted app labels once DesktopEntries finishes scan. const entries = DesktopEntries.applications.values; const remembered = root.rememberedApplications; return Object.keys(root.appRules).map(appId => ({ id: appId, name: root.applicationLabel(appId, entries, remembered) })).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") : ""; } // Freedesktop timeout resolution, shared by the toast countdown (Toast.qml) // and the no-display expiry a transient notification gets while Do Not // Disturb is on (below). Critical urgency and an explicit expireTimeout // override policy; -1 ("server decides") falls back to it. 0 means "never // auto-expire" per spec. function notificationTimeoutMs(notification: var): int { if (notification.urgency === NotificationUrgency.Critical) return Settings.notificationTimeoutCriticalMs; if (notification.expireTimeout === 0) return 0; if (notification.expireTimeout > 0) return Math.round(notification.expireTimeout * 1000); return Settings.notificationTimeoutMs; } readonly property bool hasNotifications: root.history.length > 0 function notificationAppId(notification: var): string { const desktopEntry = String(notification.desktopEntry ?? "").trim(); return desktopEntry || String(notification.appName ?? "").trim() || "Notifications"; } function applicationLabel(appId: string, entries: var, remembered: var): string { const desktopId = appId.endsWith(".desktop") ? appId.slice(0, -8) : appId; const entry = DesktopEntries.byId(appId) || DesktopEntries.byId(desktopId) || DesktopEntries.heuristicLookup(appId) || DesktopEntries.heuristicLookup(desktopId); return entry?.name || remembered[appId]?.name || appId; } function normalizedAppRule(rule: var): var { const source = rule && typeof rule === "object" && !Array.isArray(rule) ? rule : {}; // Only `enabled` -- see the lock-screen note below for why the rule // model carries nothing else, and drops old fields on the way through. return { enabled: source.enabled !== 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 }; if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next)) root.fallbackAppRules = {}; else root.fallbackAppRules = next; return true; } function rememberApplication(notification: var): 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; } // There are deliberately no lock-screen policy getters here, and no // lock-screen switches on the Notifications page. The lock screen is // hyprlock, which cannot render notifications, so two per-app privacy // toggles shipped for a while that controlled nothing -- on the page a // person checks precisely when they care. Stored rules may still carry // showOnLockScreen fields from that era; nothing reads them. If a lock // screen that can render notifications ever exists, the rule store and // appRule() are the right place to hang its policy back onto. // history grouped by app, in most-recent-app-first order — the shape // NotificationList.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 => root.handleNotification(notification) } function handleNotification(notification: var): void { // 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; } // An exception belongs to the focus mode that is in force. A Do Not // Disturb switched on by hand has no exceptions and stays absolute, // because FocusModes.allows is false whenever no mode is active. if (!root.doNotDisturb || FocusModes.allows(root.notificationAppId(notification))) { root.popups = [notification].concat(root.popups); root.playBell(notification); } else if (notification.transient) { // Never shown, and (being transient) never filed in history // either — nothing will otherwise dismiss() it, so schedule // the same release its popup timeout would have given it. root.scheduleTransientExpiry(notification); } } // ── The chime ─────────────────────────────────────────────────────────── // // A notification you can hear. GTK applications get this from libcanberra // for free; Panama's own popups had no sound at all, so a notification that // arrived while you were looking elsewhere simply did not happen. Same // theme, same desktop preference: one switch covers the whole session // rather than leaving Panama as the one thing that stays quiet -- or the // one thing that will not shut up. // // Throttled to one bell a second. A burst -- a chat catching up after a // suspend, ten build jobs finishing together -- would otherwise stack a // dozen overlapping bells, which is a noise rather than a notification. property real lastBellAt: 0 function playBell(notification: var): void { if (!SoundFeedback.eventSounds) return; // Low urgency is the "you did not need to know this" tier -- battery // reaching full, a sync completing. It stays silent by design. if (notification.urgency === NotificationUrgency.Low) return; const now = Date.now(); if (bell.running || now - root.lastBellAt < 1000) return; root.lastBellAt = now; bell.command = SoundFeedback.bellCommand; bell.running = true; } Process { id: bell } // Runs a DND-hidden transient notification through the same lifetime it // would have gotten as a visible popup (Toast.qml's countdown), just // without ever showing it, so it still gets released instead of staying // tracked forever. function scheduleTransientExpiry(notification: var): void { const ms = root.notificationTimeoutMs(notification); if (ms <= 0) return; const timer = Qt.createQmlObject("import QtQuick; Timer { repeat: false }", root); timer.interval = ms; timer.triggered.connect(() => { timer.destroy(); root.releaseTransient(notification); }); // Closed some other way first (app-side close, dismissAll()) — cancel // the pending timer instead of firing a stale dismiss() later. notification.closed.connect(() => timer.destroy()); timer.running = true; } // Transient notifications are never filed in history, so nothing else // holds a reference once their lifetime ends — release tracked state // directly. Shared by the DND-hidden expiry above and dismissAll() below. function releaseTransient(n: var): void { if (n.transient) n.dismiss(); } // ── Mutation ──────────────────────────────────────────────────────────── function pushHistory(n: var): 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; root.releaseTransient(n); } 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(); // Transient notifications are never filed in history — whether // currently shown as a popup or hidden by Do Not Disturb with a // pending scheduleTransientExpiry() timer — so releasing them needs // its own pass over the server's full tracked set. const transients = root.active.values.filter(n => n.transient); root.history = []; root.popups = []; for (const n of all) n.dismiss(); for (const n of transients) root.releaseTransient(n); 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: var): 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); } }