diff --git a/src/session/model/notifications/notifications_settings.rs b/src/session/model/notifications/notifications_settings.rs index 256d9eed..8778cd78 100644 --- a/src/session/model/notifications/notifications_settings.rs +++ b/src/session/model/notifications/notifications_settings.rs @@ -54,6 +54,9 @@ mod imp { /// The global setting about which messages trigger notifications. #[property(get, builder(NotificationsGlobalSetting::default()))] pub global_setting: Cell, + /// The list of keywords that trigger notifications. + #[property(get)] + pub keywords_list: gtk::StringList, } #[glib::object_subclass] @@ -211,6 +214,11 @@ impl NotificationsSettings { } else { self.set_global_setting_inner(NotificationsGlobalSetting::MentionsOnly); } + + let keywords = spawn_tokio!(async move { api.enabled_keywords().await }) + .await + .unwrap(); + self.update_keywords_list(&keywords); } /// Set whether notifications are enabled for this session. @@ -293,6 +301,91 @@ impl NotificationsSettings { self.imp().global_setting.set(setting); self.notify_global_setting(); } + + /// Update the local list of keywords with the remote one. + fn update_keywords_list<'a>(&self, keywords: impl IntoIterator) { + let list = &self.imp().keywords_list; + let mut diverges_at = None; + + let keywords = keywords.into_iter().map(|s| s.as_str()).collect::>(); + let new_len = keywords.len() as u32; + let old_len = list.n_items(); + + // Check if there is any keyword that changed, was moved or was added. + for (pos, keyword) in keywords.iter().enumerate() { + if Some(*keyword) + != list + .item(pos as u32) + .and_downcast::() + .map(|o| o.string()) + .as_deref() + { + diverges_at = Some(pos as u32); + break; + } + } + + // Check if keywords were removed. + if diverges_at.is_none() && old_len > new_len { + diverges_at = Some(new_len); + } + + let Some(pos) = diverges_at else { + // Nothing to do. + return; + }; + + let additions = &keywords[pos as usize..]; + list.splice(pos, old_len.saturating_sub(pos), additions) + } + + /// Remove a keyword from the list. + pub async fn remove_keyword(&self, keyword: String) -> Result<(), NotificationSettingsError> { + let Some(api) = self.api() else { + error!("Cannot update notifications settings when API is not initialized"); + return Err(NotificationSettingsError::UnableToUpdatePushRule); + }; + + let api_clone = api.clone(); + let keyword_clone = keyword.clone(); + let handle = spawn_tokio!(async move { api_clone.remove_keyword(&keyword_clone).await }); + + if let Err(error) = handle.await.unwrap() { + error!("Failed to remove notification keyword `{keyword}`: {error}"); + return Err(error); + } + + let keywords = spawn_tokio!(async move { api.enabled_keywords().await }) + .await + .unwrap(); + self.update_keywords_list(&keywords); + + Ok(()) + } + + /// Add a keyword to the list. + pub async fn add_keyword(&self, keyword: String) -> Result<(), NotificationSettingsError> { + let Some(api) = self.api() else { + error!("Cannot update notifications settings when API is not initialized"); + return Err(NotificationSettingsError::UnableToUpdatePushRule); + }; + + let api_clone = api.clone(); + let keyword_clone = keyword.clone(); + let handle = spawn_tokio!(async move { api_clone.add_keyword(keyword_clone).await }); + + if let Err(error) = handle.await.unwrap() { + error!("Failed to add notification keyword `{keyword}`: {error}"); + return Err(error); + } + + let keywords = spawn_tokio!(async move { api.enabled_keywords().await }) + .await + .unwrap(); + self.update_keywords_list(&keywords); + + Ok(()) + } } impl Default for NotificationsSettings { diff --git a/src/session/view/account_settings/notifications_page.rs b/src/session/view/account_settings/notifications_page.rs index b74e3b9b..9ea235cd 100644 --- a/src/session/view/account_settings/notifications_page.rs +++ b/src/session/view/account_settings/notifications_page.rs @@ -1,17 +1,22 @@ use adw::{prelude::*, subclass::prelude::*}; use gettextrs::gettext; -use gtk::{glib, glib::clone, CompositeTemplate}; +use gtk::{gio, glib, glib::clone, CompositeTemplate}; use tracing::error; use crate::{ components::{LoadingBin, Spinner}, + i18n::gettext_f, session::model::{NotificationsGlobalSetting, NotificationsSettings}, spawn, toast, - utils::BoundObjectWeakRef, + utils::{BoundObjectWeakRef, DummyObject}, }; mod imp { - use std::{cell::Cell, marker::PhantomData}; + use std::{ + cell::{Cell, RefCell}, + collections::HashMap, + marker::PhantomData, + }; use glib::subclass::InitializingObject; @@ -41,6 +46,13 @@ mod imp { pub global_mentions_bin: TemplateChild, #[template_child] pub global_mentions_radio: TemplateChild, + #[template_child] + pub keywords: TemplateChild, + #[template_child] + pub keywords_add_entry: TemplateChild, + #[template_child] + pub keywords_add_bin: TemplateChild, + pub keywords_suffixes: RefCell>, /// The notifications settings of the current session. #[property(get, set = Self::set_notifications_settings, explicit_notify)] pub notifications_settings: BoundObjectWeakRef, @@ -76,7 +88,17 @@ mod imp { } #[glib::derived_properties] - impl ObjectImpl for NotificationsPage {} + impl ObjectImpl for NotificationsPage { + fn constructed(&self) { + self.parent_constructed(); + let obj = self.obj(); + + self.keywords_add_entry + .connect_changed(clone!(@weak obj => move |_| { + obj.update_keywords(); + })); + } + } impl WidgetImpl for NotificationsPage {} impl PreferencesPageImpl for NotificationsPage {} @@ -116,6 +138,24 @@ mod imp { global_setting_handler, ], ); + + let extra_items = gio::ListStore::new::(); + extra_items.append(&DummyObject::new("add")); + + let all_items = gio::ListStore::new::(); + all_items.append(&settings.keywords_list()); + all_items.append(&extra_items); + + let flattened_list = gtk::FlattenListModel::new(Some(all_items)); + self.keywords.bind_model( + Some(&flattened_list), + clone!(@weak obj => @default-return { adw::ActionRow::new().upcast() }, move |item| obj.create_keyword_row(item)), + ); + } else { + self.keywords.bind_model( + Option::<&gio::ListModel>::None, + clone!(@weak obj => @default-return { adw::ActionRow::new().upcast() }, move |item| obj.create_keyword_row(item)), + ); } obj.update_account(); @@ -186,6 +226,7 @@ impl NotificationsPage { // Other sections will be disabled or not. self.update_global(); + self.update_keywords(); } /// Update the section about global. @@ -203,6 +244,24 @@ impl NotificationsPage { imp.global.set_sensitive(sensitive); } + /// Update the section about keywords. + fn update_keywords(&self) { + let Some(settings) = self.notifications_settings() else { + return; + }; + let imp = self.imp(); + + let sensitive = settings.account_enabled() && settings.session_enabled(); + imp.keywords.set_sensitive(sensitive); + + if !sensitive { + // Nothing else to update. + return; + } + + imp.keywords_add_bin.set_sensitive(self.can_add_keyword()); + } + fn set_account_loading(&self, loading: bool) { self.imp().account_loading.set(loading); self.notify_account_loading(); @@ -291,4 +350,145 @@ impl NotificationsPage { obj.update_global(); })); } + + /// Create a row in the keywords list for the given item. + fn create_keyword_row(&self, item: &glib::Object) -> gtk::Widget { + let imp = self.imp(); + + if let Some(string_obj) = item.downcast_ref::() { + let keyword = string_obj.string(); + let row = adw::ActionRow::builder().title(keyword.clone()).build(); + + let suffix = LoadingBin::new(); + let remove_button = gtk::Button::builder() + .icon_name("close-symbolic") + .valign(gtk::Align::Center) + .halign(gtk::Align::Center) + .css_classes(["flat"]) + .tooltip_text(gettext_f("Remove “{keyword}”", &[("keyword", &keyword)])) + .build(); + remove_button.connect_clicked(clone!(@weak self as obj, @weak row => move |_| { + obj.remove_keyword(row.title()); + })); + suffix.set_child(Some(remove_button)); + + row.add_suffix(&suffix); + // We need to keep track of suffixes to change their loading state. + imp.keywords_suffixes.borrow_mut().insert(keyword, suffix); + + row.upcast() + } else { + // It can only be the dummy item to add a new keyword. + imp.keywords_add_entry.clone().upcast() + } + } + + /// Remove the given keyword. + fn remove_keyword(&self, keyword: glib::GString) { + let Some(settings) = self.notifications_settings() else { + return; + }; + + let Some(suffix) = self.imp().keywords_suffixes.borrow().get(&keyword).cloned() else { + return; + }; + + suffix.set_is_loading(true); + + spawn!( + clone!(@weak self as obj, @weak settings, @weak suffix => async move { + if settings.remove_keyword(keyword.to_string()).await.is_err() { + toast!( + obj, + gettext("Could not remove notification keyword") + ); + } else { + // The row should be removed. + obj.imp().keywords_suffixes.borrow_mut().remove(&keyword); + } + + suffix.set_is_loading(false); + }) + ); + } + + /// Whether we can add the keyword that is currently in the entry. + fn can_add_keyword(&self) -> bool { + let imp = self.imp(); + + // Cannot add a keyword is section is disabled. + if !imp.keywords.is_sensitive() { + return false; + } + + // Cannot add a keyword if a keyword is already being added. + if imp.keywords_add_bin.is_loading() { + return false; + } + + let text = imp.keywords_add_entry.text().to_lowercase(); + + // Cannot add an empty keyword. + if text.is_empty() { + return false; + } + + // Cannot add a keyword without the API. + let Some(settings) = self.notifications_settings() else { + return false; + }; + + // Cannot add a keyword that already exists. + let keywords_list = settings.keywords_list(); + for keyword_obj in keywords_list.iter::() { + let Ok(keyword_obj) = keyword_obj else { + break; + }; + + if let Some(keyword) = keyword_obj + .downcast_ref::() + .map(|o| o.string()) + { + if keyword.to_lowercase() == text { + return false; + } + } + } + + true + } + + /// Add the keyword that is currently in the entry. + #[template_callback] + fn add_keyword(&self) { + let Some(settings) = self.notifications_settings() else { + return; + }; + if !self.can_add_keyword() { + return; + } + let imp = self.imp(); + + imp.keywords_add_entry.set_sensitive(false); + imp.keywords_add_bin.set_is_loading(true); + + spawn!(clone!(@weak self as obj, @weak settings => async move { + let imp = obj.imp(); + let keyword = imp.keywords_add_entry.text().into(); + + if settings.add_keyword(keyword).await.is_err() { + toast!( + obj, + gettext("Could not add notification keyword") + ); + } else { + // Adding the keyword was successful, reset the entry. + imp.keywords_add_entry.set_text(""); + } + + imp.keywords_add_bin.set_is_loading(false); + imp.keywords_add_entry.set_sensitive(true); + obj.update_keywords(); + })); + } } diff --git a/src/session/view/account_settings/notifications_page.ui b/src/session/view/account_settings/notifications_page.ui index c7d854a8..e6fd1660 100644 --- a/src/session/view/account_settings/notifications_page.ui +++ b/src/session/view/account_settings/notifications_page.ui @@ -93,5 +93,38 @@ + + + Keywords + Messages that contain one of these keywords trigger notifications. Matching on these keywords is case-insensitive. + + + + + + + + + Add Keyword… + + + + + + add-symbolic + center + center + Add Keyword + + + + + + + diff --git a/src/utils/dummy_object.rs b/src/utils/dummy_object.rs new file mode 100644 index 00000000..fc89ff9b --- /dev/null +++ b/src/utils/dummy_object.rs @@ -0,0 +1,37 @@ +use gtk::{glib, prelude::*, subclass::prelude::*}; + +mod imp { + use std::cell::RefCell; + + use super::*; + + #[derive(Debug, Default, glib::Properties)] + #[properties(wrapper_type = super::DummyObject)] + pub struct DummyObject { + /// The identifier of this item. + #[property(get, set)] + pub id: RefCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for DummyObject { + const NAME: &'static str = "DummyObject"; + type Type = super::DummyObject; + } + + #[glib::derived_properties] + impl ObjectImpl for DummyObject {} +} + +glib::wrapper! { + /// A dummy GObject. + /// + /// It can be used for example to add extra widgets in a list model and can be identified with its ID. + pub struct DummyObject(ObjectSubclass); +} + +impl DummyObject { + pub fn new(id: &str) -> Self { + glib::Object::builder().property("id", id).build() + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 78df6ebe..11da7544 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,5 +1,6 @@ //! Collection of common methods and types. +mod dummy_object; mod expression_list_model; pub mod macros; pub mod matrix; @@ -27,7 +28,7 @@ use once_cell::sync::{Lazy, OnceCell}; use regex::Regex; use tracing::error; -pub use self::expression_list_model::ExpressionListModel; +pub use self::{dummy_object::DummyObject, expression_list_model::ExpressionListModel}; use crate::RUNTIME; /// Returns an expression that is the and’ed result of the given boolean