Skip to content

Commit 5304a85

Browse files
authored
Add per app notification settings (#13598)
1 parent e7c5a36 commit 5304a85

2 files changed

Lines changed: 190 additions & 22 deletions

File tree

files/usr/share/cinnamon/cinnamon-settings/modules/cs_notifications.py

Lines changed: 151 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import gi
44
gi.require_version('Notify', '0.7')
5-
from gi.repository import Gio, Notify
5+
from gi.repository import Gio, Notify, Gtk, Pango
6+
import re
67

78
from bin.SettingsWidgets import SidePage
89
from xapp.GSettingsWidgets import *
@@ -50,11 +51,16 @@ def on_module_selected(self):
5051
page = SettingsPage()
5152
self.sidePage.add_widget(page)
5253

53-
settings = page.add_section(_("Notification settings"))
54+
settings = page.add_section()
5455

5556
switch = GSettingsSwitch(_("Enable notifications"), "org.cinnamon.desktop.notifications", "display-notifications")
5657
settings.add_row(switch)
5758

59+
button = Button(_("Application notifications"), self.open_app_settings)
60+
settings.add_reveal_row(button, "org.cinnamon.desktop.notifications", "display-notifications")
61+
62+
settings = page.add_reveal_section(_("Notification settings"), "org.cinnamon.desktop.notifications", "display-notifications")
63+
5864
switch = GSettingsSwitch(_("Remove notifications after their timeout is reached"), "org.cinnamon.desktop.notifications", "remove-old")
5965
settings.add_reveal_row(switch, "org.cinnamon.desktop.notifications", "display-notifications")
6066

@@ -86,3 +92,146 @@ def on_module_selected(self):
8692
def send_test(self, widget):
8793
n = Notify.Notification.new(_("This is a test notification"), content, "dialog-warning")
8894
n.show()
95+
96+
def open_app_settings(self, widget):
97+
win = AppNotificationsWindow(widget.get_toplevel())
98+
99+
PER_APP_SCHEMA = "org.cinnamon.desktop.notifications.application"
100+
PER_APP_BASE_PATH = "/org/cinnamon/desktop/notifications/application/"
101+
102+
class AppNotificationRow(Gtk.ListBoxRow):
103+
def __init__(self, app_info, parent_settings):
104+
super().__init__()
105+
self.parent_settings = parent_settings
106+
self.set_activatable(True)
107+
self.set_selectable(False)
108+
self.set_can_focus(True)
109+
110+
self.app_name = app_info.get_name().lower()
111+
112+
# Sanitise app ID for GSettings path (this should remain the same as in ui/messageTray.js)
113+
# 1. Convert to lower case.
114+
# 2. Replace any one or more consecutive characters that is not a lowercase letter or a digit with a hyphen.
115+
# 3. Trim any leading or trailing hyphens.
116+
app_id = app_info.get_id().lower().replace(".desktop", "")
117+
self.settings_id = re.sub(r'[^a-z0-9]+', '-', app_id).strip('-')
118+
path = f"{PER_APP_BASE_PATH}{self.settings_id}/"
119+
120+
self.settings = Gio.Settings.new_with_path(PER_APP_SCHEMA, path)
121+
122+
hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
123+
hbox.set_margin_start(8)
124+
hbox.set_margin_end(8)
125+
hbox.set_margin_top(4)
126+
hbox.set_margin_bottom(4)
127+
128+
# Icon
129+
gicon = app_info.get_icon()
130+
if not gicon:
131+
gicon = Gio.ThemedIcon.new("application-x-executable")
132+
icon = Gtk.Image.new_from_gicon(gicon, Gtk.IconSize.DND)
133+
icon.set_pixel_size(32)
134+
hbox.pack_start(icon, False, False, 0)
135+
136+
# Labels
137+
name_label = Gtk.Label(label=app_info.get_name(), xalign=0)
138+
name_label.set_ellipsize(Pango.EllipsizeMode.END)
139+
hbox.pack_start(name_label, True, True, 0)
140+
141+
# Switch
142+
self.switch = Gtk.Switch()
143+
self.switch.set_active(self.settings.get_boolean("enabled"))
144+
self.settings.bind("enabled", self.switch, "active", Gio.SettingsBindFlags.DEFAULT)
145+
self.settings.connect("changed::enabled", self.update_index)
146+
hbox.pack_start(self.switch, False, False, 0)
147+
148+
self.add(hbox)
149+
150+
def update_index(self, settings, key):
151+
current_children = list(self.parent_settings.get_strv("application-children"))
152+
153+
if self.settings.get_boolean("enabled"):
154+
# Since 'true' is the default, we can remove the custom setting from dconf
155+
if self.settings_id in current_children:
156+
current_children.remove(self.settings_id)
157+
self.parent_settings.set_strv("application-children", current_children)
158+
self.settings.reset("enabled")
159+
else:
160+
if self.settings_id not in current_children:
161+
current_children.append(self.settings_id)
162+
self.parent_settings.set_strv("application-children", current_children)
163+
164+
def toggle_switch(self):
165+
self.switch.set_active(not self.switch.get_active())
166+
167+
class AppNotificationsWindow(Gtk.Dialog):
168+
def __init__(self, parent):
169+
super().__init__(title=_("Application Notifications"), transient_for=parent)
170+
self.set_modal(True)
171+
self.set_destroy_with_parent(True)
172+
self.set_default_size(430, 480)
173+
self.set_border_width(10)
174+
175+
frame = Gtk.Frame()
176+
frame.set_border_width(6)
177+
frame.set_shadow_type(Gtk.ShadowType.IN)
178+
inner_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
179+
self.search_entry = Gtk.SearchEntry()
180+
self.search_entry.set_margin_start(16)
181+
self.search_entry.set_margin_end(16)
182+
self.search_entry.connect("search-changed", self.on_search_changed)
183+
scrolled = Gtk.ScrolledWindow()
184+
scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
185+
186+
self.listbox = Gtk.ListBox()
187+
self.listbox.set_selection_mode(Gtk.SelectionMode.NONE)
188+
self.listbox.connect("row-activated", self.on_row_activated)
189+
self.listbox.set_filter_func(self.filter_func)
190+
191+
self.parent_settings = Gio.Settings.new("org.cinnamon.desktop.notifications")
192+
apps = Gio.AppInfo.get_all()
193+
# Filter for unique apps that are not hidden
194+
seen_ids = set()
195+
for app in sorted(apps, key=lambda x: x.get_name()):
196+
app_id = app.get_id()
197+
if app.should_show() and app_id not in seen_ids and not app_id.startswith("cinnamon-settings-"):
198+
row = AppNotificationRow(app, self.parent_settings)
199+
self.listbox.add(row)
200+
seen_ids.add(app_id)
201+
202+
scrolled.add(self.listbox)
203+
inner_vbox.pack_start(self.search_entry, False, False, 6)
204+
inner_vbox.pack_start(scrolled, True, True, 0)
205+
frame.add(inner_vbox)
206+
content_area = self.get_content_area()
207+
content_area.pack_start(frame, True, True, 0)
208+
209+
reset_button = Gtk.Button(label=_("Reset All"))
210+
reset_button.connect("clicked", self.on_reset_all_clicked)
211+
self.add_action_widget(reset_button, Gtk.ResponseType.NONE)
212+
213+
self.show_all()
214+
215+
def on_row_activated(self, listbox, row):
216+
row.toggle_switch()
217+
218+
def filter_func(self, row):
219+
search_text = self.search_entry.get_text().lower()
220+
if not search_text:
221+
return True
222+
return search_text in row.app_name
223+
224+
def on_search_changed(self, entry):
225+
self.listbox.invalidate_filter()
226+
227+
def on_reset_all_clicked(self, button):
228+
overridden_apps = self.parent_settings.get_strv("application-children")
229+
if not overridden_apps:
230+
return
231+
232+
for app_id in overridden_apps:
233+
path = f"{PER_APP_BASE_PATH}{app_id}/"
234+
app_settings = Gio.Settings.new_with_path(PER_APP_SCHEMA, path)
235+
app_settings.reset("enabled")
236+
237+
self.parent_settings.set_strv("application-children", [])

js/ui/messageTray.js

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,7 @@ MessageTray.prototype = {
765765
this._notificationTimeoutId = 0;
766766
this._notificationExpandedId = 0;
767767
this._notificationRemoved = false;
768+
this._appSettingsCache = {};
768769

769770
this._sources = [];
770771
Main.layoutManager.addChrome(this._notificationBin);
@@ -859,7 +860,38 @@ MessageTray.prototype = {
859860
this._updateState();
860861
},
861862

863+
_isAppEnabled: function(source) {
864+
if (!source.app) return true;
865+
866+
let appId = source.app.get_id();
867+
if (appId.endsWith(":flatpak")) appId = appId.slice(0, -8);
868+
if (appId.endsWith(".desktop")) appId = appId.slice(0, -8);
869+
// Sanitise ID for GSettings path. (this should remain the same as in cs_notifications.py)
870+
// 1. Convert to lower case.
871+
// 2. Replace any one or more consecutive characters that is not a lowercase letter or a digit with a hyphen.
872+
// 3. Trim any leading or trailing hyphens.
873+
appId = appId.toLowerCase();
874+
const settingsId = appId.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
875+
876+
if (!this._appSettingsCache[settingsId]) {
877+
const path = `/org/cinnamon/desktop/notifications/application/${settingsId}/`;
878+
879+
this._appSettingsCache[settingsId] = new Gio.Settings({
880+
schema_id: "org.cinnamon.desktop.notifications.application",
881+
path: path
882+
});
883+
}
884+
885+
// The default for "enabled" key is true so this returns true if the path doesn't exist.
886+
return this._appSettingsCache[settingsId].get_boolean("enabled");
887+
},
888+
862889
_onNotify: function (source, notification) {
890+
if (!this._notificationsEnabled || !this._isAppEnabled(source)) {
891+
notification.destroy(NotificationDestroyedReason.DISMISSED);
892+
return;
893+
}
894+
863895
if (this._notification == notification) {
864896
// If a notification that is being shown is updated, we update
865897
// how it is shown and extend the time until it auto-hides.
@@ -900,28 +932,15 @@ MessageTray.prototype = {
900932
// _updateState() figures out what (if anything) needs to be done
901933
// at the present time.
902934
_updateState: function () {
903-
// Notifications
904-
let notificationUrgent = this._notificationQueue.length > 0 && this._notificationQueue[0].urgency == Urgency.CRITICAL;
905-
let notificationsPending = this._notificationQueue.length > 0 && (!this._busy || notificationUrgent);
906-
907-
let notificationExpired = (this._notificationTimeoutId == 0 &&
908-
!(this._notification && this._notification.urgency == Urgency.CRITICAL) &&
909-
!this._locked
910-
) || this._notificationRemoved;
911-
let canShowNotification = notificationsPending && this._notificationsEnabled;
912-
913-
if (this._notificationState == State.HIDDEN) {
914-
if (canShowNotification) {
935+
if (this._notificationState === State.HIDDEN && this._notificationQueue.length > 0) {
936+
if (!this._busy || this._notificationQueue[0].urgency === Urgency.CRITICAL) {
915937
this._showNotification();
916938
}
917-
else if (!this._notificationsEnabled) {
918-
if (notificationsPending) {
919-
this._notification = this._notificationQueue.shift();
920-
this._notification.destroy(NotificationDestroyedReason.DISMISSED);
921-
this._notification = null;
922-
}
923-
}
924-
} else if (this._notificationState == State.SHOWN) {
939+
} else if (this._notificationState === State.SHOWN) {
940+
const isCritical = this._notification && this._notification.urgency === Urgency.CRITICAL;
941+
const notificationExpired = (this._notificationTimeoutId === 0 &&
942+
!isCritical && !this._locked) || this._notificationRemoved;
943+
925944
if (notificationExpired)
926945
this._hideNotification();
927946
}

0 commit comments

Comments
 (0)