Files
Panama/config/dot/quickshell/services/Notifs.qml
T
Gabriel Brown d96863b687 Convert British spellings to American across the repo
colour -> color, behaviour -> behavior, centre -> center, favourite ->
favorite, and about twenty other pairs, applied consistently across
comments, docs, error/UI copy, and a handful of QML identifiers that
used the British spelling as their actual name: SystemSettings'
serialiseValue/serialiseTable/normaliseGradient, Displays'
normaliseModes, Wallpaper's normalisePolicy, SettingsBackup's
serialiseHomeState, DateTime's ntpSynchronised property, Clipboard's
_normalise helper, and ShortcutCapture's cancelled signal (with its
onCancelled handler in ShortcutsPage.qml). Every call site and the two
tests that assert on the literal source text (settings-ownership and
settings-backup-live contracts) were updated in lockstep.

Left untouched: config/dot/espanso/match/packages/misspell-en/ is a
vendored third-party autocorrect dictionary -- its entries are typo
corrections, not our prose, and rewriting them would fight the
package's own purpose (and any future re-sync from upstream).

The already-American `favorites` property (Home page pinned
accessories) was never actually misspelled -- only nearby comments and
error strings said "favourites" -- so no data migration was needed
there.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-19 08:07:55 -04:00

346 lines
14 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 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.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 : {};
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
// 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;
}
if (!root.doNotDisturb) {
root.popups = [notification].concat(root.popups);
} 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);
}
}
// 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);
}
}