fractal/session/view/account_settings/
notifications_page.rs

1use adw::{prelude::*, subclass::prelude::*};
2use gettextrs::gettext;
3use gtk::{gio, glib, glib::clone, CompositeTemplate};
4use tracing::error;
5
6use crate::{
7    components::{CheckLoadingRow, EntryAddRow, RemovableRow, SwitchLoadingRow},
8    i18n::gettext_f,
9    session::model::{NotificationsGlobalSetting, NotificationsSettings},
10    spawn, toast,
11    utils::{BoundObjectWeakRef, PlaceholderObject, SingleItemListModel},
12};
13
14mod imp {
15    use std::{cell::Cell, marker::PhantomData};
16
17    use glib::subclass::InitializingObject;
18
19    use super::*;
20
21    #[derive(Debug, Default, CompositeTemplate, glib::Properties)]
22    #[template(
23        resource = "/org/gnome/Fractal/ui/session/view/account_settings/notifications_page.ui"
24    )]
25    #[properties(wrapper_type = super::NotificationsPage)]
26    pub struct NotificationsPage {
27        #[template_child]
28        account_row: TemplateChild<SwitchLoadingRow>,
29        #[template_child]
30        session_row: TemplateChild<adw::SwitchRow>,
31        #[template_child]
32        global: TemplateChild<adw::PreferencesGroup>,
33        #[template_child]
34        global_all_row: TemplateChild<CheckLoadingRow>,
35        #[template_child]
36        global_direct_row: TemplateChild<CheckLoadingRow>,
37        #[template_child]
38        global_mentions_row: TemplateChild<CheckLoadingRow>,
39        #[template_child]
40        keywords: TemplateChild<gtk::ListBox>,
41        #[template_child]
42        keywords_add_row: TemplateChild<EntryAddRow>,
43        /// The notifications settings of the current session.
44        #[property(get, set = Self::set_notifications_settings, explicit_notify)]
45        notifications_settings: BoundObjectWeakRef<NotificationsSettings>,
46        /// Whether the account section is busy.
47        #[property(get)]
48        account_loading: Cell<bool>,
49        /// Whether the global section is busy.
50        #[property(get)]
51        global_loading: Cell<bool>,
52        /// The global notifications setting, as a string.
53        #[property(get = Self::global_setting, set = Self::set_global_setting)]
54        global_setting: PhantomData<String>,
55    }
56
57    #[glib::object_subclass]
58    impl ObjectSubclass for NotificationsPage {
59        const NAME: &'static str = "NotificationsPage";
60        type Type = super::NotificationsPage;
61        type ParentType = adw::PreferencesPage;
62
63        fn class_init(klass: &mut Self::Class) {
64            Self::bind_template(klass);
65            Self::bind_template_callbacks(klass);
66
67            klass.install_property_action("notifications.set-global-default", "global-setting");
68        }
69
70        fn instance_init(obj: &InitializingObject<Self>) {
71            obj.init_template();
72        }
73    }
74
75    #[glib::derived_properties]
76    impl ObjectImpl for NotificationsPage {}
77
78    impl WidgetImpl for NotificationsPage {}
79    impl PreferencesPageImpl for NotificationsPage {}
80
81    #[gtk::template_callbacks]
82    impl NotificationsPage {
83        /// Set the notifications settings of the current session.
84        fn set_notifications_settings(
85            &self,
86            notifications_settings: Option<&NotificationsSettings>,
87        ) {
88            if self.notifications_settings.obj().as_ref() == notifications_settings {
89                return;
90            }
91
92            self.notifications_settings.disconnect_signals();
93
94            if let Some(settings) = notifications_settings {
95                let account_enabled_handler = settings.connect_account_enabled_notify(clone!(
96                    #[weak(rename_to = imp)]
97                    self,
98                    move |_| {
99                        imp.update_account();
100                    }
101                ));
102                let session_enabled_handler = settings.connect_session_enabled_notify(clone!(
103                    #[weak(rename_to = imp)]
104                    self,
105                    move |_| {
106                        imp.update_session();
107                    }
108                ));
109                let global_setting_handler = settings.connect_global_setting_notify(clone!(
110                    #[weak(rename_to = imp)]
111                    self,
112                    move |_| {
113                        imp.update_global();
114                    }
115                ));
116
117                self.notifications_settings.set(
118                    settings,
119                    vec![
120                        account_enabled_handler,
121                        session_enabled_handler,
122                        global_setting_handler,
123                    ],
124                );
125
126                let extra_items = SingleItemListModel::new(&PlaceholderObject::new("add"));
127
128                let all_items = gio::ListStore::new::<glib::Object>();
129                all_items.append(&settings.keywords_list());
130                all_items.append(&extra_items);
131
132                let flattened_list = gtk::FlattenListModel::new(Some(all_items));
133                self.keywords.bind_model(
134                    Some(&flattened_list),
135                    clone!(
136                        #[weak(rename_to = imp)]
137                        self,
138                        #[upgrade_or_else]
139                        || { adw::ActionRow::new().upcast() },
140                        move |item| imp.create_keyword_row(item)
141                    ),
142                );
143            } else {
144                self.keywords.bind_model(
145                    None::<&gio::ListModel>,
146                    clone!(
147                        #[weak(rename_to = imp)]
148                        self,
149                        #[upgrade_or_else]
150                        || { adw::ActionRow::new().upcast() },
151                        move |item| imp.create_keyword_row(item)
152                    ),
153                );
154            }
155
156            self.update_account();
157            self.obj().notify_notifications_settings();
158        }
159
160        /// The global notifications setting, as a string.
161        fn global_setting(&self) -> String {
162            let Some(settings) = self.notifications_settings.obj() else {
163                return String::new();
164            };
165
166            settings.global_setting().to_string()
167        }
168
169        /// Set the global notifications setting, as a string.
170        fn set_global_setting(&self, default: &str) {
171            let Ok(default) = default.parse::<NotificationsGlobalSetting>() else {
172                error!("Invalid value to set global default notifications setting: {default}");
173                return;
174            };
175
176            spawn!(clone!(
177                #[weak(rename_to = imp)]
178                self,
179                async move {
180                    imp.global_setting_changed(default).await;
181                }
182            ));
183        }
184
185        /// Update the section about the account.
186        fn update_account(&self) {
187            let Some(settings) = self.notifications_settings.obj() else {
188                return;
189            };
190
191            let checked = settings.account_enabled();
192            self.account_row.set_is_active(checked);
193            self.account_row.set_sensitive(!self.account_loading.get());
194
195            // Other sections will be disabled or not.
196            self.update_session();
197        }
198
199        /// Update the section about the session.
200        fn update_session(&self) {
201            let Some(settings) = self.notifications_settings.obj() else {
202                return;
203            };
204
205            self.session_row.set_active(settings.session_enabled());
206            self.session_row.set_sensitive(settings.account_enabled());
207
208            // Other sections will be disabled or not.
209            self.update_global();
210            self.update_keywords();
211        }
212
213        /// Update the section about global.
214        fn update_global(&self) {
215            let Some(settings) = self.notifications_settings.obj() else {
216                return;
217            };
218
219            // Updates the active radio button.
220            self.obj().notify_global_setting();
221
222            let sensitive = settings.account_enabled()
223                && settings.session_enabled()
224                && !self.global_loading.get();
225            self.global.set_sensitive(sensitive);
226        }
227
228        /// Update the section about keywords.
229        #[template_callback]
230        fn update_keywords(&self) {
231            let Some(settings) = self.notifications_settings.obj() else {
232                return;
233            };
234
235            let sensitive = settings.account_enabled() && settings.session_enabled();
236            self.keywords.set_sensitive(sensitive);
237
238            if !sensitive {
239                // Nothing else to update.
240                return;
241            }
242
243            self.keywords_add_row
244                .set_inhibit_add(!self.can_add_keyword());
245        }
246
247        fn set_account_loading(&self, loading: bool) {
248            self.account_loading.set(loading);
249            self.obj().notify_account_loading();
250        }
251
252        #[template_callback]
253        async fn account_switched(&self) {
254            let Some(settings) = self.notifications_settings.obj() else {
255                return;
256            };
257
258            let enabled = self.account_row.is_active();
259            if enabled == settings.account_enabled() {
260                // Nothing to do.
261                return;
262            }
263
264            self.account_row.set_sensitive(false);
265            self.set_account_loading(true);
266
267            if settings.set_account_enabled(enabled).await.is_err() {
268                let msg = if enabled {
269                    gettext("Could not enable account notifications")
270                } else {
271                    gettext("Could not disable account notifications")
272                };
273                toast!(self.obj(), msg);
274            }
275
276            self.set_account_loading(false);
277            self.update_account();
278        }
279
280        #[template_callback]
281        fn session_switched(&self) {
282            let Some(settings) = self.notifications_settings.obj() else {
283                return;
284            };
285
286            settings.set_session_enabled(self.session_row.is_active());
287        }
288
289        fn set_global_loading(&self, loading: bool, setting: NotificationsGlobalSetting) {
290            // Only show the spinner on the selected one.
291            self.global_all_row
292                .set_is_loading(loading && setting == NotificationsGlobalSetting::All);
293            self.global_direct_row.set_is_loading(
294                loading && setting == NotificationsGlobalSetting::DirectAndMentions,
295            );
296            self.global_mentions_row
297                .set_is_loading(loading && setting == NotificationsGlobalSetting::MentionsOnly);
298
299            self.global_loading.set(loading);
300            self.obj().notify_global_loading();
301        }
302
303        #[template_callback]
304        async fn global_setting_changed(&self, setting: NotificationsGlobalSetting) {
305            let Some(settings) = self.notifications_settings.obj() else {
306                return;
307            };
308
309            if setting == settings.global_setting() {
310                // Nothing to do.
311                return;
312            }
313
314            self.global.set_sensitive(false);
315            self.set_global_loading(true, setting);
316
317            if settings.set_global_setting(setting).await.is_err() {
318                toast!(
319                    self.obj(),
320                    gettext("Could not change global notifications setting"),
321                );
322            }
323
324            self.set_global_loading(false, setting);
325            self.update_global();
326        }
327
328        /// Create a row in the keywords list for the given item.
329        fn create_keyword_row(&self, item: &glib::Object) -> gtk::Widget {
330            let Some(string_obj) = item.downcast_ref::<gtk::StringObject>() else {
331                // It can only be the dummy item to add a new keyword.
332                return self.keywords_add_row.clone().upcast();
333            };
334
335            let keyword = string_obj.string();
336            let row = RemovableRow::new();
337            row.set_title(&keyword);
338            row.set_remove_button_tooltip_text(Some(gettext_f(
339                "Remove “{keyword}”",
340                &[("keyword", &keyword)],
341            )));
342
343            row.connect_remove(clone!(
344                #[weak(rename_to = imp)]
345                self,
346                move |row| {
347                    imp.remove_keyword(row);
348                }
349            ));
350
351            row.upcast()
352        }
353
354        /// Remove the keyword from the given row.
355        fn remove_keyword(&self, row: &RemovableRow) {
356            let Some(settings) = self.notifications_settings.obj() else {
357                return;
358            };
359
360            row.set_is_loading(true);
361
362            let obj = self.obj();
363            spawn!(clone!(
364                #[weak]
365                obj,
366                #[weak]
367                row,
368                async move {
369                    if settings.remove_keyword(row.title().into()).await.is_err() {
370                        toast!(obj, gettext("Could not remove notification keyword"));
371                    }
372
373                    row.set_is_loading(false);
374                }
375            ));
376        }
377
378        /// Whether we can add the keyword that is currently in the entry.
379        fn can_add_keyword(&self) -> bool {
380            // Cannot add a keyword if section is disabled.
381            if !self.keywords.is_sensitive() {
382                return false;
383            }
384
385            // Cannot add a keyword if a keyword is already being added.
386            if self.keywords_add_row.is_loading() {
387                return false;
388            }
389
390            let text = self.keywords_add_row.text().to_lowercase();
391
392            // Cannot add an empty keyword.
393            if text.is_empty() {
394                return false;
395            }
396
397            // Cannot add a keyword without the API.
398            let Some(settings) = self.notifications_settings.obj() else {
399                return false;
400            };
401
402            // Cannot add a keyword that already exists.
403            let keywords_list = settings.keywords_list();
404            for keyword_obj in keywords_list.iter::<glib::Object>() {
405                let Ok(keyword_obj) = keyword_obj else {
406                    break;
407                };
408
409                if let Some(keyword) = keyword_obj
410                    .downcast_ref::<gtk::StringObject>()
411                    .map(gtk::StringObject::string)
412                {
413                    if keyword.to_lowercase() == text {
414                        return false;
415                    }
416                }
417            }
418
419            true
420        }
421
422        /// Add the keyword that is currently in the entry.
423        #[template_callback]
424        async fn add_keyword(&self) {
425            if !self.can_add_keyword() {
426                return;
427            }
428
429            let Some(settings) = self.notifications_settings.obj() else {
430                return;
431            };
432
433            self.keywords_add_row.set_is_loading(true);
434
435            let keyword = self.keywords_add_row.text().into();
436
437            if settings.add_keyword(keyword).await.is_err() {
438                toast!(self.obj(), gettext("Could not add notification keyword"));
439            } else {
440                // Adding the keyword was successful, reset the entry.
441                self.keywords_add_row.set_text("");
442            }
443
444            self.keywords_add_row.set_is_loading(false);
445            self.update_keywords();
446        }
447    }
448}
449
450glib::wrapper! {
451    /// Preferences page to edit global notification settings.
452    pub struct NotificationsPage(ObjectSubclass<imp::NotificationsPage>)
453        @extends gtk::Widget, adw::PreferencesPage, @implements gtk::Accessible;
454}
455
456impl NotificationsPage {
457    pub fn new(notifications_settings: &NotificationsSettings) -> Self {
458        glib::Object::builder()
459            .property("notifications-settings", notifications_settings)
460            .build()
461    }
462}