602 lines
27 KiB
QML
602 lines
27 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.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)
|
|
|
|
// 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 => {
|
|
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
|
|
// 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.
|
|
// 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 (root.effectiveUrgency(notification) === 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";
|
|
}
|
|
|
|
// 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;
|
|
return DesktopEntries.byId(appId)
|
|
|| DesktopEntries.byId(desktopId)
|
|
|| DesktopEntries.heuristicLookup(appId)
|
|
|| DesktopEntries.heuristicLookup(desktopId);
|
|
}
|
|
|
|
// 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 : {};
|
|
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 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] = root.normalizedAppRule(merged);
|
|
|
|
if (root.appRulesSchemaAvailable && DesktopPreferences.set("notificationAppRules", next))
|
|
root.fallbackAppRules = {};
|
|
else
|
|
root.fallbackAppRules = next;
|
|
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);
|
|
next[appId] = {
|
|
name: String(notification.appName ?? "").trim() || appId
|
|
};
|
|
root.rememberedApplications = next;
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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);
|
|
// 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.
|
|
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;
|
|
}
|
|
|
|
// "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 (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) {
|
|
// 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
|
|
|
|
// Emitted for every notification that is bell-eligible, whether or not a
|
|
// bell is actually audible. modules/notifications/VisualBell.qml listens.
|
|
signal bellEligible(var notification)
|
|
|
|
// Would this notification ring the bell, setting aside whether sound is
|
|
// switched on at all?
|
|
//
|
|
// THE PINNED RULE, because it is the whole point of visual alerts: the
|
|
// flash follows this predicate, and the bell follows this predicate AND
|
|
// SoundFeedback.eventSounds. Every gate below is shared -- an application
|
|
// you silenced stays silent both ways, a low-urgency notification stays
|
|
// quiet both ways, and an application that says it played its own sound is
|
|
// taken at its word both ways -- but the event-sounds switch is NOT.
|
|
//
|
|
// Gating the flash on event sounds would make Visual Alerts do nothing for
|
|
// exactly the person it exists for: somebody who cannot hear the bell has
|
|
// no reason to have event sounds on, and would turn on a switch that stays
|
|
// dark. The flash is not a picture of the bell; it is the same alert in the
|
|
// sense the person can receive.
|
|
//
|
|
// Sound RESOLUTION is deliberately not part of this. playBell gives up when
|
|
// it cannot find a file to play, which is a fact about the sound theme on
|
|
// disk; losing the flash because a theme is missing an ogg would be absurd.
|
|
function bellWouldRing(notification: var): bool {
|
|
// 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 false;
|
|
|
|
// Low urgency is the "you did not need to know this" tier -- battery
|
|
// 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 false;
|
|
|
|
// The freedesktop sound hint. This is the fix for the double chime: an
|
|
// application that plays its own sound sets suppress-sound so the
|
|
// notification server stays quiet, and Panama ignoring it meant one
|
|
// notification made two noises a beat apart.
|
|
const hints = notification.hints ?? {};
|
|
if (hints["suppress-sound"] === true)
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
function playBell(notification: var): void {
|
|
if (!root.bellWouldRing(notification))
|
|
return;
|
|
|
|
// Announced BEFORE the event-sounds gate, on purpose. See the rule
|
|
// pinned above bellWouldRing.
|
|
root.bellEligible(notification);
|
|
|
|
if (!SoundFeedback.eventSounds)
|
|
return;
|
|
|
|
// The remaining two sound hints say what to play instead of the theme
|
|
// bell -- sound-file is an absolute path the application supplies,
|
|
// sound-name is a theme sound resolved through the same chain the bell
|
|
// uses. Neither can make a notification ineligible, so neither is part
|
|
// of the predicate above.
|
|
const hints = notification.hints ?? {};
|
|
const soundFile = String(hints["sound-file"] ?? "");
|
|
const soundName = String(hints["sound-name"] ?? "");
|
|
const candidates = soundFile.startsWith("/")
|
|
? [soundFile]
|
|
: (soundName !== ""
|
|
? SoundFeedback.soundCandidates(soundName)
|
|
: SoundFeedback.bellCandidates);
|
|
// A hint naming something unresolvable is a request for that sound, not
|
|
// a request for the bell -- substituting would be a lie about which
|
|
// notification arrived.
|
|
if (candidates.length === 0)
|
|
return;
|
|
|
|
const now = Date.now();
|
|
if (bell.running || now - root.lastBellAt < 1000)
|
|
return;
|
|
root.lastBellAt = now;
|
|
bell.command = SoundFeedback.playCommand(candidates);
|
|
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);
|
|
}
|
|
}
|