diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index 8150002c..fd9dd2e6 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -11,8 +11,11 @@ icons/scalable/status/explore-symbolic.svg icons/scalable/status/verified-symbolic.svg style.css + ui/account-settings-change-password-subpage.ui + ui/account-settings-deactivate-account-subpage.ui ui/account-settings-device-row.ui ui/account-settings-devices-page.ui + ui/account-settings-user-page.ui ui/account-settings.ui ui/add-account-row.ui ui/avatar-with-selection.ui diff --git a/data/resources/style.css b/data/resources/style.css index 335667d6..ba84a43c 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -44,6 +44,19 @@ button.opaque.success { color: @error_bg_color; } +preferencesgroup .body { + line-height: 140%; +} + +preferencespage.status-page clamp > box { + margin: 42px 12px; +} + +button.row { + min-height: 50px; + border-radius: 12px; +} + /* Components */ @@ -97,9 +110,6 @@ row.entry { animation-timing-function: ease-in-out; outline: 0 solid transparent; outline-offset: 2px; - border-top: 1px solid transparent; - border-left: 1px solid transparent; - border-right: 1px solid transparent; } row.entry:focus-within { @@ -108,26 +118,14 @@ row.entry:focus-within { outline-offset: -2px; } -row.entry.success { - border: 1px solid @success_color; -} - row.entry.success:focus-within { outline-color: @success_color; } -row.entry.warning { - border: 1px solid @warning_color; -} - row.entry.warning:focus-within { outline-color: @warning_color; } -row.entry.error { - border: 1px solid @error_color; -} - row.entry.error:focus-within { outline-color: @error_color; } @@ -150,6 +148,10 @@ row.entry levelbar.discrete block { min-height: 5px; } +row.entry levelbar.discrete block.filled { + background-color: alpha(currentColor, 0.5); +} + row.entry.accent levelbar.discrete block.filled { background-color: @accent_color; } diff --git a/data/resources/ui/account-settings-change-password-subpage.ui b/data/resources/ui/account-settings-change-password-subpage.ui new file mode 100644 index 00000000..7065b8f1 --- /dev/null +++ b/data/resources/ui/account-settings-change-password-subpage.ui @@ -0,0 +1,103 @@ + + + + diff --git a/data/resources/ui/account-settings-deactivate-account-subpage.ui b/data/resources/ui/account-settings-deactivate-account-subpage.ui new file mode 100644 index 00000000..d27f6e0a --- /dev/null +++ b/data/resources/ui/account-settings-deactivate-account-subpage.ui @@ -0,0 +1,101 @@ + + + + diff --git a/data/resources/ui/account-settings-user-page.ui b/data/resources/ui/account-settings-user-page.ui new file mode 100644 index 00000000..1e04fd52 --- /dev/null +++ b/data/resources/ui/account-settings-user-page.ui @@ -0,0 +1,129 @@ + + + + + + + + + + diff --git a/data/resources/ui/account-settings.ui b/data/resources/ui/account-settings.ui index c4c54c6c..c9ca479f 100644 --- a/data/resources/ui/account-settings.ui +++ b/data/resources/ui/account-settings.ui @@ -2,14 +2,21 @@ - diff --git a/po/POTFILES.in b/po/POTFILES.in index 81faf3cb..5ac240a4 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -5,8 +5,11 @@ data/org.gnome.FractalNext.gschema.xml.in data/org.gnome.FractalNext.metainfo.xml.in.in # UI files +data/resources/ui/account-settings-change-password-subpage.ui +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-user-page.ui data/resources/ui/account-settings.ui data/resources/ui/components-auth-dialog.ui data/resources/ui/components-loading-listbox-row.ui @@ -42,6 +45,9 @@ src/login.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/user_page/change_password_subpage.rs +src/session/account_settings/user_page/deactivate_account_subpage.rs +src/session/account_settings/user_page/mod.rs src/session/content/explore/public_room_row.rs src/session/content/room_details/member_page/mod.rs src/session/content/room_details/mod.rs diff --git a/src/components/editable_avatar.rs b/src/components/editable_avatar.rs index 76ca6848..47caba01 100644 --- a/src/components/editable_avatar.rs +++ b/src/components/editable_avatar.rs @@ -362,14 +362,14 @@ impl EditableAvatar { } else { error!("The chosen file is not an image"); let _ = self.activate_action( - "win.message", + "win.add-toast", Some(&gettext("The chosen file is not an image").to_variant()), ); } } else { error!("Could not get the content type of the file"); let _ = self.activate_action( - "win.message", + "win.add-toast", Some( &gettext("Could not determine the type of the chosen file") .to_variant(), @@ -379,7 +379,7 @@ impl EditableAvatar { } else { error!("No file chosen"); let _ = self.activate_action( - "win.message", + "win.add-toast", Some(&gettext("No file was chosen").to_variant()), ); } diff --git a/src/session/account_settings/mod.rs b/src/session/account_settings/mod.rs index 08b90dd0..8e02562a 100644 --- a/src/session/account_settings/mod.rs +++ b/src/session/account_settings/mod.rs @@ -1,22 +1,24 @@ -use adw::subclass::prelude::*; -use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate}; +use adw::{prelude::*, subclass::prelude::*}; +use gtk::{glib, glib::FromVariant, subclass::prelude::*, CompositeTemplate}; mod devices_page; +mod user_page; use devices_page::DevicesPage; +use user_page::UserPage; -use crate::session::User; +use super::Session; mod imp { use std::cell::RefCell; - use glib::subclass::InitializingObject; + use glib::{subclass::InitializingObject, WeakRef}; use super::*; #[derive(Debug, Default, CompositeTemplate)] #[template(resource = "/org/gnome/FractalNext/account-settings.ui")] pub struct AccountSettings { - pub user: RefCell>, + pub session: RefCell>>, } #[glib::object_subclass] @@ -27,7 +29,23 @@ mod imp { fn class_init(klass: &mut Self::Class) { DevicesPage::static_type(); + UserPage::static_type(); Self::bind_template(klass); + + klass.install_action("account-settings.close", None, |obj, _, _| { + obj.close(); + }); + + klass.install_action("win.add-toast", Some("s"), |obj, _, message| { + if let Some(message) = message.and_then(String::from_variant) { + let toast = adw::Toast::new(&message); + obj.add_toast(&toast); + } + }); + + klass.install_action("win.close-subpage", None, |obj, _, _| { + obj.close_subpage(); + }); } fn instance_init(obj: &InitializingObject) { @@ -40,11 +58,11 @@ mod imp { use once_cell::sync::Lazy; static PROPERTIES: Lazy> = Lazy::new(|| { vec![glib::ParamSpecObject::new( - "user", - "User", - "The user of this account", - User::static_type(), - glib::ParamFlags::READWRITE, + "session", + "Session", + "The session", + Session::static_type(), + glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, )] }); @@ -59,14 +77,14 @@ mod imp { pspec: &glib::ParamSpec, ) { match pspec.name() { - "user" => obj.set_user(value.get().unwrap()), + "session" => obj.set_session(value.get().unwrap()), _ => unimplemented!(), } } fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { match pspec.name() { - "user" => obj.user().to_value(), + "session" => obj.session().to_value(), _ => unimplemented!(), } } @@ -85,21 +103,27 @@ glib::wrapper! { } impl AccountSettings { - pub fn new(parent_window: Option<&impl IsA>, user: &User) -> Self { - glib::Object::new(&[("transient-for", &parent_window), ("user", user)]) + pub fn new(parent_window: Option<&impl IsA>, session: &Session) -> Self { + glib::Object::new(&[("transient-for", &parent_window), ("session", session)]) .expect("Failed to create AccountSettings") } - pub fn user(&self) -> Option { - self.imp().user.borrow().clone() + pub fn session(&self) -> Option { + self.imp() + .session + .borrow() + .clone() + .and_then(|session| session.upgrade()) } - fn set_user(&self, user: Option) { - if self.user() == user { + pub fn set_session(&self, session: Option) { + if self.session() == session { return; } - self.imp().user.replace(user); - self.notify("user"); + self.imp() + .session + .replace(session.map(|session| session.downgrade())); + self.notify("session"); } } diff --git a/src/session/account_settings/user_page/change_password_subpage.rs b/src/session/account_settings/user_page/change_password_subpage.rs new file mode 100644 index 00000000..026426cc --- /dev/null +++ b/src/session/account_settings/user_page/change_password_subpage.rs @@ -0,0 +1,344 @@ +use adw::{prelude::*, subclass::prelude::*}; +use gettextrs::gettext; +use gtk::{ + glib::{self, clone}, + subclass::prelude::*, + CompositeTemplate, +}; +use log::error; +use matrix_sdk::{ + ruma::{ + api::{ + client::r0::account::change_password, + error::{FromHttpResponseError, ServerError}, + }, + assign, + }, + Error as MatrixError, HttpError, +}; + +use crate::{ + components::{AuthDialog, AuthError, PasswordEntryRow, SpinnerButton}, + session::Session, + spawn, + utils::validate_password, +}; + +mod imp { + use glib::{subclass::InitializingObject, WeakRef}; + use once_cell::unsync::OnceCell; + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/FractalNext/account-settings-change-password-subpage.ui")] + pub struct ChangePasswordSubpage { + pub session: OnceCell>, + #[template_child] + pub password: TemplateChild, + #[template_child] + pub confirm_password: TemplateChild, + #[template_child] + pub button: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for ChangePasswordSubpage { + const NAME: &'static str = "ChangePasswordSubpage"; + type Type = super::ChangePasswordSubpage; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + PasswordEntryRow::static_type(); + SpinnerButton::static_type(); + Self::bind_template(klass); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for ChangePasswordSubpage { + 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, + )] + }); + + 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()), + _ => unimplemented!(), + } + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "session" => obj.session().to_value(), + _ => unimplemented!(), + } + } + + fn constructed(&self, obj: &Self::Type) { + self.parent_constructed(obj); + + self.password.define_progress_steps(&[ + >k::LEVEL_BAR_OFFSET_LOW, + "step2", + "step3", + >k::LEVEL_BAR_OFFSET_HIGH, + >k::LEVEL_BAR_OFFSET_FULL, + ]); + self.password + .connect_focused(clone!(@weak obj => move |entry, focused| { + if focused { + entry.set_progress_visible(true); + obj.validate_password(); + } else { + entry.remove_css_class("warning"); + entry.remove_css_class("success"); + if entry.text().is_empty() { + entry.set_progress_visible(false); + } + } + })); + self.password + .connect_activated(clone!(@weak obj => move|_| { + spawn!( + clone!(@weak obj => async move { + obj.change_password().await; + }) + ); + })); + self.password.connect_changed(clone!(@weak obj => move|_| { + obj.validate_password(); + })); + + self.confirm_password + .connect_focused(clone!(@weak obj => move |entry, focused| { + if focused { + obj.validate_password_confirmation(); + } else { + entry.remove_css_class("warning"); + entry.remove_css_class("success"); + } + })); + self.confirm_password + .connect_activated(clone!(@weak obj => move|_| { + spawn!( + clone!(@weak obj => async move { + obj.change_password().await; + }) + ); + })); + self.confirm_password + .connect_changed(clone!(@weak obj => move|_| { + obj.validate_password_confirmation(); + })); + + self.button.connect_clicked(clone!(@weak obj => move|_| { + spawn!( + clone!(@weak obj => async move { + obj.change_password().await; + }) + ); + })); + } + } + + impl WidgetImpl for ChangePasswordSubpage {} + impl BoxImpl for ChangePasswordSubpage {} +} + +glib::wrapper! { + /// Account settings page about the user and the session. + pub struct ChangePasswordSubpage(ObjectSubclass) + @extends gtk::Widget, gtk::Box, @implements gtk::Accessible; +} + +impl ChangePasswordSubpage { + pub fn new(session: &Session) -> Self { + glib::Object::new(&[("session", session)]).expect("Failed to create ChangePasswordSubpage") + } + + pub fn session(&self) -> Option { + self.imp() + .session + .get() + .and_then(|session| session.upgrade()) + } + + pub fn set_session(&self, session: Option) { + if let Some(session) = session { + self.imp().session.set(session.downgrade()).unwrap(); + } + } + + fn validate_password(&self) { + let entry = &self.imp().password; + let password = entry.text(); + + if password.is_empty() { + entry.set_hint(""); + entry.remove_css_class("success"); + entry.remove_css_class("warning"); + entry.set_progress_value(0.0); + self.update_button(); + return; + } + + let validity = validate_password(&password); + + entry.set_progress_value(validity.progress as f64 / 20.0); + if validity.progress == 100 { + entry.set_hint(""); + entry.add_css_class("success"); + entry.remove_css_class("warning"); + } else { + entry.remove_css_class("success"); + entry.add_css_class("warning"); + if !validity.has_length { + entry.set_hint(&gettext("Password must be at least 8 characters long")); + } else if !validity.has_lowercase { + entry.set_hint(&gettext( + "Password must have at least one lower-case letter", + )); + } else if !validity.has_uppercase { + entry.set_hint(&gettext( + "Password must have at least one upper-case letter", + )); + } else if !validity.has_number { + entry.set_hint(&gettext("Password must have at least one digit")); + } else if !validity.has_symbol { + entry.set_hint(&gettext("Password must have at least one symbol")); + } + } + self.update_button(); + } + + fn validate_password_confirmation(&self) { + let priv_ = self.imp(); + let entry = &priv_.confirm_password; + let password = priv_.password.text(); + let confirmation = entry.text(); + + if confirmation.is_empty() { + entry.set_hint(""); + entry.remove_css_class("success"); + entry.remove_css_class("warning"); + return; + } + + if password == confirmation { + entry.set_hint(""); + entry.add_css_class("success"); + entry.remove_css_class("warning"); + } else { + entry.remove_css_class("success"); + entry.add_css_class("warning"); + entry.set_hint(&gettext("Passwords do not match")); + } + self.update_button(); + } + + fn update_button(&self) { + self.imp().button.set_sensitive(self.can_change_password()); + } + + fn can_change_password(&self) -> bool { + let priv_ = self.imp(); + let password = priv_.password.text(); + let confirmation = priv_.confirm_password.text(); + + validate_password(&password).progress == 100 && password == confirmation + } + + async fn change_password(&self) { + if !self.can_change_password() { + return; + } + + let priv_ = self.imp(); + let password = priv_.password.text(); + + priv_.button.set_loading(true); + priv_.password.set_entry_sensitive(false); + priv_.confirm_password.set_entry_sensitive(false); + + let session = self.session().unwrap(); + let dialog = AuthDialog::new( + self.root() + .as_ref() + .and_then(|root| root.downcast_ref::()), + &session, + ); + + let result = dialog + .authenticate(move |client, auth_data| { + let password = password.clone(); + async move { + if let Some(auth) = auth_data { + let auth = Some(auth.as_matrix_auth_data()); + let request = assign!(change_password::Request::new(&password), { auth }); + client.send(request, None).await.map_err(Into::into) + } else { + let request = change_password::Request::new(&password); + client.send(request, None).await.map_err(Into::into) + } + } + }) + .await; + + match result { + Ok(_) => { + let _ = self.activate_action( + "win.add-toast", + Some(&gettext("Password changed successfully").to_variant()), + ); + priv_.password.set_text(""); + priv_.confirm_password.set_text(""); + self.activate_action("win.close-subpage", None).unwrap(); + } + Err(err) => match err { + AuthError::UserCancelled => {} + AuthError::ServerResponse(error) + if matches!(error.as_ref(), MatrixError::Http(HttpError::ClientApi( + FromHttpResponseError::Http(ServerError::Known(error)), + )) if error.kind.as_ref() == "M_WEAK_PASSWORD") => + { + error!("Weak password: {:?}", error); + let _ = self.activate_action( + "win.add-toast", + Some(&gettext("Password rejected for being too weak").to_variant()), + ); + } + _ => { + error!("Failed to change the password: {:?}", err); + let _ = self.activate_action( + "win.add-toast", + Some(&gettext("Could not change password").to_variant()), + ); + } + }, + } + priv_.button.set_loading(false); + priv_.password.set_entry_sensitive(true); + priv_.confirm_password.set_entry_sensitive(true); + } +} diff --git a/src/session/account_settings/user_page/deactivate_account_subpage.rs b/src/session/account_settings/user_page/deactivate_account_subpage.rs new file mode 100644 index 00000000..4294468a --- /dev/null +++ b/src/session/account_settings/user_page/deactivate_account_subpage.rs @@ -0,0 +1,217 @@ +use adw::{prelude::*, subclass::prelude::*}; +use gettextrs::gettext; +use gtk::{ + glib::{self, clone}, + subclass::prelude::*, + CompositeTemplate, +}; +use log::error; +use matrix_sdk::ruma::{api::client::r0::account::deactivate, assign}; + +use crate::{ + components::{AuthDialog, EntryRow, SpinnerButton, Toast}, + session::{Session, UserExt}, + spawn, +}; + +mod imp { + use glib::{subclass::InitializingObject, WeakRef}; + use once_cell::unsync::OnceCell; + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/FractalNext/account-settings-deactivate-account-subpage.ui")] + pub struct DeactivateAccountSubpage { + pub session: OnceCell>, + #[template_child] + pub confirmation: TemplateChild, + #[template_child] + pub button: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for DeactivateAccountSubpage { + const NAME: &'static str = "DeactivateAccountSubpage"; + type Type = super::DeactivateAccountSubpage; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + EntryRow::static_type(); + Self::bind_template(klass); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for DeactivateAccountSubpage { + 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, + )] + }); + + 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()), + _ => unimplemented!(), + } + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "session" => obj.session().to_value(), + _ => unimplemented!(), + } + } + + fn constructed(&self, obj: &Self::Type) { + self.parent_constructed(obj); + + self.confirmation + .connect_activated(clone!(@weak obj => move|_| { + spawn!( + clone!(@weak obj => async move { + obj.deactivate_account().await; + }) + ); + })); + self.confirmation + .connect_changed(clone!(@weak obj => move|_| { + obj.update_button(); + })); + + self.button.connect_clicked(clone!(@weak obj => move|_| { + spawn!( + clone!(@weak obj => async move { + obj.deactivate_account().await; + }) + ); + })); + } + } + + impl WidgetImpl for DeactivateAccountSubpage {} + impl BoxImpl for DeactivateAccountSubpage {} +} + +glib::wrapper! { + /// Account settings page about the user and the session. + pub struct DeactivateAccountSubpage(ObjectSubclass) + @extends gtk::Widget, gtk::Box, @implements gtk::Accessible; +} + +impl DeactivateAccountSubpage { + pub fn new(session: &Session) -> Self { + glib::Object::new(&[("session", session)]) + .expect("Failed to create DeactivateAccountSubpage") + } + + pub fn session(&self) -> Option { + self.imp() + .session + .get() + .and_then(|session| session.upgrade()) + } + + pub fn set_session(&self, session: Option) { + if let Some(session) = session { + let priv_ = self.imp(); + priv_.session.set(session.downgrade()).unwrap(); + priv_ + .confirmation + .set_placeholder_text(Some(&self.user_id())); + } + } + + fn user_id(&self) -> String { + self.session() + .as_ref() + .and_then(|session| session.user()) + .unwrap() + .user_id() + .to_string() + } + + fn update_button(&self) { + self.imp() + .button + .set_sensitive(self.can_deactivate_account()); + } + + fn can_deactivate_account(&self) -> bool { + let confirmation = self.imp().confirmation.text(); + confirmation == self.user_id() + } + + async fn deactivate_account(&self) { + if !self.can_deactivate_account() { + return; + } + + let priv_ = self.imp(); + priv_.button.set_loading(true); + priv_.confirmation.set_sensitive(false); + + let session = self.session().unwrap(); + let dialog = AuthDialog::new( + self.root() + .as_ref() + .and_then(|root| root.downcast_ref::()), + &session, + ); + + let result = dialog + .authenticate(move |client, auth_data| async move { + if let Some(auth) = auth_data { + let auth = Some(auth.as_matrix_auth_data()); + let request = assign!(deactivate::Request::new(), { auth }); + client.send(request, None).await.map_err(Into::into) + } else { + let request = deactivate::Request::new(); + client.send(request, None).await.map_err(Into::into) + } + }) + .await; + + match result { + Ok(_) => { + if let Some(session) = self.session() { + session + .parent_window() + .unwrap() + .add_toast(&Toast::new(&gettext("Account successfully deactivated"))); + session.handle_logged_out(); + } + self.activate_action("account-settings.close", None) + .unwrap(); + } + Err(err) => { + error!("Failed to deactivate account: {:?}", err); + let _ = self.activate_action( + "win.add-toast", + Some(&gettext("Could not deactivate account").to_variant()), + ); + } + } + priv_.button.set_loading(false); + priv_.confirmation.set_sensitive(true); + } +} diff --git a/src/session/account_settings/user_page/mod.rs b/src/session/account_settings/user_page/mod.rs new file mode 100644 index 00000000..38be6f84 --- /dev/null +++ b/src/session/account_settings/user_page/mod.rs @@ -0,0 +1,482 @@ +use std::{fs::File, time::Duration}; + +use adw::{prelude::*, subclass::prelude::*}; +use gettextrs::gettext; +use gtk::{ + gio, + glib::{self, clone}, + subclass::prelude::*, + CompositeTemplate, +}; +use log::error; +use matrix_sdk::ruma::{api::client::r0::capabilities::get_capabilities, MxcUri}; + +mod change_password_subpage; +mod deactivate_account_subpage; + +use change_password_subpage::ChangePasswordSubpage; +use deactivate_account_subpage::DeactivateAccountSubpage; + +use crate::{ + components::{ActionState, ButtonRow, EditableAvatar, EntryRow}, + session::{Session, User, UserExt}, + spawn, spawn_tokio, + utils::TemplateCallbacks, +}; + +mod imp { + use std::cell::{Cell, RefCell}; + + use glib::{subclass::InitializingObject, WeakRef}; + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/FractalNext/account-settings-user-page.ui")] + pub struct UserPage { + pub session: RefCell>>, + #[template_child] + pub avatar: TemplateChild, + #[template_child] + pub display_name: TemplateChild, + #[template_child] + pub change_password_group: TemplateChild, + #[template_child] + pub change_password_subpage: TemplateChild, + #[template_child] + pub homeserver: TemplateChild, + #[template_child] + pub user_id: TemplateChild, + #[template_child] + pub session_id: TemplateChild, + #[template_child] + pub deactivate_account_subpage: TemplateChild, + pub changing_avatar_to: RefCell>>, + pub removing_avatar: Cell, + pub changing_display_name_to: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for UserPage { + const NAME: &'static str = "UserPage"; + type Type = super::UserPage; + type ParentType = adw::PreferencesPage; + + fn class_init(klass: &mut Self::Class) { + EditableAvatar::static_type(); + EntryRow::static_type(); + ButtonRow::static_type(); + ChangePasswordSubpage::static_type(); + DeactivateAccountSubpage::static_type(); + Self::bind_template(klass); + Self::Type::bind_template_callbacks(klass); + TemplateCallbacks::bind_template_callbacks(klass); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for UserPage { + 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, + )] + }); + + 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()), + _ => unimplemented!(), + } + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "session" => obj.session().to_value(), + _ => unimplemented!(), + } + } + + fn constructed(&self, obj: &Self::Type) { + self.parent_constructed(obj); + + obj.init_avatar(); + obj.init_display_name(); + obj.init_change_password(); + } + } + + impl WidgetImpl for UserPage {} + impl PreferencesPageImpl for UserPage {} +} + +glib::wrapper! { + /// Account settings page about the user and the session. + pub struct UserPage(ObjectSubclass) + @extends gtk::Widget, adw::PreferencesPage, @implements gtk::Accessible; +} + +#[gtk::template_callbacks] +impl UserPage { + pub fn new(parent_window: &Option, session: &Session) -> Self { + glib::Object::new(&[("transient-for", parent_window), ("session", session)]) + .expect("Failed to create UserPage") + } + + pub fn session(&self) -> Option { + self.imp() + .session + .borrow() + .clone() + .and_then(|session| session.upgrade()) + } + + pub fn set_session(&self, session: Option) { + if self.session() == session { + return; + } + self.imp() + .session + .replace(session.map(|session| session.downgrade())); + self.notify("session"); + + self.user().avatar().connect_notify_local( + Some("url"), + clone!(@weak self as obj => move |avatar, _| { + obj.avatar_changed(avatar.url().as_deref()); + }), + ); + self.user().connect_notify_local( + Some("display-name"), + clone!(@weak self as obj => move |user, _| { + obj.display_name_changed(&user.display_name()); + }), + ); + + spawn!( + glib::PRIORITY_LOW, + clone!(@weak self as obj => async move { + let priv_ = obj.imp(); + let client = obj.session().unwrap().client(); + + let homeserver = client.homeserver().await; + priv_.homeserver.set_label(homeserver.as_ref()); + + let user_id = client.user_id().await.unwrap(); + priv_.user_id.set_label(user_id.as_ref()); + + let session_id = client.device_id().await.unwrap(); + priv_.session_id.set_label(session_id.as_ref()); + }) + ); + } + + fn user(&self) -> User { + self.session() + .as_ref() + .and_then(|session| session.user()) + .unwrap() + .to_owned() + } + + fn init_avatar(&self) { + let avatar = &self.imp().avatar; + avatar.connect_edit_avatar(clone!(@weak self as obj => move |_, file| { + spawn!( + clone!(@weak obj => async move { + obj.change_avatar(file).await; + }) + ); + })); + avatar.connect_remove_avatar(clone!(@weak self as obj => move |_| { + spawn!( + clone!(@weak obj => async move { + obj.remove_avatar().await; + }) + ); + })); + } + + fn avatar_changed(&self, uri: Option<&MxcUri>) { + let priv_ = self.imp(); + let avatar = &*priv_.avatar; + if uri.is_none() && priv_.removing_avatar.get() { + priv_.removing_avatar.set(false); + avatar.show_temp_image(false); + avatar.set_remove_state(ActionState::Success); + avatar.set_edit_sensitive(true); + let _ = self.activate_action( + "win.add-toast", + Some(&gettext("Avatar removed successfully").to_variant()), + ); + glib::timeout_add_local_once( + Duration::from_secs(2), + clone!(@weak avatar => move || { + avatar.set_remove_state(ActionState::Default); + }), + ); + } else if uri.is_some() { + let to_uri = priv_.changing_avatar_to.borrow().clone(); + if to_uri.as_deref() == uri { + priv_.changing_avatar_to.take(); + avatar.set_edit_state(ActionState::Success); + avatar.show_temp_image(false); + avatar.set_temp_image_from_file(None); + avatar.set_remove_sensitive(true); + let _ = self.activate_action( + "win.add-toast", + Some(&gettext("Avatar changed successfully").to_variant()), + ); + glib::timeout_add_local_once( + Duration::from_secs(2), + clone!(@weak avatar => move || { + avatar.set_edit_state(ActionState::Default); + }), + ); + } + } + } + + async fn change_avatar(&self, file: gio::File) { + let priv_ = self.imp(); + let avatar = &priv_.avatar; + avatar.set_temp_image_from_file(Some(&file)); + avatar.show_temp_image(true); + avatar.set_edit_state(ActionState::Loading); + avatar.set_remove_sensitive(false); + + let client = self.session().unwrap().client(); + let mime = file + .query_info_future( + &gio::FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE, + gio::FileQueryInfoFlags::NONE, + glib::PRIORITY_LOW, + ) + .await + .ok() + .and_then(|info| info.content_type()) + .and_then(|content_type| gio::content_type_get_mime_type(&content_type)) + .unwrap(); + let mut file = File::open(file.path().unwrap()).unwrap(); + + let client_clone = client.clone(); + let handle = + spawn_tokio!( + async move { client_clone.upload(&mime.parse().unwrap(), &mut file).await } + ); + + let uri = match handle.await.unwrap() { + Ok(res) => res.content_uri, + Err(error) => { + error!("Could not upload user avatar: {}", error); + let _ = self.activate_action( + "win.add-toast", + Some(&gettext("Could not upload avatar").to_variant()), + ); + avatar.show_temp_image(false); + avatar.set_temp_image_from_file(None); + avatar.set_edit_state(ActionState::Default); + avatar.set_remove_sensitive(true); + return; + } + }; + + priv_.changing_avatar_to.replace(Some(uri.clone())); + let handle = spawn_tokio!(async move { client.account().set_avatar_url(Some(&uri)).await }); + + match handle.await.unwrap() { + Ok(_) => { + let to_uri = priv_.changing_avatar_to.borrow().clone(); + if let Some(avatar) = to_uri { + self.user().set_avatar_url(Some(avatar)) + } + } + Err(error) => { + if priv_.changing_avatar_to.take().is_some() { + error!("Could not change user avatar: {}", error); + let _ = self.activate_action( + "win.add-toast", + Some(&gettext("Could not change avatar").to_variant()), + ); + avatar.show_temp_image(false); + avatar.set_temp_image_from_file(None); + avatar.set_edit_state(ActionState::Default); + avatar.set_remove_sensitive(true); + } + } + } + } + + async fn remove_avatar(&self) { + let priv_ = self.imp(); + let avatar = &*priv_.avatar; + avatar.show_temp_image(true); + avatar.set_remove_state(ActionState::Loading); + avatar.set_edit_sensitive(false); + + let client = self.session().unwrap().client(); + let handle = spawn_tokio!(async move { client.account().set_avatar_url(None).await }); + priv_.removing_avatar.set(true); + + match handle.await.unwrap() { + Ok(_) => { + self.user().set_avatar_url(None); + } + Err(error) => { + if priv_.removing_avatar.get() { + priv_.removing_avatar.set(false); + error!("Couldn’t remove user avatar: {}", error); + let _ = self.activate_action( + "win.add-toast", + Some(&gettext("Could not remove avatar").to_variant()), + ); + avatar.show_temp_image(false); + avatar.set_remove_state(ActionState::Default); + avatar.set_edit_sensitive(true); + } + } + } + } + + fn init_display_name(&self) { + let entry = &*self.imp().display_name; + entry.connect_focused(clone!(@weak self as obj => move|entry, focused| { + if entry.entry_sensitive() { + if focused { + entry.set_action_state(ActionState::Confirm); + } else if entry.text() == obj.user().display_name() { + entry.set_action_state(ActionState::Default); + } + } + })); + entry.connect_activated(clone!(@weak self as obj => move|_| { + spawn!( + clone!(@weak obj => async move { + obj.change_display_name().await; + }) + ); + })); + entry.connect_cancel(clone!(@weak self as obj => move|entry| { + entry.set_text(&obj.user().display_name()); + })); + } + + fn display_name_changed(&self, name: &str) { + let priv_ = self.imp(); + let entry = &*priv_.display_name; + + let to_display_name = priv_ + .changing_display_name_to + .borrow() + .clone() + .unwrap_or_default(); + if to_display_name == name { + priv_.changing_display_name_to.take(); + entry.remove_css_class("error"); + entry.set_action_state(ActionState::Success); + entry.set_entry_sensitive(true); + let _ = self.activate_action( + "win.add-toast", + Some(&gettext("Name changed successfully").to_variant()), + ); + glib::timeout_add_local_once( + Duration::from_secs(2), + clone!(@weak entry => move || { + entry.set_action_state(ActionState::Default); + }), + ); + } + } + + async fn change_display_name(&self) { + let priv_ = self.imp(); + let entry = &*priv_.display_name; + entry.set_action_state(ActionState::Loading); + entry.set_entry_sensitive(false); + + let display_name = entry.text(); + priv_ + .changing_display_name_to + .replace(Some(display_name.to_string())); + + let client = self.session().unwrap().client(); + let handle = + spawn_tokio!( + async move { client.account().set_display_name(Some(&display_name)).await } + ); + + match handle.await.unwrap() { + Ok(_) => { + let to_display_name = priv_.changing_display_name_to.borrow().clone(); + if let Some(display_name) = to_display_name { + self.user().set_display_name(Some(display_name)); + } + } + Err(err) => { + error!("Couldn’t change user display name: {}", err); + let _ = self.activate_action( + "win.add-toast", + Some(&gettext("Could not change display name").to_variant()), + ); + entry.set_action_state(ActionState::Retry); + entry.add_css_class("error"); + entry.set_entry_sensitive(true); + } + } + } + + fn init_change_password(&self) { + spawn!( + glib::PRIORITY_LOW, + clone!(@weak self as obj => async move { + let client = obj.session().unwrap().client(); + + // Check whether the user can change their password. + let handle = spawn_tokio!(async move { + client.send(get_capabilities::Request::new(), None).await + }); + match handle.await.unwrap() { + Ok(res) => { + obj.imp().change_password_group.set_visible(res.capabilities.change_password.enabled); + } + Err(error) => error!("Could not get server capabilities: {}", error), + } + }) + ); + } + + #[template_callback] + fn show_change_password(&self) { + self.root() + .as_ref() + .and_then(|root| root.downcast_ref::()) + .unwrap() + .present_subpage(&*self.imp().change_password_subpage); + } + + #[template_callback] + fn show_deactivate_account(&self) { + self.root() + .as_ref() + .and_then(|root| root.downcast_ref::()) + .unwrap() + .present_subpage(&*self.imp().deactivate_account_subpage); + } +} diff --git a/src/session/mod.rs b/src/session/mod.rs index e9b1505c..3665442c 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -40,7 +40,7 @@ use matrix_sdk::{ assign, identifiers::RoomId, }, - Client, Error as MatrixError, HttpError, + Client, HttpError, }; use rand::{distributions::Alphanumeric, thread_rng, Rng}; use tokio::task::JoinHandle; @@ -394,28 +394,12 @@ impl Session { let priv_ = self.imp(); let error = match result { Ok((client, session)) => { - priv_.client.replace(Some(client.clone())); + priv_.client.replace(Some(client)); let user = User::new(self, &session.user_id); - priv_.user.set(user.clone()).unwrap(); + priv_.user.set(user).unwrap(); self.notify("user"); - let handle = spawn_tokio!(async move { - let account = client.account(); - let display_name = account.get_display_name().await?; - let avatar_url = account.get_avatar_url().await?; - let result: Result<_, MatrixError> = Ok((display_name, avatar_url)); - result - }); - - spawn!(glib::PRIORITY_LOW, async move { - match handle.await.unwrap() { - Ok((display_name, avatar_url)) => { - user.set_display_name(display_name); - user.set_avatar_url(avatar_url); - } - Err(error) => error!("Couldn’t fetch account metadata: {}", error), - } - }); + self.update_user_profile(); let res = if store_session { match secret::store_session(&session) { @@ -567,10 +551,31 @@ impl Session { .get_or_init(|| ItemList::new(&RoomList::new(self), &VerificationList::new(self))) } + /// The user of this session. pub fn user(&self) -> Option<&User> { self.imp().user.get() } + /// Update the profile of this session’s user. + /// + /// Fetches the updated profile and updates the local data. + pub fn update_user_profile(&self) { + let client = self.client(); + let user = self.user().unwrap().to_owned(); + + let handle = spawn_tokio!(async move { client.account().get_profile().await }); + + spawn!(glib::PRIORITY_LOW, async move { + match handle.await.unwrap() { + Ok(res) => { + user.set_display_name(res.displayname); + user.set_avatar_url(res.avatar_url); + } + Err(error) => error!("Couldn’t fetch account metadata: {}", error), + } + }); + } + pub fn client(&self) -> Client { self.imp() .client @@ -652,8 +657,7 @@ impl Session { ))) = error { if let ErrorKind::UnknownToken { soft_logout: _ } = error.kind { - self.emit_by_name::<()>("logged-out", &[]); - self.cleanup_session(); + self.handle_logged_out(); } } error!("Failed to perform sync: {:?}", error); @@ -673,10 +677,8 @@ impl Session { } fn open_account_settings(&self) { - if let Some(user) = self.user() { - let window = AccountSettings::new(self.parent_window().as_ref(), user); - window.show(); - } + let window = AccountSettings::new(self.parent_window().as_ref(), self); + window.show(); } fn show_room_creation_dialog(&self) { @@ -712,6 +714,15 @@ impl Session { } } + /// Handle that the session has been logged out. + /// + /// This should only be called if the session has been logged out without + /// `Session::logout`. + pub fn handle_logged_out(&self) { + self.emit_by_name::<()>("logged-out", &[]); + self.cleanup_session(); + } + fn cleanup_session(&self) { let priv_ = self.imp(); let info = priv_.info.get().unwrap(); diff --git a/src/session/room/member.rs b/src/session/room/member.rs index 6e9f3394..af94b853 100644 --- a/src/session/room/member.rs +++ b/src/session/room/member.rs @@ -193,6 +193,13 @@ impl Member { self.set_display_name(event.display_name()); self.avatar().set_url(event.avatar_url()); self.set_membership((&event.content().membership).into()); + + let session = self.session(); + if let Some(user) = session.user() { + if user.user_id() == self.user_id() { + session.update_user_profile(); + } + } } } diff --git a/src/utils.rs b/src/utils.rs index 4cafc675..96128a6e 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -244,4 +244,78 @@ impl TemplateCallbacks { fn string_not_empty(string: Option<&str>) -> bool { !string.unwrap_or_default().is_empty() } + + #[template_callback] + fn object_is_some(obj: Option) -> bool { + obj.is_some() + } +} + +/// The result of a password validation. +#[derive(Debug, Default, Clone, Copy)] +pub struct PasswordValidity { + /// Whether the password includes at least one lowercase letter. + pub has_lowercase: bool, + /// Whether the password includes at least one uppercase letter. + pub has_uppercase: bool, + /// Whether the password includes at least one number. + pub has_number: bool, + /// Whether the password includes at least one symbol. + pub has_symbol: bool, + /// Whether the password is at least 8 characters long. + pub has_length: bool, + /// The percentage of checks passed for the password, between 0 and 100. + /// + /// If progress is 100, the password is valid. + pub progress: u32, +} + +impl PasswordValidity { + pub fn new() -> Self { + Self::default() + } +} + +/// Validate a password according to the Matrix specification. +/// +/// A password should include a lower-case letter, an upper-case letter, a +/// number and a symbol and be at a minimum 8 characters in length. +/// +/// See: https://spec.matrix.org/v1.1/client-server-api/#notes-on-password-management +pub fn validate_password(password: &str) -> PasswordValidity { + let mut validity = PasswordValidity::new(); + + for char in password.chars() { + if char.is_numeric() { + validity.has_number = true; + } else if char.is_lowercase() { + validity.has_lowercase = true; + } else if char.is_uppercase() { + validity.has_uppercase = true; + } else { + validity.has_symbol = true; + } + } + + validity.has_length = password.len() >= 8; + + let mut passed = 0; + if validity.has_number { + passed += 1; + } + if validity.has_lowercase { + passed += 1; + } + if validity.has_uppercase { + passed += 1; + } + if validity.has_symbol { + passed += 1; + } + if validity.has_length { + passed += 1; + } + validity.progress = passed * 100 / 5; + + validity }