From fceded96fb13a80899ae953faf4075bd1ba24ecb Mon Sep 17 00:00:00 2001 From: fredcw <58893963+fredcw@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:52:53 +0000 Subject: [PATCH] Add per app notification settings --- .../modules/cs_notifications.py | 149 +++++++++++++++++- js/ui/messageTray.js | 59 ++++--- 2 files changed, 187 insertions(+), 21 deletions(-) diff --git a/files/usr/share/cinnamon/cinnamon-settings/modules/cs_notifications.py b/files/usr/share/cinnamon/cinnamon-settings/modules/cs_notifications.py index 785b2e5f2e..bd87fd0c14 100755 --- a/files/usr/share/cinnamon/cinnamon-settings/modules/cs_notifications.py +++ b/files/usr/share/cinnamon/cinnamon-settings/modules/cs_notifications.py @@ -2,7 +2,8 @@ import gi gi.require_version('Notify', '0.7') -from gi.repository import Gio, Notify +from gi.repository import Gio, Notify, Gtk, Pango +import re from bin.SettingsWidgets import SidePage from xapp.GSettingsWidgets import * @@ -55,6 +56,9 @@ def on_module_selected(self): switch = GSettingsSwitch(_("Enable notifications"), "org.cinnamon.desktop.notifications", "display-notifications") settings.add_row(switch) + button = Button(_("Application notifications"), self.open_app_settings) + settings.add_reveal_row(button, "org.cinnamon.desktop.notifications", "display-notifications") + switch = GSettingsSwitch(_("Remove notifications after their timeout is reached"), "org.cinnamon.desktop.notifications", "remove-old") settings.add_reveal_row(switch, "org.cinnamon.desktop.notifications", "display-notifications") @@ -86,3 +90,146 @@ def on_module_selected(self): def send_test(self, widget): n = Notify.Notification.new(_("This is a test notification"), content, "dialog-warning") n.show() + + def open_app_settings(self, widget): + win = AppNotificationsWindow(widget.get_toplevel()) + +PER_APP_SCHEMA = "org.cinnamon.desktop.notifications.application" +PER_APP_BASE_PATH = "/org/cinnamon/desktop/notifications/application/" + +class AppNotificationRow(Gtk.ListBoxRow): + def __init__(self, app_info, parent_settings): + super().__init__() + self.parent_settings = parent_settings + self.set_activatable(True) + self.set_selectable(False) + self.set_can_focus(True) + + self.app_name = app_info.get_name().lower() + + # Sanitise app ID for GSettings path (this should remain the same as in ui/messageTray.js) + # 1. Convert to lower case. + # 2. Replace any one or more consecutive characters that is not a lowercase letter or a digit with a hyphen. + # 3. Trim any leading or trailing hyphens. + app_id = app_info.get_id().lower().replace(".desktop", "") + self.settings_id = re.sub(r'[^a-z0-9]+', '-', app_id).strip('-') + path = f"{PER_APP_BASE_PATH}{self.settings_id}/" + + self.settings = Gio.Settings.new_with_path(PER_APP_SCHEMA, path) + + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + hbox.set_margin_start(8) + hbox.set_margin_end(8) + hbox.set_margin_top(4) + hbox.set_margin_bottom(4) + + # Icon + gicon = app_info.get_icon() + if not gicon: + gicon = Gio.ThemedIcon.new("application-x-executable") + icon = Gtk.Image.new_from_gicon(gicon, Gtk.IconSize.DND) + icon.set_pixel_size(32) + hbox.pack_start(icon, False, False, 0) + + # Labels + name_label = Gtk.Label(label=app_info.get_name(), xalign=0) + name_label.set_ellipsize(Pango.EllipsizeMode.END) + hbox.pack_start(name_label, True, True, 0) + + # Switch + self.switch = Gtk.Switch() + self.switch.set_active(self.settings.get_boolean("enabled")) + self.settings.bind("enabled", self.switch, "active", Gio.SettingsBindFlags.DEFAULT) + self.settings.connect("changed::enabled", self.update_index) + hbox.pack_start(self.switch, False, False, 0) + + self.add(hbox) + + def update_index(self, settings, key): + current_children = list(self.parent_settings.get_strv("application-children")) + + if self.settings.get_boolean("enabled"): + # Since 'true' is the default, we can remove the custom setting from dconf + if self.settings_id in current_children: + current_children.remove(self.settings_id) + self.parent_settings.set_strv("application-children", current_children) + self.settings.reset("enabled") + else: + if self.settings_id not in current_children: + current_children.append(self.settings_id) + self.parent_settings.set_strv("application-children", current_children) + + def toggle_switch(self): + self.switch.set_active(not self.switch.get_active()) + +class AppNotificationsWindow(Gtk.Dialog): + def __init__(self, parent): + super().__init__(title=_("Application Notifications"), transient_for=parent) + self.set_modal(True) + self.set_destroy_with_parent(True) + self.set_default_size(430, 480) + self.set_border_width(10) + + frame = Gtk.Frame() + frame.set_border_width(6) + frame.set_shadow_type(Gtk.ShadowType.IN) + inner_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + self.search_entry = Gtk.SearchEntry() + self.search_entry.set_margin_start(16) + self.search_entry.set_margin_end(16) + self.search_entry.connect("search-changed", self.on_search_changed) + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + + self.listbox = Gtk.ListBox() + self.listbox.set_selection_mode(Gtk.SelectionMode.NONE) + self.listbox.connect("row-activated", self.on_row_activated) + self.listbox.set_filter_func(self.filter_func) + + self.parent_settings = Gio.Settings.new("org.cinnamon.desktop.notifications") + apps = Gio.AppInfo.get_all() + # Filter for unique apps that are not hidden + seen_ids = set() + for app in sorted(apps, key=lambda x: x.get_name()): + app_id = app.get_id() + if app.should_show() and app_id not in seen_ids and not app_id.startswith("cinnamon-settings-"): + row = AppNotificationRow(app, self.parent_settings) + self.listbox.add(row) + seen_ids.add(app_id) + + scrolled.add(self.listbox) + inner_vbox.pack_start(self.search_entry, False, False, 6) + inner_vbox.pack_start(scrolled, True, True, 0) + frame.add(inner_vbox) + content_area = self.get_content_area() + content_area.pack_start(frame, True, True, 0) + + reset_button = Gtk.Button(label=_("Reset All")) + reset_button.connect("clicked", self.on_reset_all_clicked) + self.add_action_widget(reset_button, Gtk.ResponseType.NONE) + + self.show_all() + + def on_row_activated(self, listbox, row): + row.toggle_switch() + + def filter_func(self, row): + search_text = self.search_entry.get_text().lower() + if not search_text: + return True + return search_text in row.app_name + + def on_search_changed(self, entry): + self.listbox.invalidate_filter() + + def on_reset_all_clicked(self, button): + overridden_apps = self.parent_settings.get_strv("application-children") + if not overridden_apps: + return + + for app_id in overridden_apps: + path = f"{PER_APP_BASE_PATH}{app_id}/" + app_settings = Gio.Settings.new_with_path(PER_APP_SCHEMA, path) + app_settings.reset("enabled") + + self.parent_settings.set_strv("application-children", []) diff --git a/js/ui/messageTray.js b/js/ui/messageTray.js index 00ac0d67fc..b1495031fc 100644 --- a/js/ui/messageTray.js +++ b/js/ui/messageTray.js @@ -765,6 +765,7 @@ MessageTray.prototype = { this._notificationTimeoutId = 0; this._notificationExpandedId = 0; this._notificationRemoved = false; + this._appSettingsCache = {}; this._sources = []; Main.layoutManager.addChrome(this._notificationBin); @@ -859,7 +860,38 @@ MessageTray.prototype = { this._updateState(); }, + _isAppEnabled: function(source) { + if (!source.app) return true; + + let appId = source.app.get_id(); + if (appId.endsWith(":flatpak")) appId = appId.slice(0, -8); + if (appId.endsWith(".desktop")) appId = appId.slice(0, -8); + // Sanitise ID for GSettings path. (this should remain the same as in cs_notifications.py) + // 1. Convert to lower case. + // 2. Replace any one or more consecutive characters that is not a lowercase letter or a digit with a hyphen. + // 3. Trim any leading or trailing hyphens. + appId = appId.toLowerCase(); + const settingsId = appId.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + + if (!this._appSettingsCache[settingsId]) { + const path = `/org/cinnamon/desktop/notifications/application/${settingsId}/`; + + this._appSettingsCache[settingsId] = new Gio.Settings({ + schema_id: "org.cinnamon.desktop.notifications.application", + path: path + }); + } + + // The default for "enabled" key is true so this returns true if the path doesn't exist. + return this._appSettingsCache[settingsId].get_boolean("enabled"); + }, + _onNotify: function (source, notification) { + if (!this._notificationsEnabled || !this._isAppEnabled(source)) { + notification.destroy(NotificationDestroyedReason.DISMISSED); + return; + } + if (this._notification == notification) { // If a notification that is being shown is updated, we update // how it is shown and extend the time until it auto-hides. @@ -900,28 +932,15 @@ MessageTray.prototype = { // _updateState() figures out what (if anything) needs to be done // at the present time. _updateState: function () { - // Notifications - let notificationUrgent = this._notificationQueue.length > 0 && this._notificationQueue[0].urgency == Urgency.CRITICAL; - let notificationsPending = this._notificationQueue.length > 0 && (!this._busy || notificationUrgent); - - let notificationExpired = (this._notificationTimeoutId == 0 && - !(this._notification && this._notification.urgency == Urgency.CRITICAL) && - !this._locked - ) || this._notificationRemoved; - let canShowNotification = notificationsPending && this._notificationsEnabled; - - if (this._notificationState == State.HIDDEN) { - if (canShowNotification) { + if (this._notificationState === State.HIDDEN && this._notificationQueue.length > 0) { + if (!this._busy || this._notificationQueue[0].urgency === Urgency.CRITICAL) { this._showNotification(); } - else if (!this._notificationsEnabled) { - if (notificationsPending) { - this._notification = this._notificationQueue.shift(); - this._notification.destroy(NotificationDestroyedReason.DISMISSED); - this._notification = null; - } - } - } else if (this._notificationState == State.SHOWN) { + } else if (this._notificationState === State.SHOWN) { + const isCritical = this._notification && this._notification.urgency === Urgency.CRITICAL; + const notificationExpired = (this._notificationTimeoutId === 0 && + !isCritical && !this._locked) || this._notificationRemoved; + if (notificationExpired) this._hideNotification(); }