diff --git a/data/resources/icons/scalable/status/devices-symbolic.svg b/data/resources/icons/scalable/status/devices-symbolic.svg new file mode 100644 index 0000000000000000000000000000000000000000..9699a38f134594e207baadc4fd41b2bb94c53d1f --- /dev/null +++ b/data/resources/icons/scalable/status/devices-symbolic.svg @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/data/resources/icons/scalable/status/verified-symbolic.svg b/data/resources/icons/scalable/status/verified-symbolic.svg new file mode 100644 index 0000000000000000000000000000000000000000..82a6ac86e1b920d0bf06b9ea0bbe431a0291929d --- /dev/null +++ b/data/resources/icons/scalable/status/verified-symbolic.svg @@ -0,0 +1,17 @@ + + + + + + image/svg+xml + + + + + + + + + + + \ No newline at end of file diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index 088f12e9652b24c57ae771f94162767f7eef16de..182b4cfc097f44ef392163449e4954cba245ede0 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -34,10 +34,17 @@ ui/in-app-notification.ui ui/components-avatar.ui ui/avatar-with-selection.ui + ui/components-auth-dialog.ui + ui/account-settings.ui + ui/account-settings-device-row.ui + ui/account-settings-devices-page.ui + ui/components-loading-listbox-row.ui style.css icons/scalable/actions/send-symbolic.svg icons/scalable/status/welcome.svg icons/scalable/status/empty-page.svg icons/scalable/status/explore-symbolic.svg + icons/scalable/status/devices-symbolic.svg + icons/scalable/status/verified-symbolic.svg diff --git a/data/resources/ui/account-settings-device-row.ui b/data/resources/ui/account-settings-device-row.ui new file mode 100644 index 0000000000000000000000000000000000000000..f7646bd46bff69b11645f230af57da8d9c61e3c3 --- /dev/null +++ b/data/resources/ui/account-settings-device-row.ui @@ -0,0 +1,70 @@ + + + + + diff --git a/data/resources/ui/account-settings-devices-page.ui b/data/resources/ui/account-settings-devices-page.ui new file mode 100644 index 0000000000000000000000000000000000000000..453ee78123ae93a67156e92723586c08ceda0fa3 --- /dev/null +++ b/data/resources/ui/account-settings-devices-page.ui @@ -0,0 +1,39 @@ + + + + + diff --git a/data/resources/ui/account-settings.ui b/data/resources/ui/account-settings.ui new file mode 100644 index 0000000000000000000000000000000000000000..c4c54c6c33998e223b595d11bffcdaa251d46e8b --- /dev/null +++ b/data/resources/ui/account-settings.ui @@ -0,0 +1,15 @@ + + + + + diff --git a/data/resources/ui/components-auth-dialog.ui b/data/resources/ui/components-auth-dialog.ui new file mode 100644 index 0000000000000000000000000000000000000000..66cc4226691ee601abc89b6d16f86aa18f1ac43c --- /dev/null +++ b/data/resources/ui/components-auth-dialog.ui @@ -0,0 +1,132 @@ + + + + + diff --git a/data/resources/ui/components-loading-listbox-row.ui b/data/resources/ui/components-loading-listbox-row.ui new file mode 100644 index 0000000000000000000000000000000000000000..bb2d4b3d78a812327ba62597e59d7c994fdf8fda --- /dev/null +++ b/data/resources/ui/components-loading-listbox-row.ui @@ -0,0 +1,41 @@ + + + + diff --git a/data/resources/ui/user-entry-row.ui b/data/resources/ui/user-entry-row.ui index 9785ad23f0df1a61cd194c85a1c425803b9fe805..68241553bb386f401fd0e7537ab47d2d0c0e0ede 100644 --- a/data/resources/ui/user-entry-row.ui +++ b/data/resources/ui/user-entry-row.ui @@ -49,14 +49,26 @@ + + + applications-system-symbolic + user-entry-row.open-account-settings + center + center + + + + diff --git a/po/POTFILES.in b/po/POTFILES.in index 2086295fc58b479cb9a2873ba660d26694a4c9b4..902998b2d049e813f38e689769f28dd24a074756 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -6,7 +6,12 @@ data/org.gnome.FractalNext.metainfo.xml.in.in # UI files data/resources/ui/add_account.ui +data/resources/ui/account-settings.ui +data/resources/ui/account-settings-device-row.ui +data/resources/ui/account-settings-devices-page.ui +data/resources/ui/components-auth-dialog.ui data/resources/ui/components-avatar.ui +data/resources/ui/components-loading-listbox-row.ui data/resources/ui/avatar-with-selection.ui data/resources/ui/content-divider-row.ui data/resources/ui/content-item-row-menu.ui @@ -36,10 +41,12 @@ data/resources/ui/window.ui # Rust files src/application.rs +src/components/auth_dialog.rs src/components/avatar.rs src/components/context_menu_bin.rs src/components/custom_entry.rs src/components/label_with_widgets.rs +src/components/loading_listbox_row.rs src/components/in_app_notification.rs src/components/mod.rs src/components/spinner_button.rs @@ -48,6 +55,12 @@ src/error.rs src/login.rs src/main.rs src/secret.rs +src/session/account_settings/devices_page/device.rs +src/session/account_settings/devices_page/device_item.rs +src/session/account_settings/devices_page/device_list.rs +src/session/account_settings/devices_page/device_row.rs +src/session/account_settings/devices_page/mod.rs +src/session/account_settings/mod.rs src/session/categories/category.rs src/session/categories/category_type.rs src/session/categories/mod.rs diff --git a/src/components/auth_dialog.rs b/src/components/auth_dialog.rs new file mode 100644 index 0000000000000000000000000000000000000000..4206786282ab3fda73d688f428ab0360ebae029c --- /dev/null +++ b/src/components/auth_dialog.rs @@ -0,0 +1,383 @@ +use adw::subclass::prelude::*; +use gtk::gdk; +use gtk::gio::prelude::*; +use gtk::glib::clone; +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::{glib, CompositeTemplate}; +use std::cell::Cell; +use std::future::Future; + +use crate::session::Session; +use crate::session::UserExt; +use crate::RUNTIME; + +use matrix_sdk::{ + ruma::api::{ + client::{ + error::ErrorBody, + r0::uiaa::{ + AuthData as MatrixAuthData, + FallbackAcknowledgement as MatrixFallbackAcknowledgement, + Password as MatrixPassword, UiaaInfo, UiaaResponse, UserIdentifier, + }, + }, + error::{FromHttpResponseError, ServerError}, + OutgoingRequest, + }, + ruma::assign, + HttpError, + HttpError::UiaaError, + HttpResult, +}; + +use std::fmt::Debug; + +pub struct Password { + pub user_id: String, + pub password: String, + pub session: Option, +} + +pub struct FallbackAcknowledgement { + pub session: String, +} + +// FIXME: we can't move the ruma AuthData between threads +// because it's not owned data and doesn't live long enough. +// Therefore we have our own AuthData. +pub enum AuthData { + Password(Password), + FallbackAcknowledgement(FallbackAcknowledgement), +} + +impl AuthData { + pub fn as_matrix_auth_data(&self) -> MatrixAuthData { + match self { + AuthData::Password(Password { + user_id, + password, + session, + }) => MatrixAuthData::Password(assign!(MatrixPassword::new( + UserIdentifier::MatrixId(&user_id), + &password, + ), { session: session.as_deref() })), + AuthData::FallbackAcknowledgement(FallbackAcknowledgement { session }) => { + MatrixAuthData::FallbackAcknowledgement(MatrixFallbackAcknowledgement::new( + &session, + )) + } + } + } +} + +mod imp { + use super::*; + use glib::subclass::{InitializingObject, Signal}; + use glib::SignalHandlerId; + use once_cell::sync::Lazy; + use std::cell::RefCell; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/FractalNext/components-auth-dialog.ui")] + pub struct AuthDialog { + pub session: RefCell>, + #[template_child] + pub stack: TemplateChild, + #[template_child] + pub password: TemplateChild, + #[template_child] + pub error: TemplateChild, + + #[template_child] + pub button_cancel: TemplateChild, + #[template_child] + pub button_ok: TemplateChild, + + #[template_child] + pub open_browser_btn: TemplateChild, + pub open_browser_btn_handler: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for AuthDialog { + const NAME: &'static str = "ComponentsAuthDialog"; + type Type = super::AuthDialog; + type ParentType = adw::Window; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + let response = glib::Variant::from_tuple(&[false.to_variant()]); + klass.add_binding_signal( + gdk::keys::constants::Escape, + gdk::ModifierType::empty(), + "response", + Some(&response), + ); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for AuthDialog { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![glib::ParamSpec::new_object( + "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); + + self.button_cancel + .connect_clicked(clone!(@weak obj => move |_| { + obj.emit_by_name("response", &[&false]).unwrap(); + })); + + self.button_ok + .connect_clicked(clone!(@weak obj => move |_| { + obj.emit_by_name("response", &[&true]).unwrap(); + })); + + obj.connect_close_request( + clone!(@weak obj => @default-return gtk::Inhibit(false), move |_| { + obj.emit_by_name("response", &[&false]).unwrap(); + gtk::Inhibit(false) + }), + ); + } + + fn signals() -> &'static [Signal] { + static SIGNALS: Lazy> = Lazy::new(|| { + vec![Signal::builder( + "response", + &[bool::static_type().into()], + <()>::static_type().into(), + ) + .action() + .build()] + }); + SIGNALS.as_ref() + } + } + impl WidgetImpl for AuthDialog {} + impl WindowImpl for AuthDialog {} + impl AdwWindowImpl for AuthDialog {} +} + +glib::wrapper! { + /// Button showing a spinner, revealing its label once loaded. + pub struct AuthDialog(ObjectSubclass) + @extends gtk::Widget, adw::Window, gtk::Dialog, gtk::Window, @implements gtk::Accessible; +} + +impl AuthDialog { + pub fn new(transient_for: Option<&impl IsA>, session: &Session) -> Self { + glib::Object::new(&[("transient-for", &transient_for), ("session", session)]) + .expect("Failed to create AuthDialog") + } + + pub fn session(&self) -> Option { + let priv_ = imp::AuthDialog::from_instance(self); + priv_.session.borrow().clone() + } + + pub fn set_session(&self, session: Option) { + let priv_ = imp::AuthDialog::from_instance(self); + + if self.session() == session { + return; + }; + + priv_.session.replace(session); + + self.notify("session"); + } + + pub async fn authenticate< + Request: Send + 'static, + F1: Future> + Send + 'static, + FN: Fn(Option) -> F1 + Send + Sync + 'static + Clone, + >( + &self, + callback: FN, + ) -> Option> + where + Request: OutgoingRequest + Debug, + Request::IncomingResponse: Send, + HttpError: From>, + { + let priv_ = imp::AuthDialog::from_instance(self); + let mut auth_data = None; + + loop { + let callback_clone = callback.clone(); + let (sender, receiver) = futures::channel::oneshot::channel(); + RUNTIME.spawn(async move { sender.send(callback_clone(auth_data).await) }); + let response = receiver.await.unwrap(); + + let uiaa_info: UiaaInfo = match response { + Ok(result) => return Some(Ok(result)), + Err(UiaaError(FromHttpResponseError::Http(ServerError::Known( + UiaaResponse::AuthResponse(uiaa_info), + )))) => uiaa_info, + Err(error) => return Some(Err(error)), + }; + + self.show_auth_error(&uiaa_info.auth_error); + + // Find the first flow that matches the completed flow + let flow = uiaa_info + .flows + .iter() + .find(|flow| flow.stages.starts_with(&uiaa_info.completed))?; + + match flow.stages[uiaa_info.completed.len()].as_str() { + "m.login.password" => { + priv_.stack.set_visible_child_name("m.login.password"); + if self.show_and_wait_for_response().await { + let user_id = self + .session() + .unwrap() + .user() + .unwrap() + .user_id() + .to_string(); + let password = priv_.password.text().to_string(); + let session = uiaa_info.session; + + auth_data = Some(AuthData::Password(Password { + user_id, + password, + session, + })); + + continue; + } + } + // TODO implement other authentication types + // See: https://gitlab.gnome.org/GNOME/fractal/-/issues/835 + _ => { + if let Some(session) = uiaa_info.session { + priv_.stack.set_visible_child_name("fallback"); + + let client = self.session()?.client().clone(); + let (sender, receiver) = futures::channel::oneshot::channel(); + RUNTIME.spawn(async move { sender.send(client.homeserver().await) }); + let homeserver = receiver.await.unwrap(); + self.setup_fallback_page( + homeserver.as_str(), + &flow.stages.first()?, + &session, + ); + if self.show_and_wait_for_response().await { + auth_data = + Some(AuthData::FallbackAcknowledgement(FallbackAcknowledgement { + session, + })); + + continue; + } + } + } + } + + return None; + } + } + + async fn show_and_wait_for_response(&self) -> bool { + let (sender, receiver) = futures::channel::oneshot::channel(); + let sender = Cell::new(Some(sender)); + + let handler_id = self.connect_response(move |_, response| { + if let Some(sender) = sender.take() { + sender.send(response).unwrap(); + } + }); + + self.show(); + + let result = receiver.await.unwrap(); + self.disconnect(handler_id); + self.close(); + + result + } + + fn show_auth_error(&self, auth_error: &Option) { + let priv_ = imp::AuthDialog::from_instance(self); + + if let Some(auth_error) = auth_error { + priv_.error.set_label(&auth_error.message); + priv_.error.show(); + } else { + priv_.error.hide(); + } + } + + fn setup_fallback_page(&self, homeserver: &str, auth_type: &str, session: &str) { + let priv_ = imp::AuthDialog::from_instance(self); + + if let Some(handler) = priv_.open_browser_btn_handler.take() { + priv_.open_browser_btn.disconnect(handler); + } + + let uri = format!( + "{}_matrix/client/r0/auth/{}/fallback/web?session={}", + homeserver, auth_type, session + ); + + let handler = + priv_ + .open_browser_btn + .connect_clicked(clone!(@weak self as obj => move |_| { + gtk::show_uri(obj.transient_for().as_ref(), &uri, gdk::CURRENT_TIME); + })); + + priv_.open_browser_btn_handler.replace(Some(handler)); + } + + pub fn connect_response(&self, f: F) -> glib::SignalHandlerId { + self.connect_local("response", true, move |values| { + //FIXME The manuel cast is needed because of https://github.com/gtk-rs/gtk4-rs/issues/591 + let obj: Self = values[0].get::().unwrap().downcast().unwrap(); + let response = values[1].get::().unwrap(); + + f(&obj, response); + + None + }) + .unwrap() + } +} diff --git a/src/components/loading_listbox_row.rs b/src/components/loading_listbox_row.rs new file mode 100644 index 0000000000000000000000000000000000000000..ae997750e082b9401dba4b08357e3d7b106da549 --- /dev/null +++ b/src/components/loading_listbox_row.rs @@ -0,0 +1,174 @@ +use glib::subclass::Signal; +use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate}; + +mod imp { + use super::*; + use glib::subclass::InitializingObject; + use once_cell::sync::Lazy; + use std::cell::Cell; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/FractalNext/components-loading-listbox-row.ui")] + pub struct LoadingListBoxRow { + #[template_child] + pub spinner: TemplateChild, + #[template_child] + pub stack: TemplateChild, + #[template_child] + pub error: TemplateChild, + #[template_child] + pub error_label: TemplateChild, + #[template_child] + pub retry_button: TemplateChild, + pub is_error: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for LoadingListBoxRow { + const NAME: &'static str = "ComponentsLoadingListBoxRow"; + type Type = super::LoadingListBoxRow; + type ParentType = gtk::ListBoxRow; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for LoadingListBoxRow { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpec::new_boolean( + "loading", + "Loading", + "Whether to show the loading spinner", + true, + glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, + ), + glib::ParamSpec::new_string( + "error", + "Error", + "The error message to show", + None, + 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() { + "loading" => { + obj.set_loading(value.get().unwrap()); + } + "error" => { + obj.set_error(value.get().unwrap()); + } + _ => unimplemented!(), + } + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "loading" => obj.is_loading().to_value(), + "error" => obj.error().to_value(), + _ => unimplemented!(), + } + } + + fn signals() -> &'static [Signal] { + static SIGNALS: Lazy> = Lazy::new(|| { + vec![Signal::builder("retry", &[], <()>::static_type().into()).build()] + }); + SIGNALS.as_ref() + } + + fn constructed(&self, obj: &Self::Type) { + self.parent_constructed(obj); + + self.retry_button + .connect_clicked(clone!(@weak obj => move |_| { + obj.emit_by_name("retry", &[]).unwrap(); + })); + } + } + impl WidgetImpl for LoadingListBoxRow {} + impl ListBoxRowImpl for LoadingListBoxRow {} +} + +glib::wrapper! { + /// This is a `ListBoxRow` continaing a loading spinner. + /// + /// It's also possible to set an error once the loading fails including a retry button. + pub struct LoadingListBoxRow(ObjectSubclass) + @extends gtk::Widget, gtk::ListBoxRow, @implements gtk::Accessible; +} + +impl LoadingListBoxRow { + pub fn new() -> Self { + glib::Object::new(&[]).expect("Failed to create LoadingListBoxRow") + } + + pub fn is_loading(&self) -> bool { + let priv_ = imp::LoadingListBoxRow::from_instance(self); + !priv_.is_error.get() + } + + pub fn set_loading(&self, loading: bool) { + let priv_ = imp::LoadingListBoxRow::from_instance(self); + + if self.is_loading() == loading { + return; + } + + priv_.stack.set_visible_child(&*priv_.spinner); + priv_.is_error.set(false); + + self.notify("loading"); + } + + pub fn error(&self) -> Option { + let priv_ = imp::LoadingListBoxRow::from_instance(self); + let message = priv_.error_label.text(); + if message.is_empty() { + None + } else { + Some(message) + } + } + + pub fn set_error(&self, message: Option<&str>) { + let priv_ = imp::LoadingListBoxRow::from_instance(self); + + if let Some(message) = message { + priv_.is_error.set(true); + priv_.error_label.set_text(message); + priv_.stack.set_visible_child(&*priv_.error); + } else { + priv_.is_error.set(false); + priv_.stack.set_visible_child(&*priv_.spinner); + } + self.notify("error"); + } + + pub fn connect_retry(&self, f: F) -> glib::SignalHandlerId { + self.connect_local("retry", true, move |values| { + let obj = values[0].get::().unwrap(); + f(&obj); + None + }) + .unwrap() + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index d80ef5a37f69df92402fb3359dff8c904e8149f7..83d37e6169ae72f97167efa10e892514e1821821 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,17 +1,21 @@ +mod auth_dialog; mod avatar; mod context_menu_bin; mod custom_entry; mod in_app_notification; mod label_with_widgets; +mod loading_listbox_row; mod pill; mod room_title; mod spinner_button; +pub use self::auth_dialog::{AuthData, AuthDialog}; pub use self::avatar::Avatar; pub use self::context_menu_bin::{ContextMenuBin, ContextMenuBinExt, ContextMenuBinImpl}; pub use self::custom_entry::CustomEntry; pub use self::in_app_notification::InAppNotification; pub use self::label_with_widgets::LabelWithWidgets; +pub use self::loading_listbox_row::LoadingListBoxRow; pub use self::pill::Pill; pub use self::room_title::RoomTitle; pub use self::spinner_button::SpinnerButton; diff --git a/src/meson.build b/src/meson.build index 94e8dd32c2d857723c8606ed22648fb719407221..84177de320188a9e12815440d5fc9ed51548131e 100644 --- a/src/meson.build +++ b/src/meson.build @@ -21,6 +21,7 @@ run_command( sources = files( 'application.rs', 'components/avatar.rs', + 'components/auth_dialog.rs', 'components/context_menu_bin.rs', 'components/custom_entry.rs', 'components/label_with_widgets.rs', @@ -29,6 +30,7 @@ sources = files( 'components/room_title.rs', 'components/in_app_notification.rs', 'components/spinner_button.rs', + 'components/loading_listbox_row.rs', 'config.rs', 'error.rs', 'main.rs', @@ -38,6 +40,11 @@ sources = files( 'utils.rs', 'session/avatar.rs', 'session/event_source_dialog.rs', + 'session/account_settings/devices_page/device.rs', + 'session/account_settings/devices_page/device_row.rs', + 'session/account_settings/devices_page/device_list.rs', + 'session/account_settings/devices_page/mod.rs', + 'session/account_settings/mod.rs', 'session/user.rs', 'session/mod.rs', 'session/content/divider_row.rs', diff --git a/src/session/account_settings/devices_page/device.rs b/src/session/account_settings/devices_page/device.rs new file mode 100644 index 0000000000000000000000000000000000000000..70862b3143191f3901edfc7302f0d12651c10f89 --- /dev/null +++ b/src/session/account_settings/devices_page/device.rs @@ -0,0 +1,245 @@ +use gtk::{glib, prelude::*, subclass::prelude::*}; + +use crate::components::{AuthData, AuthDialog}; +use crate::session::Session; +use matrix_sdk::{ + encryption::identities::Device as CryptoDevice, + ruma::{ + api::client::r0::device::{delete_device, Device as MatrixDevice}, + assign, + identifiers::DeviceId, + }, +}; + +use log::error; + +mod imp { + use super::*; + use once_cell::sync::{Lazy, OnceCell}; + use std::cell::RefCell; + + #[derive(Debug, Default)] + pub struct Device { + pub device: OnceCell, + pub crypto_device: OnceCell, + pub session: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for Device { + const NAME: &'static str = "Device"; + type Type = super::Device; + type ParentType = glib::Object; + } + + impl ObjectImpl for Device { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpec::new_object( + "session", + "Session", + "The session", + Session::static_type(), + glib::ParamFlags::READWRITE, + ), + glib::ParamSpec::new_string( + "device-id", + "Device Id", + "The Id of this device", + None, + glib::ParamFlags::READABLE, + ), + glib::ParamSpec::new_string( + "display-name", + "Display Name", + "The display name of the device", + None, + glib::ParamFlags::READABLE, + ), + glib::ParamSpec::new_string( + "last-seen-ip", + "Last Seen Ip", + "The last ip the device used", + None, + glib::ParamFlags::READABLE, + ), + glib::ParamSpec::new_pointer( + "last-seen-ts", + "Last Seen Ts", + "The last time the device was used", + glib::ParamFlags::READABLE, + ), + glib::ParamSpec::new_pointer( + "verified", + "Verified", + "Whether this devices is verified", + glib::ParamFlags::READABLE, + ), + ] + }); + + 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(), + "display-name" => obj.display_name().to_value(), + "device-id" => obj.device_id().as_str().to_value(), + "last-seen-ip" => obj.last_seen_ip().to_value(), + "last-seen-ts" => obj.last_seen_ts().to_value(), + "verified" => obj.is_verified().to_value(), + _ => unimplemented!(), + } + } + } +} + +glib::wrapper! { + /// `glib::Object` representation of a Device/Session of a User. + pub struct Device(ObjectSubclass); +} + +impl Device { + pub fn new( + session: Option<&Session>, + device: MatrixDevice, + crypto_device: Option, + ) -> Self { + let obj: Self = + glib::Object::new(&[("session", &session)]).expect("Failed to create Device"); + + obj.set_matrix_device(device, crypto_device); + + obj + } + + pub fn session(&self) -> Option { + let priv_ = imp::Device::from_instance(self); + priv_.session.borrow().clone() + } + + fn set_session(&self, session: Option) { + let priv_ = imp::Device::from_instance(self); + + if self.session() == session { + return; + }; + + priv_.session.replace(session); + + self.notify("session"); + } + + fn set_matrix_device(&self, device: MatrixDevice, crypto_device: Option) { + let priv_ = imp::Device::from_instance(self); + priv_.device.set(device).unwrap(); + if let Some(crypto_device) = crypto_device { + priv_.crypto_device.set(crypto_device).unwrap(); + } + } + + pub fn device_id(&self) -> &DeviceId { + let priv_ = imp::Device::from_instance(self); + &priv_.device.get().unwrap().device_id + } + + pub fn display_name(&self) -> &str { + let priv_ = imp::Device::from_instance(self); + if let Some(ref display_name) = priv_.device.get().unwrap().display_name { + display_name + } else { + self.device_id().as_str() + } + } + + pub fn last_seen_ip(&self) -> Option<&str> { + let priv_ = imp::Device::from_instance(self); + // TODO: Would be nice to also show the location + // See: https://gitlab.gnome.org/GNOME/fractal/-/issues/700 + priv_ + .device + .get() + .unwrap() + .last_seen_ip + .as_ref() + .map(String::as_str) + } + + pub fn last_seen_ts(&self) -> Option { + let priv_ = imp::Device::from_instance(self); + if let Some(last_seen_ts) = priv_.device.get().unwrap().last_seen_ts { + Some( + glib::DateTime::from_unix_utc(last_seen_ts.as_secs().into()) + .and_then(|t| t.to_local()) + .unwrap(), + ) + } else { + None + } + } + + /// Delete the `Device` + /// + /// Returns `true` for success + pub async fn delete(&self, transient_for: Option<&impl IsA>) -> bool { + let session = self + .session() + .expect("Session needs to be set when removing a device"); + let client = session.client().clone(); + let device_id = self.device_id().to_owned(); + + let delete_fn = move |auth_data: Option| { + let device_id = device_id.clone(); + let client = client.clone(); + + async move { + if let Some(auth) = auth_data { + let auth = Some(auth.as_matrix_auth_data()); + let request = assign!(delete_device::Request::new(&device_id), { auth }); + client.send(request, None).await + } else { + let request = delete_device::Request::new(&device_id); + client.send(request, None).await + } + } + }; + + let dialog = AuthDialog::new(transient_for, &session); + + let result = dialog + .authenticate::(delete_fn) + .await; + match result { + Some(Ok(_)) => true, + Some(Err(err)) => { + // TODO: show error message to the user + error!("Failed to delete device: {}", err); + false + } + None => false, + } + } + + pub fn is_verified(&self) -> bool { + let priv_ = imp::Device::from_instance(self); + priv_ + .crypto_device + .get() + .map_or(false, |device| device.verified()) + } +} diff --git a/src/session/account_settings/devices_page/device_item.rs b/src/session/account_settings/devices_page/device_item.rs new file mode 100644 index 0000000000000000000000000000000000000000..59d5e29f0509916deace109fff4669fd39cbe6f8 --- /dev/null +++ b/src/session/account_settings/devices_page/device_item.rs @@ -0,0 +1,97 @@ +use gtk::{glib, prelude::*, subclass::prelude::*}; + +use super::Device; + +/// This enum contains all possible types the device list can hold. +#[derive(Debug, Clone)] +pub enum ItemType { + Device(Device), + Error(String), + LoadingSpinner, +} + +#[derive(Clone, Debug, glib::GBoxed)] +#[gboxed(type_name = "BoxedDeviceItemType")] +pub struct BoxedItemType(ItemType); + +impl From for BoxedItemType { + fn from(type_: ItemType) -> Self { + BoxedItemType(type_) + } +} + +mod imp { + use super::*; + use once_cell::sync::{Lazy, OnceCell}; + + #[derive(Debug, Default)] + pub struct Item { + pub type_: OnceCell, + } + + #[glib::object_subclass] + impl ObjectSubclass for Item { + const NAME: &'static str = "DeviceItem"; + type Type = super::Item; + type ParentType = glib::Object; + } + + impl ObjectImpl for Item { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![glib::ParamSpec::new_boxed( + "type", + "Type", + "The type of this item", + BoxedItemType::static_type(), + glib::ParamFlags::WRITABLE | glib::ParamFlags::CONSTRUCT_ONLY, + )] + }); + + PROPERTIES.as_ref() + } + + fn set_property( + &self, + _obj: &Self::Type, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + match pspec.name() { + "type" => { + let type_ = value.get::().unwrap(); + self.type_.set(type_.0).unwrap(); + } + + _ => unimplemented!(), + } + } + } +} + +glib::wrapper! { + pub struct Item(ObjectSubclass); +} + +impl Item { + pub fn for_device(device: Device) -> Self { + let type_ = BoxedItemType(ItemType::Device(device)); + glib::Object::new(&[("type", &type_)]).expect("Failed to create Item") + } + + pub fn for_error(error: String) -> Self { + let type_ = BoxedItemType(ItemType::Error(error)); + glib::Object::new(&[("type", &type_)]).expect("Failed to create Item") + } + + pub fn for_loading_spinner() -> Self { + let type_ = BoxedItemType(ItemType::LoadingSpinner); + glib::Object::new(&[("type", &type_)]).expect("Failed to create Item") + } + + pub fn type_(&self) -> &ItemType { + let priv_ = imp::Item::from_instance(self); + priv_.type_.get().unwrap() + } +} diff --git a/src/session/account_settings/devices_page/device_list.rs b/src/session/account_settings/devices_page/device_list.rs new file mode 100644 index 0000000000000000000000000000000000000000..5ab1ddb78780525e24f6b497521697a05fc5293d --- /dev/null +++ b/src/session/account_settings/devices_page/device_list.rs @@ -0,0 +1,257 @@ +use gettextrs::gettext; +use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*}; +use log::error; +use matrix_sdk::encryption::identities::UserDevices as CryptoDevices; +use matrix_sdk::ruma::api::client::r0::device::Device as MatrixDevice; +use matrix_sdk::Error; + +use crate::{session::Session, utils::do_async}; + +use super::{Device, DeviceItem}; + +mod imp { + use once_cell::sync::Lazy; + use std::cell::{Cell, RefCell}; + + use super::*; + + #[derive(Debug, Default)] + pub struct DeviceList { + pub list: RefCell>, + pub session: RefCell>, + pub current_device: RefCell>, + pub loading: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for DeviceList { + const NAME: &'static str = "DeviceList"; + type Type = super::DeviceList; + type ParentType = glib::Object; + type Interfaces = (gio::ListModel,); + } + + impl ObjectImpl for DeviceList { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpec::new_object( + "session", + "Session", + "The session", + Session::static_type(), + glib::ParamFlags::READWRITE, + ), + glib::ParamSpec::new_object( + "current-device", + "Current Device", + "The device of this session", + DeviceItem::static_type(), + glib::ParamFlags::READABLE, + ), + ] + }); + + 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(), + "current-device" => obj.current_device().to_value(), + _ => unimplemented!(), + } + } + } + + impl ListModelImpl for DeviceList { + fn item_type(&self, _list_model: &Self::Type) -> glib::Type { + DeviceItem::static_type() + } + fn n_items(&self, _list_model: &Self::Type) -> u32 { + self.list.borrow().len() as u32 + } + fn item(&self, _list_model: &Self::Type, position: u32) -> Option { + self.list + .borrow() + .get(position as usize) + .map(glib::object::Cast::upcast_ref::) + .cloned() + } + } +} + +glib::wrapper! { + /// List of active devices for the logged in user. + pub struct DeviceList(ObjectSubclass) + @implements gio::ListModel; +} + +impl DeviceList { + pub fn new(session: &Session) -> Self { + glib::Object::new(&[("session", session)]).expect("Failed to create DeviceList") + } + + pub fn session(&self) -> Option { + let priv_ = imp::DeviceList::from_instance(self); + priv_.session.borrow().clone() + } + + fn set_session(&self, session: Option) { + let priv_ = imp::DeviceList::from_instance(self); + + if self.session() == session { + return; + }; + + priv_.session.replace(session); + + self.load_devices(); + + self.notify("session"); + } + + fn set_loading(&self, loading: bool) { + let priv_ = imp::DeviceList::from_instance(self); + + if loading == priv_.loading.get() { + return; + } + if loading { + self.update_list(vec![DeviceItem::for_loading_spinner()]); + } + priv_.loading.set(loading); + self.notify("current-device"); + } + + fn loading(&self) -> bool { + let priv_ = imp::DeviceList::from_instance(self); + priv_.loading.get() + } + + pub fn current_device(&self) -> DeviceItem { + let priv_ = imp::DeviceList::from_instance(self); + + priv_.current_device.borrow().clone().unwrap_or_else(|| { + if self.loading() { + DeviceItem::for_loading_spinner() + } else { + DeviceItem::for_error(gettext("Failed to load connected device.")) + } + }) + } + + fn set_current_device(&self, device: Option) { + let priv_ = imp::DeviceList::from_instance(self); + + priv_.current_device.replace(device); + + self.notify("current-device"); + } + + fn update_list(&self, devices: Vec) { + let priv_ = imp::DeviceList::from_instance(self); + let added = devices.len(); + + let prev_devices = priv_.list.replace(devices); + + self.items_changed(0, prev_devices.len() as u32, added as u32); + } + + fn finish_loading( + &self, + response: Result<(Option, Vec, CryptoDevices), Error>, + ) { + let session = self.session(); + let session = session.as_ref(); + + match response { + Ok((current_device, devices, crypto_devices)) => { + let devices = devices + .into_iter() + .map(|device| { + let crypto_device = crypto_devices.get(&device.device_id); + DeviceItem::for_device(Device::new(session, device, crypto_device)) + }) + .collect(); + + self.update_list(devices); + + self.set_current_device(current_device.map(|device| { + let crypto_device = crypto_devices.get(&device.device_id); + DeviceItem::for_device(Device::new(session, device, crypto_device)) + })); + } + Err(error) => { + error!("Couldn't load device list: {}", error); + self.update_list(vec![DeviceItem::for_error(gettext( + "Failed to load connected devices.", + ))]); + } + } + self.set_loading(false); + } + + pub fn load_devices(&self) { + let client = if let Some(session) = self.session() { + session.client().clone() + } else { + return; + }; + + self.set_loading(true); + + do_async( + glib::PRIORITY_DEFAULT, + async move { + let user_id = client.user_id().await.unwrap(); + let crypto_devices = client.get_user_devices(&user_id).await; + + let crypto_devices = match crypto_devices { + Ok(crypto_devices) => crypto_devices, + Err(error) => return Err(Error::CryptoStoreError(error)), + }; + + match client.devices().await { + Ok(mut response) => { + response + .devices + .sort_unstable_by(|a, b| b.last_seen_ts.cmp(&a.last_seen_ts)); + + let current_device = + if let Some(current_device_id) = client.device_id().await { + if let Some(index) = response.devices.iter().position(|device| { + *device.device_id == current_device_id.as_ref() + }) { + Some(response.devices.remove(index)) + } else { + None + } + } else { + None + }; + + Ok((current_device, response.devices, crypto_devices)) + } + Err(error) => Err(Error::Http(error)), + } + }, + clone!(@weak self as obj => move |response| async move { + obj.finish_loading(response); + }), + ); + } +} diff --git a/src/session/account_settings/devices_page/device_row.rs b/src/session/account_settings/devices_page/device_row.rs new file mode 100644 index 0000000000000000000000000000000000000000..19fe9b4c233eb27f76359413f244b51fcb969745 --- /dev/null +++ b/src/session/account_settings/devices_page/device_row.rs @@ -0,0 +1,270 @@ +use gettextrs::gettext; +use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate}; +use gtk_macros::spawn; + +use super::Device; +use crate::components::SpinnerButton; + +const G_TIME_SPAN_DAY: i64 = 86400000000; + +mod imp { + use super::*; + use glib::subclass::InitializingObject; + use once_cell::sync::Lazy; + use std::cell::RefCell; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/FractalNext/account-settings-device-row.ui")] + pub struct DeviceRow { + #[template_child] + pub display_name: TemplateChild, + #[template_child] + pub verified_icon: TemplateChild, + #[template_child] + pub last_seen_ip: TemplateChild, + #[template_child] + pub last_seen_ts: TemplateChild, + #[template_child] + pub delete_button: TemplateChild, + #[template_child] + pub verify_button: TemplateChild, + pub device: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for DeviceRow { + const NAME: &'static str = "AccountSettingsDeviceRow"; + type Type = super::DeviceRow; + type ParentType = gtk::ListBoxRow; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for DeviceRow { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![glib::ParamSpec::new_object( + "device", + "Device", + "The device this row is showing", + Device::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() { + "device" => { + obj.set_device(value.get().unwrap()); + } + _ => unimplemented!(), + } + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "device" => obj.device().to_value(), + _ => unimplemented!(), + } + } + + fn constructed(&self, obj: &Self::Type) { + self.parent_constructed(obj); + + self.delete_button + .connect_clicked(clone!(@weak obj => move |_| { + obj.delete(); + })); + + self.verify_button + .connect_clicked(clone!(@weak obj => move |_| { + todo!("Not implemented"); + })); + } + } + impl WidgetImpl for DeviceRow {} + impl ListBoxRowImpl for DeviceRow {} +} + +glib::wrapper! { + pub struct DeviceRow(ObjectSubclass) + @extends gtk::Widget, gtk::ListBoxRow, @implements gtk::Accessible; +} + +impl DeviceRow { + pub fn new(device: &Device) -> Self { + glib::Object::new(&[("device", device)]).expect("Failed to create DeviceRow") + } + + pub fn device(&self) -> Option { + let priv_ = imp::DeviceRow::from_instance(self); + priv_.device.borrow().clone() + } + + pub fn set_device(&self, device: Option) { + let priv_ = imp::DeviceRow::from_instance(self); + + if self.device() == device { + return; + } + + if let Some(ref device) = device { + priv_.display_name.set_label(&device.display_name()); + self.set_tooltip_text(Some(device.device_id().as_str())); + + priv_.verified_icon.set_visible(device.is_verified()); + // TODO: Implement verification + //priv_.verify_button.set_visible(!device.is_verified()); + + if let Some(last_seen_ip) = device.last_seen_ip() { + priv_.last_seen_ip.set_label(last_seen_ip); + priv_.last_seen_ip.show(); + } else { + priv_.last_seen_ip.hide(); + } + + if let Some(last_seen_ts) = device.last_seen_ts() { + let last_seen_ts = format_date_time_as_string(last_seen_ts); + priv_.last_seen_ts.set_label(&last_seen_ts); + priv_.last_seen_ts.show(); + } else { + priv_.last_seen_ts.hide(); + } + } + + priv_.device.replace(device); + self.notify("device"); + } + + fn delete(&self) { + let priv_ = imp::DeviceRow::from_instance(self); + + priv_.delete_button.set_loading(true); + + if let Some(device) = self.device() { + spawn!(clone!(@weak self as obj => async move { + let window: Option = obj.root().and_then(|root| root.downcast().ok()); + let success = device.delete(window.as_ref()).await; + let priv_ = imp::DeviceRow::from_instance(&obj); + priv_.delete_button.set_loading(false); + + if success { + obj.hide(); + } + })); + } + } +} + +// This was ported from Nautilus and simplified for our use case. +// See: https://gitlab.gnome.org/GNOME/nautilus/-/blob/master/src/nautilus-file.c#L5488 +pub fn format_date_time_as_string(datetime: glib::DateTime) -> glib::GString { + let now = glib::DateTime::new_now_local().unwrap(); + let format; + let days_ago = { + let today_midnight = + glib::DateTime::new_local(now.year(), now.month(), now.day_of_month(), 0, 0, 0f64) + .unwrap(); + + let date = glib::DateTime::new_local( + datetime.year(), + datetime.month(), + datetime.day_of_month(), + 0, + 0, + 0f64, + ) + .unwrap(); + + today_midnight.difference(&date) / G_TIME_SPAN_DAY + }; + + let use_24 = { + let local_time = datetime.format("%X").unwrap().as_str().to_ascii_lowercase(); + local_time.ends_with("am") || local_time.ends_with("pm") + }; + + // Show only the time if date is on today + if days_ago == 0 { + if use_24 { + // Translators: Time in 24h format + format = gettext("Last seen at %H:%M"); + } else { + // Translators: Time in 12h format + format = gettext("Last seen at %l:%M %p"); + } + } + // Show the word "Yesterday" and time if date is on yesterday + else if days_ago == 1 { + if use_24 { + // Translators: this is the word Yesterday followed by + // a time in 24h format. i.e. "Last seen Yesterday at 23:04" + // xgettext:no-c-format + format = gettext("Last seen Yesterday at %H:%M"); + } else { + // Translators: this is the word Yesterday followed by + // a time in 12h format. i.e. "Last seen Yesterday at 9:04 PM" + // xgettext:no-c-format + format = gettext("Last seen Yesterday at %l:%M %p"); + } + } + // Show a week day and time if date is in the last week + else if days_ago > 1 && days_ago < 7 { + if use_24 { + // Translators: this is the name of the week day followed by + // a time in 24h format. i.e. "Last seen Monday at 23:04" + // xgettext:no-c-format + format = gettext("Last seen %A at %H:%M"); + } else { + // Translators: this is the week day name followed by + // a time in 12h format. i.e. "Last seen Monday at 9:04 PM" + // xgettext:no-c-format + format = gettext("Last seen %A at %l:%M %p"); + } + } else if datetime.year() == now.year() { + if use_24 { + // Translators: this is the day of the month followed + // by the abbreviated month name followed by a time in + // 24h format i.e. "Last seen February 3 at 23:04" + // xgettext:no-c-format + format = gettext("Last seen %B %-e at %H:%M"); + } else { + // Translators: this is the day of the month followed + // by the abbreviated month name followed by a time in + // 12h format i.e. "Last seen February 3 at 9:04 PM" + // xgettext:no-c-format + format = gettext("Last seen %B %-e at %l:%M %p"); + } + } else { + if use_24 { + // Translators: this is the day number followed + // by the abbreviated month name followed by the year followed + // by a time in 24h format i.e. "Last seen February 3 2015 at 23:04" + // xgettext:no-c-format + format = gettext("Last seen %B %-e %Y at %H:%M"); + } else { + // Translators: this is the day number followed + // by the abbreviated month name followed by the year followed + // by a time in 12h format i.e. "Last seen February 3 2015 at 9:04 PM" + // xgettext:no-c-format + format = gettext("Last seen %B %-e %Y at %l:%M %p"); + } + } + + datetime.format(&format).unwrap() +} diff --git a/src/session/account_settings/devices_page/mod.rs b/src/session/account_settings/devices_page/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..cfb98eab08c446cb7f2aac3cea30888597e5ecb9 --- /dev/null +++ b/src/session/account_settings/devices_page/mod.rs @@ -0,0 +1,195 @@ +use adw::subclass::prelude::*; +use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate}; + +mod device; +use self::device::Device; +mod device_row; +use self::device_row::DeviceRow; +mod device_item; +use self::device_item::Item as DeviceItem; +mod device_list; +use self::device_list::DeviceList; + +use crate::components::LoadingListBoxRow; + +use crate::session::user::UserExt; +use crate::session::User; + +mod imp { + use super::*; + use glib::subclass::InitializingObject; + use std::cell::RefCell; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/FractalNext/account-settings-devices-page.ui")] + pub struct DevicesPage { + pub user: RefCell>, + #[template_child] + pub other_sessions_group: TemplateChild, + #[template_child] + pub other_sessions: TemplateChild, + #[template_child] + pub current_session: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for DevicesPage { + const NAME: &'static str = "DevicesPage"; + type Type = super::DevicesPage; + 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 DevicesPage { + fn properties() -> &'static [glib::ParamSpec] { + use once_cell::sync::Lazy; + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![glib::ParamSpec::new_object( + "user", + "User", + "The user of this account", + User::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() { + "user" => obj.set_user(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(), + _ => unimplemented!(), + } + } + } + + impl WidgetImpl for DevicesPage {} + impl PreferencesPageImpl for DevicesPage {} +} + +glib::wrapper! { + /// Preference Window to display and update room details. + pub struct DevicesPage(ObjectSubclass) + @extends gtk::Widget, gtk::Window, adw::Window, adw::PreferencesWindow, @implements gtk::Accessible; +} + +impl DevicesPage { + pub fn new(parent_window: &Option, user: &User) -> Self { + glib::Object::new(&[("transient-for", parent_window), ("user", user)]) + .expect("Failed to create DevicesPage") + } + + pub fn user(&self) -> Option { + let priv_ = imp::DevicesPage::from_instance(self); + priv_.user.borrow().clone() + } + + fn set_user(&self, user: Option) { + let priv_ = imp::DevicesPage::from_instance(self); + + if self.user() == user { + return; + } + + if let Some(ref user) = user { + let device_list = DeviceList::new(user.session()); + priv_.other_sessions.bind_model( + Some(&device_list), + clone!(@weak device_list => @default-panic, move |item| { + match item.downcast_ref::().unwrap().type_() { + device_item::ItemType::Device(device) => { + DeviceRow::new(&device).upcast::() + } + device_item::ItemType::Error(error) => { + let row = LoadingListBoxRow::new(); + row.set_error(Some(error)); + row.connect_retry(clone!(@weak device_list => move|_| { + device_list.load_devices() + })); + row.upcast::() + } + device_item::ItemType::LoadingSpinner => { + LoadingListBoxRow::new().upcast::() + } + } + }), + ); + + device_list.connect_items_changed( + clone!(@weak self as obj => move |device_list, _, _, _| { + obj.set_other_sessions_visiblity(device_list.n_items() > 0) + }), + ); + + self.set_other_sessions_visiblity(device_list.n_items() > 0); + + device_list.connect_notify_local( + Some("current-device"), + clone!(@weak self as obj => move |device_list, _| { + obj.set_current_device(&device_list); + }), + ); + + self.set_current_device(&device_list); + } else { + priv_.other_sessions.unbind_model(); + + if let Some(child) = priv_.current_session.first_child() { + priv_.current_session.remove(&child); + } + } + + priv_.user.replace(user); + self.notify("user"); + } + + fn set_other_sessions_visiblity(&self, visible: bool) { + let priv_ = imp::DevicesPage::from_instance(self); + priv_.other_sessions_group.set_visible(visible); + } + + fn set_current_device(&self, device_list: &DeviceList) { + let priv_ = imp::DevicesPage::from_instance(self); + if let Some(child) = priv_.current_session.first_child() { + priv_.current_session.remove(&child); + } + let row = match device_list.current_device().type_() { + device_item::ItemType::Device(device) => { + DeviceRow::new(&device).upcast::() + } + device_item::ItemType::Error(error) => { + let row = LoadingListBoxRow::new(); + row.set_error(Some(error)); + row.connect_retry(clone!(@weak device_list => move|_| { + device_list.load_devices() + })); + row.upcast::() + } + device_item::ItemType::LoadingSpinner => { + LoadingListBoxRow::new().upcast::() + } + }; + priv_.current_session.append(&row); + } +} diff --git a/src/session/account_settings/mod.rs b/src/session/account_settings/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..c19cd44271d99b700cc7a0c9149ace92e04182a1 --- /dev/null +++ b/src/session/account_settings/mod.rs @@ -0,0 +1,106 @@ +use adw::subclass::prelude::*; +use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate}; + +mod devices_page; +use devices_page::DevicesPage; + +use crate::session::User; + +mod imp { + use super::*; + use glib::subclass::InitializingObject; + use std::cell::RefCell; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/FractalNext/account-settings.ui")] + pub struct AccountSettings { + pub user: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for AccountSettings { + const NAME: &'static str = "AccountSettings"; + type Type = super::AccountSettings; + type ParentType = adw::PreferencesWindow; + + fn class_init(klass: &mut Self::Class) { + DevicesPage::static_type(); + Self::bind_template(klass); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for AccountSettings { + fn properties() -> &'static [glib::ParamSpec] { + use once_cell::sync::Lazy; + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![glib::ParamSpec::new_object( + "user", + "User", + "The user of this account", + User::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() { + "user" => obj.set_user(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(), + _ => unimplemented!(), + } + } + } + + impl WidgetImpl for AccountSettings {} + impl WindowImpl for AccountSettings {} + impl AdwWindowImpl for AccountSettings {} + impl PreferencesWindowImpl for AccountSettings {} +} + +glib::wrapper! { + /// Preference Window to display and update room details. + pub struct AccountSettings(ObjectSubclass) + @extends gtk::Widget, gtk::Window, adw::Window, adw::PreferencesWindow, @implements gtk::Accessible; +} + +impl AccountSettings { + pub fn new(parent_window: &Option, user: &User) -> Self { + glib::Object::new(&[("transient-for", parent_window), ("user", user)]) + .expect("Failed to create AccountSettings") + } + + pub fn user(&self) -> Option { + let priv_ = imp::AccountSettings::from_instance(self); + priv_.user.borrow().clone() + } + + fn set_user(&self, user: Option) { + let priv_ = imp::AccountSettings::from_instance(self); + + if self.user() == user { + return; + } + + priv_.user.replace(user); + self.notify("user"); + } +} diff --git a/src/session/mod.rs b/src/session/mod.rs index 23549befa1ca58d28b9fccbfd161c6bec0aabeda..74e3efeff9619e99c28724a616b376a254f53e2c 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -1,3 +1,4 @@ +mod account_settings; mod avatar; mod content; mod event_source_dialog; @@ -6,6 +7,7 @@ mod room_list; mod sidebar; mod user; +use self::account_settings::AccountSettings; pub use self::avatar::Avatar; use self::content::Content; pub use self::room::Room; @@ -100,6 +102,14 @@ mod imp { "session.toggle-room-search", None, ); + + klass.install_action( + "session.open-account-settings", + None, + move |widget, _, _| { + widget.open_account_settings(); + }, + ); } fn instance_init(obj: &InitializingObject) { @@ -482,6 +492,18 @@ impl Session { .sidebar .set_logged_in_users(sessions_stack_pages, self); } + + /// Returns the parent GtkWindow containing this widget. + fn parent_window(&self) -> Option { + self.root()?.downcast().ok() + } + + fn open_account_settings(&self) { + if let Some(user) = self.user() { + let window = AccountSettings::new(&self.parent_window(), &user); + window.show(); + } + } } impl Default for Session { diff --git a/src/session/sidebar/account_switcher/mod.rs b/src/session/sidebar/account_switcher/mod.rs index d4f92ee276cab08a5525df930029c92b858e1e12..82765f3d14841643130b0233949294d2e9a9bbf1 100644 --- a/src/session/sidebar/account_switcher/mod.rs +++ b/src/session/sidebar/account_switcher/mod.rs @@ -34,6 +34,7 @@ mod imp { fn class_init(klass: &mut Self::Class) { Self::bind_template(klass); + klass.set_accessible_role(gtk::AccessibleRole::Dialog); } fn instance_init(obj: &InitializingObject) { diff --git a/src/session/sidebar/account_switcher/user_entry.rs b/src/session/sidebar/account_switcher/user_entry.rs index 613df8c8787140b34acf66872bc017c53cd9f41f..34c33fc40feafcee945159f8715db9284646fde2 100644 --- a/src/session/sidebar/account_switcher/user_entry.rs +++ b/src/session/sidebar/account_switcher/user_entry.rs @@ -2,6 +2,8 @@ use super::avatar_with_selection::AvatarWithSelection; use adw::subclass::prelude::BinImpl; use gtk::{self, glib, prelude::*, subclass::prelude::*, CompositeTemplate}; +use crate::session::Session; + mod imp { use super::*; use glib::subclass::InitializingObject; @@ -29,6 +31,14 @@ mod imp { fn class_init(klass: &mut Self::Class) { AvatarWithSelection::static_type(); Self::bind_template(klass); + + klass.install_action( + "user-entry-row.open-account-settings", + None, + move |item, _, _| { + item.show_account_settings(); + }, + ); } fn instance_init(obj: &InitializingObject) { @@ -107,4 +117,19 @@ impl UserEntryRow { .display_name .set_css_classes(if hinted { &["bold"] } else { &[] }); } + + pub fn show_account_settings(&self) { + let priv_ = imp::UserEntryRow::from_instance(self); + + let session = priv_ + .session_page + .borrow() + .as_ref() + .map(|widget| widget.child()) + .unwrap() + .downcast::() + .unwrap(); + + session.activate_action("session.open-account-settings", None); + } }