289 lines
12 KiB
QML
289 lines
12 KiB
QML
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 : {};
|
|
}
|
|
|
|
// 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") : "";
|
|
}
|
|
|
|
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 : {};
|
|
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 (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;
|
|
}
|
|
|
|
// 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: var): bool {
|
|
const rule = root.appRule(root.notificationAppId(notification));
|
|
return rule.enabled && rule.showOnLockScreen;
|
|
}
|
|
|
|
function shouldShowContentOnLockScreen(notification: var): 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 => 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;
|
|
}
|
|
|
|
if (!root.doNotDisturb)
|
|
root.popups = [notification].concat(root.popups);
|
|
}
|
|
|
|
// ── 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;
|
|
|
|
// 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: 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);
|
|
}
|
|
}
|