Bound the notification app list, and give Focus a real editor
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -60,15 +60,26 @@ Singleton {
|
||||
|
||||
readonly property var appRules: Object.assign({}, root.persistedAppRules, root.fallbackAppRules)
|
||||
|
||||
// Every application that has a rule, with everything the settings list
|
||||
// needs to draw a row: the live-resolved name, the cached icon, when it
|
||||
// last said something, and the rule itself. Sorted by name, because the
|
||||
// page decides its own sections (recent / customized / all) from the
|
||||
// fields rather than from this order.
|
||||
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));
|
||||
return Object.keys(root.appRules).map(appId => {
|
||||
const rule = root.appRule(appId);
|
||||
return {
|
||||
id: appId,
|
||||
name: root.applicationLabel(appId, entries, remembered),
|
||||
icon: root.applicationIcon(appId, rule.icon),
|
||||
lastSeenMs: rule.lastSeenMs,
|
||||
rule: rule
|
||||
};
|
||||
}).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
// Arrival times, keyed by notification id — the protocol carries no
|
||||
@@ -86,8 +97,11 @@ Singleton {
|
||||
// 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.
|
||||
// Reads the effective urgency, so an application told to be treated as
|
||||
// critical also gets the critical banner duration rather than only the
|
||||
// critical look.
|
||||
function notificationTimeoutMs(notification: var): int {
|
||||
if (notification.urgency === NotificationUrgency.Critical)
|
||||
if (root.effectiveUrgency(notification) === NotificationUrgency.Critical)
|
||||
return Settings.notificationTimeoutCriticalMs;
|
||||
if (notification.expireTimeout === 0)
|
||||
return 0;
|
||||
@@ -103,37 +117,102 @@ Singleton {
|
||||
return desktopEntry || String(notification.appName ?? "").trim() || "Notifications";
|
||||
}
|
||||
|
||||
function applicationLabel(appId: string, entries: var, remembered: var): string {
|
||||
// The installed application behind a rule key, or null. An appId is either
|
||||
// a desktop entry id or the raw name an application announced itself with,
|
||||
// so both spellings and the heuristic are tried.
|
||||
function desktopEntryFor(appId: string): var {
|
||||
const desktopId = appId.endsWith(".desktop") ? appId.slice(0, -8) : appId;
|
||||
const entry = DesktopEntries.byId(appId)
|
||||
return DesktopEntries.byId(appId)
|
||||
|| DesktopEntries.byId(desktopId)
|
||||
|| DesktopEntries.heuristicLookup(appId)
|
||||
|| DesktopEntries.heuristicLookup(desktopId);
|
||||
return entry?.name || remembered[appId]?.name || appId;
|
||||
}
|
||||
|
||||
// Live first, cache second: an installed application is named by its
|
||||
// desktop entry every time, and the name stored on the rule only stands in
|
||||
// for one that is gone, sandboxed, or not scanned yet. The other way round
|
||||
// would freeze a name at the moment it was first heard from.
|
||||
function applicationLabel(appId: string, entries: var, remembered: var): string {
|
||||
return root.desktopEntryFor(appId)?.name || remembered[appId]?.name || appId;
|
||||
}
|
||||
|
||||
// Same rule for the icon: the desktop entry wins, the cached icon (an
|
||||
// entry icon, or the appIcon the notification carried) is the fallback.
|
||||
function applicationIcon(appId: string, cached: string): string {
|
||||
return root.desktopEntryFor(appId)?.icon || String(cached ?? "");
|
||||
}
|
||||
|
||||
// The durable per-application rule. Everything past `enabled` is optional
|
||||
// so rules written when this held only `enabled` still load, and anything
|
||||
// unrecognized -- including the lock-screen pair described below -- is
|
||||
// dropped rather than carried forward as if something read it.
|
||||
//
|
||||
// enabled off rejects the notification before anything else happens
|
||||
// sound false silences the bell for this application only
|
||||
// display "history" files it without a banner and without a bell
|
||||
// urgency "low"/"critical" override what the application claims
|
||||
// lastSeenMs when it last notified, stamped by rememberApplication
|
||||
// name, icon cached labels for the settings list (see above: fallback)
|
||||
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 };
|
||||
const display = String(source.display ?? "");
|
||||
const urgency = String(source.urgency ?? "");
|
||||
const lastSeen = Number(source.lastSeenMs);
|
||||
return {
|
||||
enabled: source.enabled !== false,
|
||||
sound: source.sound !== false,
|
||||
display: display === "history" ? "history" : "banners",
|
||||
urgency: urgency === "low" || urgency === "critical" ? urgency : "auto",
|
||||
lastSeenMs: isFinite(lastSeen) && lastSeen > 0 ? Math.round(lastSeen) : 0,
|
||||
name: String(source.name ?? ""),
|
||||
icon: String(source.icon ?? "")
|
||||
};
|
||||
}
|
||||
|
||||
// A rule with no defaults touched. Everything the page calls "customized"
|
||||
// is a field that differs from this.
|
||||
readonly property var defaultAppRule: root.normalizedAppRule({})
|
||||
|
||||
function appRule(appId: string): var {
|
||||
return root.normalizedAppRule(root.appRules[appId]);
|
||||
}
|
||||
|
||||
// Has this application been given a rule that actually says something? The
|
||||
// bookkeeping fields do not count -- an application is "customized"
|
||||
// because of a decision somebody made about it, not because it notified.
|
||||
function isCustomized(appId: string): bool {
|
||||
const rule = root.appRule(appId);
|
||||
return rule.enabled !== true || rule.sound !== true
|
||||
|| rule.display !== "banners" || rule.urgency !== "auto";
|
||||
}
|
||||
|
||||
function setAppRule(appId: string, patch: var): bool {
|
||||
if (!appId)
|
||||
return false;
|
||||
|
||||
const current = root.appRule(appId);
|
||||
const changes = patch && typeof patch === "object" ? patch : {};
|
||||
const merged = Object.assign({}, root.appRule(appId));
|
||||
// Field by field rather than a blind merge, so a patch cannot smuggle
|
||||
// a value of the wrong type (or an unknown key) into the stored rule.
|
||||
if (changes.enabled !== undefined)
|
||||
merged.enabled = changes.enabled === true;
|
||||
if (changes.sound !== undefined)
|
||||
merged.sound = changes.sound === true;
|
||||
if (changes.display !== undefined)
|
||||
merged.display = String(changes.display);
|
||||
if (changes.urgency !== undefined)
|
||||
merged.urgency = String(changes.urgency);
|
||||
if (changes.lastSeenMs !== undefined)
|
||||
merged.lastSeenMs = Number(changes.lastSeenMs);
|
||||
if (changes.name !== undefined)
|
||||
merged.name = String(changes.name);
|
||||
if (changes.icon !== undefined)
|
||||
merged.icon = String(changes.icon);
|
||||
|
||||
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
|
||||
};
|
||||
next[appId] = root.normalizedAppRule(merged);
|
||||
|
||||
if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next))
|
||||
root.fallbackAppRules = {};
|
||||
@@ -142,6 +221,49 @@ Singleton {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Delete the rule outright, rather than resetting its fields: an
|
||||
// application with no rule is treated as permissive, so forgetting one is
|
||||
// undone by its next notification, which writes a fresh default rule.
|
||||
// The session-only remembered name goes with it, otherwise the row would
|
||||
// linger under a name nothing is keeping.
|
||||
function forgetApp(appId: string): bool {
|
||||
if (!appId || root.appRules[appId] === undefined)
|
||||
return false;
|
||||
|
||||
const next = {};
|
||||
for (const knownAppId of Object.keys(root.appRules)) {
|
||||
if (knownAppId !== appId)
|
||||
next[knownAppId] = root.appRule(knownAppId);
|
||||
}
|
||||
|
||||
const remembered = Object.assign({}, root.rememberedApplications);
|
||||
delete remembered[appId];
|
||||
root.rememberedApplications = remembered;
|
||||
|
||||
// The session fallback is a whole-map replacement, so a deletion only
|
||||
// truly lands once the preference write succeeds -- which it does
|
||||
// whenever the schema key exists, i.e. always outside the transitional
|
||||
// build this fallback was written for.
|
||||
if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next))
|
||||
root.fallbackAppRules = {};
|
||||
else
|
||||
root.fallbackAppRules = next;
|
||||
return true;
|
||||
}
|
||||
|
||||
// What this notification counts as after the application's override: the
|
||||
// urgency the sender claimed unless a rule disagrees with it. Read by the
|
||||
// bell, the banner timeout, the Do Not Disturb breakthrough gate, and the
|
||||
// card's critical edge, so all four agree on one answer.
|
||||
function effectiveUrgency(notification: var): var {
|
||||
const rule = root.appRule(root.notificationAppId(notification));
|
||||
if (rule.urgency === "low")
|
||||
return NotificationUrgency.Low;
|
||||
if (rule.urgency === "critical")
|
||||
return NotificationUrgency.Critical;
|
||||
return notification.urgency;
|
||||
}
|
||||
|
||||
function rememberApplication(notification: var): string {
|
||||
const appId = root.notificationAppId(notification);
|
||||
const next = Object.assign({}, root.rememberedApplications);
|
||||
@@ -150,8 +272,22 @@ Singleton {
|
||||
};
|
||||
root.rememberedApplications = next;
|
||||
|
||||
if (root.appRules[appId] === undefined)
|
||||
root.setAppRule(appId, {});
|
||||
// Stamped alongside the labels the settings list falls back to when
|
||||
// the application is not installed here. Coalesced to one write per
|
||||
// minute per app: every setAppRule bumps the preferences revision and
|
||||
// restarts the settings.json write debounce, so stamping a chat
|
||||
// burst's every message would churn the whole preference store for a
|
||||
// timestamp nobody reads at that resolution.
|
||||
const rule = root.appRule(appId);
|
||||
const name = root.applicationLabel(appId, DesktopEntries.applications.values, next);
|
||||
const icon = root.applicationIcon(appId, String(notification.appIcon ?? ""));
|
||||
if (Date.now() - rule.lastSeenMs >= 60000 || rule.name !== name || rule.icon !== icon) {
|
||||
root.setAppRule(appId, {
|
||||
lastSeenMs: Date.now(),
|
||||
name: name,
|
||||
icon: icon
|
||||
});
|
||||
}
|
||||
return appId;
|
||||
}
|
||||
|
||||
@@ -211,7 +347,10 @@ Singleton {
|
||||
return;
|
||||
|
||||
const appId = root.rememberApplication(notification);
|
||||
if (!root.appRule(appId).enabled)
|
||||
// Read once: the same rule decides rejection, the bell, and
|
||||
// whether this is allowed to be a banner at all.
|
||||
const rule = root.appRule(appId);
|
||||
if (!rule.enabled)
|
||||
return;
|
||||
|
||||
// Without this the object is destroyed the instant this returns.
|
||||
@@ -228,10 +367,27 @@ Singleton {
|
||||
root.unreadCount += 1;
|
||||
}
|
||||
|
||||
// "History only" is the quiet filing cabinet: it was added to
|
||||
// history and counted as unread above, and that is all it gets --
|
||||
// no banner, and (never reaching playBell) no sound either.
|
||||
const historyOnly = rule.display === "history";
|
||||
|
||||
// The one exception to Do Not Disturb that is not a focus mode's,
|
||||
// and it is off unless somebody turned it on. It reads the
|
||||
// effective urgency, so a per-application override decides who
|
||||
// gets to claim "critical" rather than the sender alone.
|
||||
const breaksThrough = Settings.criticalBreaksThrough
|
||||
&& root.effectiveUrgency(notification) === NotificationUrgency.Critical;
|
||||
|
||||
// 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))) {
|
||||
if (historyOnly) {
|
||||
// Transient notifications are filed nowhere, so a hidden one
|
||||
// still needs the release its popup timeout would have given.
|
||||
if (notification.transient)
|
||||
root.scheduleTransientExpiry(notification);
|
||||
} else if (!root.doNotDisturb || FocusModes.allows(appId) || breaksThrough) {
|
||||
root.popups = [notification].concat(root.popups);
|
||||
root.playBell(notification);
|
||||
} else if (notification.transient) {
|
||||
@@ -260,9 +416,17 @@ Singleton {
|
||||
if (!SoundFeedback.eventSounds)
|
||||
return;
|
||||
|
||||
// The per-application sound switch. Narrower than turning the
|
||||
// application off: its notifications still arrive and still show, they
|
||||
// just stop making noise.
|
||||
if (!root.appRule(root.notificationAppId(notification)).sound)
|
||||
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)
|
||||
// reaching full, a sync completing. It stays silent by design, and it
|
||||
// is the effective urgency, so "treat as low" is a way to keep an
|
||||
// application audible in principle but quiet in practice.
|
||||
if (root.effectiveUrgency(notification) === NotificationUrgency.Low)
|
||||
return;
|
||||
|
||||
// The freedesktop sound hints. This is the fix for the double chime:
|
||||
|
||||
Reference in New Issue
Block a user