diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index 960ef4a2..6f16cf22 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -55,6 +55,7 @@ ui/account-settings-device-row.ui ui/account-settings-devices-page.ui ui/account-settings-import-export-keys-subpage.ui + ui/account-settings-notifications-page.ui ui/account-settings-security-page.ui ui/account-settings-user-page.ui ui/account-settings.ui diff --git a/data/resources/ui/account-settings-notifications-page.ui b/data/resources/ui/account-settings-notifications-page.ui new file mode 100644 index 00000000..b650bfa8 --- /dev/null +++ b/data/resources/ui/account-settings-notifications-page.ui @@ -0,0 +1,47 @@ + + + + diff --git a/data/resources/ui/account-settings.ui b/data/resources/ui/account-settings.ui index 3140cc89..961e2aaa 100644 --- a/data/resources/ui/account-settings.ui +++ b/data/resources/ui/account-settings.ui @@ -9,6 +9,11 @@ + + + + + diff --git a/po/POTFILES.in b/po/POTFILES.in index 00583648..9bf6c50a 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -10,6 +10,7 @@ data/resources/ui/account-settings-deactivate-account-subpage.ui data/resources/ui/account-settings-device-row.ui data/resources/ui/account-settings-devices-page.ui data/resources/ui/account-settings-import-export-keys-subpage.ui +data/resources/ui/account-settings-notifications-page.ui data/resources/ui/account-settings-user-page.ui data/resources/ui/account-settings-security-page.ui data/resources/ui/account-settings.ui @@ -60,6 +61,7 @@ src/login/mod.rs src/secret.rs src/session/account_settings/devices_page/device_list.rs src/session/account_settings/devices_page/device_row.rs +src/session/account_settings/notifications_page.rs src/session/account_settings/security_page/import_export_keys_subpage.rs src/session/account_settings/user_page/change_password_subpage.rs src/session/account_settings/user_page/deactivate_account_subpage.rs diff --git a/src/session/account_settings/mod.rs b/src/session/account_settings/mod.rs index c89aad72..bfb5bb59 100644 --- a/src/session/account_settings/mod.rs +++ b/src/session/account_settings/mod.rs @@ -6,12 +6,14 @@ use gtk::{ }; mod devices_page; +mod notifications_page; mod security_page; mod user_page; -use devices_page::DevicesPage; -use security_page::SecurityPage; -use user_page::UserPage; +use self::{ + devices_page::DevicesPage, notifications_page::NotificationsPage, security_page::SecurityPage, + user_page::UserPage, +}; use super::Session; mod imp { @@ -37,6 +39,7 @@ mod imp { fn class_init(klass: &mut Self::Class) { DevicesPage::static_type(); UserPage::static_type(); + NotificationsPage::static_type(); SecurityPage::static_type(); Self::bind_template(klass); diff --git a/src/session/account_settings/notifications_page.rs b/src/session/account_settings/notifications_page.rs new file mode 100644 index 00000000..0bc481bb --- /dev/null +++ b/src/session/account_settings/notifications_page.rs @@ -0,0 +1,351 @@ +use adw::{prelude::*, subclass::prelude::*}; +use gettextrs::gettext; +use gtk::{glib, glib::clone, CompositeTemplate}; +use log::{error, warn}; +use matrix_sdk::event_handler::EventHandlerDropGuard; +use ruma::{ + api::client::push::{set_pushrule_enabled, RuleKind}, + events::push_rules::{PushRulesEvent, PushRulesEventContent}, + push::Ruleset, +}; + +use crate::{session::UserExt, spawn, spawn_tokio, toast, Session}; + +const MASTER_RULE_ID: &str = ".m.rule.master"; + +mod imp { + use std::cell::{Cell, RefCell}; + + use glib::{subclass::InitializingObject, WeakRef}; + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/Fractal/account-settings-notifications-page.ui")] + pub struct NotificationsPage { + /// The current session. + pub session: WeakRef, + /// Binding to the session settings `notifications-enabled` property. + pub settings_binding: RefCell>, + /// The guard of the event handler for push rules changes. + pub event_handler_guard: RefCell>, + /// Whether notifications are enabled for this account. + pub account_enabled: Cell, + /// Whether an account notifications change is being processed. + pub account_loading: Cell, + /// Whether notifications are enabled for this session. + pub session_enabled: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for NotificationsPage { + const NAME: &'static str = "NotificationsPage"; + type Type = super::NotificationsPage; + type ParentType = adw::PreferencesPage; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for NotificationsPage { + fn properties() -> &'static [glib::ParamSpec] { + use once_cell::sync::Lazy; + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpecObject::new( + "session", + "Session", + "The session", + Session::static_type(), + glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, + ), + glib::ParamSpecBoolean::new( + "account-enabled", + "account-enabled", + "", + false, + glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, + ), + glib::ParamSpecBoolean::new( + "account-loading", + "account-loading", + "", + false, + glib::ParamFlags::READABLE, + ), + glib::ParamSpecBoolean::new( + "session-enabled", + "session-enabled", + "", + false, + glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, + ), + ] + }); + + PROPERTIES.as_ref() + } + + fn set_property( + &self, + obj: &Self::Type, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + match pspec.name() { + "session" => obj.set_session(value.get().unwrap()), + "account-enabled" => obj.sync_account_enabled(value.get().unwrap()), + "session-enabled" => obj.set_session_enabled(value.get().unwrap()), + _ => unimplemented!(), + } + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "session" => obj.session().to_value(), + "account-enabled" => obj.account_enabled().to_value(), + "account-loading" => obj.account_loading().to_value(), + "session-enabled" => obj.session_enabled().to_value(), + _ => unimplemented!(), + } + } + } + + impl WidgetImpl for NotificationsPage {} + impl PreferencesPageImpl for NotificationsPage {} +} + +glib::wrapper! { + /// Preferences page to edit notification settings. + pub struct NotificationsPage(ObjectSubclass) + @extends gtk::Widget, adw::PreferencesPage, @implements gtk::Accessible; +} + +impl NotificationsPage { + pub fn new(session: &Session) -> Self { + glib::Object::new(&[("session", session)]).expect("Failed to create NotificationsPage") + } + + /// The current session. + pub fn session(&self) -> Option { + self.imp().session.upgrade() + } + + /// Set the current session. + pub fn set_session(&self, session: Option) { + let prev_session = self.session(); + if prev_session == session { + return; + } + + let priv_ = self.imp(); + if let Some(binding) = priv_.settings_binding.take() { + binding.unbind(); + } + priv_.event_handler_guard.take(); + + if let Some(session) = &session { + let binding = session + .settings() + .bind_property("notifications-enabled", self, "session-enabled") + .flags(glib::BindingFlags::SYNC_CREATE | glib::BindingFlags::BIDIRECTIONAL) + .build(); + priv_.settings_binding.replace(Some(binding)); + } + + priv_.session.set(session.as_ref()); + self.notify("session"); + + spawn!( + glib::PRIORITY_DEFAULT_IDLE, + clone!(@weak self as obj => async move { + obj.init_page().await; + }) + ); + } + + /// Initialize the page. + async fn init_page(&self) { + let session = match self.session() { + Some(session) => session, + None => return, + }; + + let client = session.client(); + let account = client.account(); + let handle = + spawn_tokio!(async move { account.account_data::().await }); + + match handle.await.unwrap() { + Ok(Some(pushrules)) => match pushrules.deserialize() { + Ok(pushrules) => { + self.update_page(pushrules.global); + } + Err(error) => { + error!("Could not deserialize push rules: {error}"); + toast!( + self, + gettext("Could not load notifications settings. Try again later") + ); + } + }, + Ok(None) => { + warn!("Could not find push rules, using the default ruleset instead."); + let user_id = session.user().unwrap().user_id(); + self.update_page(Ruleset::server_default(&user_id)); + } + Err(error) => { + error!("Could not get push rules: {error}"); + toast!( + self, + gettext("Could not load notifications settings. Try again later") + ); + } + } + + let obj_weak = glib::SendWeakRef::from(self.downgrade()); + let handler = client.add_event_handler(move |event: PushRulesEvent| { + let obj_weak = obj_weak.clone(); + async move { + let ctx = glib::MainContext::default(); + ctx.spawn(async move { + if let Some(obj) = obj_weak.upgrade() { + obj.update_page(event.content.global) + } + }); + } + }); + self.imp() + .event_handler_guard + .replace(Some(client.event_handler_drop_guard(handler))); + } + + /// Update the page for the given ruleset. + fn update_page(&self, rules: Ruleset) { + let account_enabled = + if let Some(rule) = rules.override_.iter().find(|r| r.rule_id == MASTER_RULE_ID) { + !rule.enabled + } else { + warn!("Could not find `.m.rule.master` push rule, using the default rule instead."); + true + }; + self.set_account_enabled(account_enabled); + } + + /// Whether notifications are enabled for this account. + pub fn account_enabled(&self) -> bool { + self.imp().account_enabled.get() + } + + /// Set whether notifications are enabled for this account. + /// + /// This only sets the property locally. + fn set_account_enabled(&self, enabled: bool) { + if self.account_enabled() == enabled { + return; + } + + if !enabled { + if let Some(session) = self.session() { + session.clear_notifications(); + } + } + + self.imp().account_enabled.set(enabled); + self.notify("account-enabled"); + } + + /// Sync whether notifications are enabled for this account. + /// + /// This sets the property locally and synchronizes the change with the + /// homeserver. + pub fn sync_account_enabled(&self, enabled: bool) { + self.set_account_enabled(enabled); + + self.set_account_loading(true); + + spawn!(clone!(@weak self as obj => async move { + obj.send_account_enabled(enabled).await; + })); + } + + /// Send whether notifications are enabled for this account. + /// + /// This only changes the setting on the homeserver. + async fn send_account_enabled(&self, enabled: bool) { + let client = match self.session() { + Some(session) => session.client(), + None => return, + }; + + let request = set_pushrule_enabled::v3::Request::new( + "global", + RuleKind::Override, + MASTER_RULE_ID, + !enabled, + ); + + let handle = spawn_tokio!(async move { client.send(request, None).await }); + + match handle.await.unwrap() { + Ok(_) => {} + Err(error) => { + error!("Could not update `{MASTER_RULE_ID}` push rule: {error}"); + + let msg = if enabled { + gettext("Could not enable account notifications") + } else { + gettext("Could not disable account notifications") + }; + toast!(self, msg); + + // Revert the local change. + self.set_account_enabled(!enabled); + } + } + + self.set_account_loading(false); + } + + /// Whether an account notifications change is being processed. + pub fn account_loading(&self) -> bool { + self.imp().account_loading.get() + } + + /// Set whether an account notifications change is being processed. + fn set_account_loading(&self, loading: bool) { + if self.account_loading() == loading { + return; + } + + self.imp().account_loading.set(loading); + self.notify("account-loading"); + } + + /// Whether notifications are enabled for this session. + pub fn session_enabled(&self) -> bool { + self.imp().session_enabled.get() + } + + /// Set whether notifications are enabled for this session. + pub fn set_session_enabled(&self, enabled: bool) { + if self.session_enabled() == enabled { + return; + } + + if !enabled { + if let Some(session) = self.session() { + session.clear_notifications(); + } + } + + self.imp().session_enabled.set(enabled); + self.notify("session-enabled"); + } +} diff --git a/src/session/mod.rs b/src/session/mod.rs index 5deac4b6..ce1b670a 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -916,6 +916,11 @@ impl Session { /// The notification won't be shown if the application is active and this /// session is displayed. fn show_notification(&self, matrix_notification: Notification) { + // Don't show notifications if they are disabled. + if !self.settings().notifications_enabled() { + return; + } + let window = self.parent_window().unwrap(); // Don't show notifications for the current session if the window is active. diff --git a/src/session/settings.rs b/src/session/settings.rs index cd927f2a..a3fb534f 100644 --- a/src/session/settings.rs +++ b/src/session/settings.rs @@ -1,4 +1,5 @@ use gtk::{glib, prelude::*, subclass::prelude::*}; +use log::error; use serde::{Deserialize, Serialize}; use crate::Application; @@ -11,22 +12,42 @@ struct StoredSessionSettings { /// Custom servers to explore. #[serde(default, skip_serializing_if = "Vec::is_empty")] explore_custom_servers: Vec, + + /// Whether notifications are enabled for this session. + #[serde( + default = "ruma::serde::default_true", + skip_serializing_if = "ruma::serde::is_true" + )] + notifications_enabled: bool, } mod imp { - use std::cell::RefCell; + use std::cell::{Cell, RefCell}; use once_cell::sync::{Lazy, OnceCell}; use super::*; - #[derive(Debug, Default)] + #[derive(Debug)] pub struct SessionSettings { /// The ID of the session these settings are for. pub session_id: OnceCell, /// Custom servers to explore. pub explore_custom_servers: RefCell>, + + /// Whether notifications are enabled for this session. + pub notifications_enabled: Cell, + } + + impl Default for SessionSettings { + fn default() -> Self { + Self { + session_id: Default::default(), + explore_custom_servers: Default::default(), + notifications_enabled: Cell::new(true), + } + } } #[glib::object_subclass] @@ -38,13 +59,22 @@ mod imp { impl ObjectImpl for SessionSettings { fn properties() -> &'static [glib::ParamSpec] { static PROPERTIES: Lazy> = Lazy::new(|| { - vec![glib::ParamSpecString::new( - "session-id", - "Session ID", - "The ID of the session these settings are for", - None, - glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY, - )] + vec![ + glib::ParamSpecString::new( + "session-id", + "Session ID", + "The ID of the session these settings are for", + None, + glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY, + ), + glib::ParamSpecBoolean::new( + "notifications-enabled", + "notifications-enabled", + "", + true, + glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, + ), + ] }); PROPERTIES.as_ref() @@ -59,6 +89,7 @@ mod imp { ) { match pspec.name() { "session-id" => obj.set_session_id(value.get().ok()), + "notifications-enabled" => obj.set_notifications_enabled(value.get().unwrap()), _ => unimplemented!(), } } @@ -66,6 +97,7 @@ mod imp { fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { match pspec.name() { "session-id" => obj.session_id().to_value(), + "notifications-enabled" => obj.notifications_enabled().to_value(), _ => unimplemented!(), } } @@ -107,17 +139,35 @@ impl SessionSettings { priv_.session_id.set(session_id).unwrap(); if let Some(settings) = index.and_then(|idx| sessions.into_iter().nth(idx)) { - *priv_.explore_custom_servers.borrow_mut() = settings.explore_custom_servers; + self.update_from_stored_settings(settings); } else { self.store_settings(); } } - fn store_settings(&self) { - let new_settings = StoredSessionSettings { + fn update_from_stored_settings(&self, settings: StoredSessionSettings) { + let priv_ = self.imp(); + let StoredSessionSettings { + session_id: _, + explore_custom_servers, + notifications_enabled, + } = settings; + + *priv_.explore_custom_servers.borrow_mut() = explore_custom_servers; + priv_.notifications_enabled.set(notifications_enabled); + } + + fn as_stored_settings(&self) -> StoredSessionSettings { + StoredSessionSettings { session_id: self.session_id().to_owned(), explore_custom_servers: self.explore_custom_servers(), - }; + notifications_enabled: self.notifications_enabled(), + } + } + + fn store_settings(&self) { + let new_settings = self.as_stored_settings(); + let app_settings = Application::default().settings(); let mut sessions = serde_json::from_str::>(&app_settings.string("sessions")) @@ -135,7 +185,7 @@ impl SessionSettings { if let Err(error) = app_settings.set_string("sessions", &serde_json::to_string(&sessions).unwrap()) { - log::error!("Error storing settings for session: {error}"); + error!("Error storing settings for session: {error}"); } } @@ -180,4 +230,20 @@ impl SessionSettings { self.imp().explore_custom_servers.replace(servers); self.store_settings(); } + + /// Whether notifications are enabled for this session. + pub fn notifications_enabled(&self) -> bool { + self.imp().notifications_enabled.get() + } + + /// Set whether notifications are enabled for this session. + pub fn set_notifications_enabled(&self, enabled: bool) { + if self.notifications_enabled() == enabled { + return; + } + + self.imp().notifications_enabled.replace(enabled); + self.store_settings(); + self.notify("notifications-enabled"); + } }