fractal/session/view/account_settings/general_page/
mod.rs

1use adw::{prelude::*, subclass::prelude::*};
2use gettextrs::gettext;
3use gtk::{gio, glib, glib::clone, CompositeTemplate};
4use matrix_sdk::authentication::oauth::{AccountManagementActionFull, AccountManagementUrlBuilder};
5use ruma::{api::client::discovery::get_capabilities::Capabilities, OwnedMxcUri};
6use tracing::error;
7
8mod change_password_subpage;
9mod deactivate_account_subpage;
10mod log_out_subpage;
11
12pub use self::{
13    change_password_subpage::ChangePasswordSubpage,
14    deactivate_account_subpage::DeactivateAccountSubpage, log_out_subpage::LogOutSubpage,
15};
16use super::AccountSettings;
17use crate::{
18    components::{ActionButton, ActionState, ButtonCountRow, CopyableRow, EditableAvatar},
19    prelude::*,
20    session::model::Session,
21    spawn, spawn_tokio, toast,
22    utils::{media::FileInfo, OngoingAsyncAction, TemplateCallbacks},
23};
24
25mod imp {
26    use std::cell::RefCell;
27
28    use glib::subclass::InitializingObject;
29
30    use super::*;
31
32    #[derive(Debug, Default, CompositeTemplate, glib::Properties)]
33    #[template(
34        resource = "/org/gnome/Fractal/ui/session/view/account_settings/general_page/mod.ui"
35    )]
36    #[properties(wrapper_type = super::GeneralPage)]
37    pub struct GeneralPage {
38        #[template_child]
39        avatar: TemplateChild<EditableAvatar>,
40        #[template_child]
41        display_name: TemplateChild<adw::EntryRow>,
42        #[template_child]
43        display_name_button: TemplateChild<ActionButton>,
44        #[template_child]
45        user_id: TemplateChild<CopyableRow>,
46        #[template_child]
47        user_sessions_row: TemplateChild<ButtonCountRow>,
48        #[template_child]
49        change_password_row: TemplateChild<adw::ButtonRow>,
50        #[template_child]
51        manage_account_row: TemplateChild<adw::ButtonRow>,
52        #[template_child]
53        homeserver: TemplateChild<CopyableRow>,
54        #[template_child]
55        session_id: TemplateChild<CopyableRow>,
56        #[template_child]
57        deactivate_account_button: TemplateChild<adw::ButtonRow>,
58        /// The current session.
59        #[property(get, set = Self::set_session, nullable)]
60        session: glib::WeakRef<Session>,
61        /// The ancestor [`AccountSettings`].
62        #[property(get, set = Self::set_account_settings, nullable)]
63        account_settings: glib::WeakRef<AccountSettings>,
64        /// The possible changes on the homeserver.
65        capabilities: RefCell<Capabilities>,
66        changing_avatar: RefCell<Option<OngoingAsyncAction<OwnedMxcUri>>>,
67        changing_display_name: RefCell<Option<OngoingAsyncAction<String>>>,
68        avatar_uri_handler: RefCell<Option<glib::SignalHandlerId>>,
69        display_name_handler: RefCell<Option<glib::SignalHandlerId>>,
70        user_sessions_count_handler: RefCell<Option<glib::SignalHandlerId>>,
71    }
72
73    #[glib::object_subclass]
74    impl ObjectSubclass for GeneralPage {
75        const NAME: &'static str = "AccountSettingsGeneralPage";
76        type Type = super::GeneralPage;
77        type ParentType = adw::PreferencesPage;
78
79        fn class_init(klass: &mut Self::Class) {
80            Self::bind_template(klass);
81            Self::bind_template_callbacks(klass);
82            TemplateCallbacks::bind_template_callbacks(klass);
83        }
84
85        fn instance_init(obj: &InitializingObject<Self>) {
86            obj.init_template();
87        }
88    }
89
90    #[glib::derived_properties]
91    impl ObjectImpl for GeneralPage {}
92
93    impl WidgetImpl for GeneralPage {}
94    impl PreferencesPageImpl for GeneralPage {}
95
96    #[gtk::template_callbacks]
97    impl GeneralPage {
98        /// Set the current session.
99        fn set_session(&self, session: Option<Session>) {
100            let prev_session = self.session.upgrade();
101            if prev_session == session {
102                return;
103            }
104
105            if let Some(session) = prev_session {
106                let user = session.user();
107
108                if let Some(handler) = self.avatar_uri_handler.take() {
109                    user.avatar_data()
110                        .image()
111                        .expect("user of session always has an avatar image")
112                        .disconnect(handler);
113                }
114                if let Some(handler) = self.display_name_handler.take() {
115                    user.disconnect(handler);
116                }
117                if let Some(handler) = self.user_sessions_count_handler.take() {
118                    session.user_sessions().other_sessions().disconnect(handler);
119                }
120            }
121
122            self.session.set(session.as_ref());
123            self.obj().notify_session();
124
125            let Some(session) = session else {
126                return;
127            };
128
129            self.user_id.set_subtitle(session.user_id().as_str());
130            self.homeserver.set_subtitle(session.homeserver().as_str());
131            self.session_id.set_subtitle(session.session_id());
132
133            let user = session.user();
134            let avatar_uri_handler = user
135                .avatar_data()
136                .image()
137                .expect("user of session always has an avatar image")
138                .connect_uri_string_notify(clone!(
139                    #[weak(rename_to = imp)]
140                    self,
141                    move |avatar_image| {
142                        imp.user_avatar_changed(avatar_image.uri().as_ref());
143                    }
144                ));
145            self.avatar_uri_handler.replace(Some(avatar_uri_handler));
146
147            let display_name_handler = user.connect_display_name_notify(clone!(
148                #[weak(rename_to=imp)]
149                self,
150                move |user| {
151                    imp.user_display_name_changed(&user.display_name());
152                }
153            ));
154            self.display_name_handler
155                .replace(Some(display_name_handler));
156
157            let other_user_sessions = session.user_sessions().other_sessions();
158            let user_sessions_count_handler = other_user_sessions.connect_items_changed(clone!(
159                #[weak(rename_to = imp)]
160                self,
161                move |other_user_sessions, _, _, _| {
162                    imp.user_sessions_row
163                        .set_count((other_user_sessions.n_items() + 1).to_string());
164                }
165            ));
166            self.user_sessions_row
167                .set_count((other_user_sessions.n_items() + 1).to_string());
168            self.user_sessions_count_handler
169                .replace(Some(user_sessions_count_handler));
170
171            spawn!(
172                glib::Priority::LOW,
173                clone!(
174                    #[weak(rename_to = imp)]
175                    self,
176                    async move {
177                        imp.load_capabilities().await;
178                    }
179                )
180            );
181        }
182
183        /// Set the acestor [`AccountSettings`].
184        fn set_account_settings(&self, account_settings: Option<&AccountSettings>) {
185            self.account_settings.set(account_settings);
186
187            if let Some(account_settings) = account_settings {
188                account_settings.connect_account_management_url_builder_changed(clone!(
189                    #[weak(rename_to = imp)]
190                    self,
191                    move |_| {
192                        imp.update_capabilities();
193                    }
194                ));
195            }
196
197            self.update_capabilities();
198        }
199
200        /// The builder for the account management URL of the OAuth 2.0
201        /// authorization server, if any.
202        fn account_management_url_builder(&self) -> Option<AccountManagementUrlBuilder> {
203            self.account_settings
204                .upgrade()
205                .and_then(|s| s.account_management_url_builder())
206        }
207
208        /// Load the possible changes on the user account.
209        async fn load_capabilities(&self) {
210            let Some(session) = self.session.upgrade() else {
211                return;
212            };
213            let client = session.client();
214
215            let handle = spawn_tokio!(async move { client.get_capabilities().await });
216            let capabilities = match handle.await.expect("task was not aborted") {
217                Ok(capabilities) => capabilities,
218                Err(error) => {
219                    error!("Could not get server capabilities: {error}");
220                    Capabilities::default()
221                }
222            };
223
224            self.capabilities.replace(capabilities);
225            self.update_capabilities();
226        }
227
228        /// Update the possible changes on the user account with the current
229        /// state.
230        fn update_capabilities(&self) {
231            let Some(session) = self.session.upgrade() else {
232                return;
233            };
234
235            let uses_oauth_api = session.uses_oauth_api();
236            let has_account_management_url = self.account_management_url_builder().is_some();
237            let capabilities = self.capabilities.borrow();
238
239            self.avatar
240                .set_editable(capabilities.set_avatar_url.enabled);
241            self.display_name
242                .set_editable(capabilities.set_displayname.enabled);
243            self.change_password_row
244                .set_visible(!has_account_management_url && capabilities.change_password.enabled);
245            self.manage_account_row
246                .set_visible(has_account_management_url);
247            self.deactivate_account_button
248                .set_visible(!uses_oauth_api || has_account_management_url);
249        }
250
251        /// Open the URL to manage the account.
252        #[template_callback]
253        async fn manage_account(&self) {
254            let Some(url_builder) = self.account_management_url_builder() else {
255                error!("Could not find open account management URL");
256                return;
257            };
258
259            let url = url_builder
260                .action(AccountManagementActionFull::Profile)
261                .build();
262
263            if let Err(error) = gtk::UriLauncher::new(url.as_str())
264                .launch_future(self.obj().root().and_downcast_ref::<gtk::Window>())
265                .await
266            {
267                error!("Could not launch account management URL: {error}");
268            }
269        }
270
271        /// Update the view when the user's avatar changed.
272        fn user_avatar_changed(&self, uri: Option<&OwnedMxcUri>) {
273            if let Some(action) = self.changing_avatar.borrow().as_ref() {
274                if uri != action.as_value() {
275                    // This is not the change we expected, maybe another device did a change too.
276                    // Let's wait for another change.
277                    return;
278                }
279            } else {
280                // No action is ongoing, we don't need to do anything.
281                return;
282            }
283
284            // Reset the state.
285            self.changing_avatar.take();
286            self.avatar.success();
287
288            let obj = self.obj();
289            if uri.is_none() {
290                toast!(obj, gettext("Avatar removed successfully"));
291            } else {
292                toast!(obj, gettext("Avatar changed successfully"));
293            }
294        }
295
296        /// Change the avatar of the user with the one in the given file.
297        #[template_callback]
298        async fn change_avatar(&self, file: gio::File) {
299            let Some(session) = self.session.upgrade() else {
300                return;
301            };
302
303            let avatar = &self.avatar;
304            avatar.edit_in_progress();
305
306            let info = match FileInfo::try_from_file(&file).await {
307                Ok(info) => info,
308                Err(error) => {
309                    error!("Could not load user avatar file info: {error}");
310                    toast!(self.obj(), gettext("Could not load file"));
311                    avatar.reset();
312                    return;
313                }
314            };
315
316            let data = match file.load_contents_future().await {
317                Ok((data, _)) => data,
318                Err(error) => {
319                    error!("Could not load user avatar file: {error}");
320                    toast!(self.obj(), gettext("Could not load file"));
321                    avatar.reset();
322                    return;
323                }
324            };
325
326            let client = session.client();
327            let client_clone = client.clone();
328            let handle = spawn_tokio!(async move {
329                client_clone
330                    .media()
331                    .upload(&info.mime, data.into(), None)
332                    .await
333            });
334
335            let uri = match handle.await.expect("task was not aborted") {
336                Ok(res) => res.content_uri,
337                Err(error) => {
338                    error!("Could not upload user avatar: {error}");
339                    toast!(self.obj(), gettext("Could not upload avatar"));
340                    avatar.reset();
341                    return;
342                }
343            };
344
345            let (action, weak_action) = OngoingAsyncAction::set(uri.clone());
346            self.changing_avatar.replace(Some(action));
347
348            let uri_clone = uri.clone();
349            let handle =
350                spawn_tokio!(
351                    async move { client.account().set_avatar_url(Some(&uri_clone)).await }
352                );
353
354            match handle.await.expect("task was not aborted") {
355                Ok(()) => {
356                    // If the user is in no rooms, we won't receive the update via sync, so change
357                    // the avatar manually if this request succeeds before the avatar is updated.
358                    // Because this action can finish in user_avatar_changed, we must only act if
359                    // this is still the current action.
360                    if weak_action.is_ongoing() {
361                        session.user().set_avatar_url(Some(uri));
362                    }
363                }
364                Err(error) => {
365                    // Because this action can finish in user_avatar_changed, we must only act if
366                    // this is still the current action.
367                    if weak_action.is_ongoing() {
368                        self.changing_avatar.take();
369                        error!("Could not change user avatar: {error}");
370                        toast!(self.obj(), gettext("Could not change avatar"));
371                        avatar.reset();
372                    }
373                }
374            }
375        }
376
377        /// Remove the current avatar of the user.
378        #[template_callback]
379        async fn remove_avatar(&self) {
380            let Some(session) = self.session.upgrade() else {
381                return;
382            };
383
384            // Ask for confirmation.
385            let confirm_dialog = adw::AlertDialog::builder()
386                .default_response("cancel")
387                .heading(gettext("Remove Avatar?"))
388                .body(gettext("Do you really want to remove your avatar?"))
389                .build();
390            confirm_dialog.add_responses(&[
391                ("cancel", &gettext("Cancel")),
392                ("remove", &gettext("Remove")),
393            ]);
394            confirm_dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
395
396            let obj = self.obj();
397            if confirm_dialog.choose_future(&*obj).await != "remove" {
398                return;
399            }
400
401            let avatar = &*self.avatar;
402            avatar.removal_in_progress();
403
404            let (action, weak_action) = OngoingAsyncAction::remove();
405            self.changing_avatar.replace(Some(action));
406
407            let client = session.client();
408            let handle = spawn_tokio!(async move { client.account().set_avatar_url(None).await });
409
410            match handle.await.expect("task was not aborted") {
411                Ok(()) => {
412                    // If the user is in no rooms, we won't receive the update via sync, so change
413                    // the avatar manually if this request succeeds before the avatar is updated.
414                    // Because this action can finish in avatar_changed, we must only act if this is
415                    // still the current action.
416                    if weak_action.is_ongoing() {
417                        session.user().set_avatar_url(None);
418                    }
419                }
420                Err(error) => {
421                    // Because this action can finish in avatar_changed, we must only act if this is
422                    // still the current action.
423                    if weak_action.is_ongoing() {
424                        self.changing_avatar.take();
425                        error!("Could not remove user avatar: {error}");
426                        toast!(obj, gettext("Could not remove avatar"));
427                        avatar.reset();
428                    }
429                }
430            }
431        }
432
433        /// Update the view when the text of the display name changed.
434        #[template_callback]
435        fn display_name_changed(&self) {
436            self.display_name_button
437                .set_visible(self.has_display_name_changed());
438        }
439
440        /// Whether the display name in the entry row is different than the
441        /// user's.
442        fn has_display_name_changed(&self) -> bool {
443            let Some(session) = self.session.upgrade() else {
444                return false;
445            };
446            let text = self.display_name.text();
447            let display_name = session.user().display_name();
448
449            text != display_name
450        }
451
452        /// Update the view when the user's display name changed.
453        fn user_display_name_changed(&self, name: &str) {
454            if let Some(action) = self.changing_display_name.borrow().as_ref() {
455                if action.as_value().map(String::as_str) == Some(name) {
456                    // This is not the change we expected, maybe another device did a change too.
457                    // Let's wait for another change.
458                    return;
459                }
460            } else {
461                // No action is ongoing, we don't need to do anything.
462                return;
463            }
464
465            // Reset state.
466            self.changing_display_name.take();
467
468            let entry = &self.display_name;
469            let button = &self.display_name_button;
470
471            entry.remove_css_class("error");
472            entry.set_sensitive(true);
473            button.set_visible(false);
474            button.set_state(ActionState::Confirm);
475            toast!(self.obj(), gettext("Name changed successfully"));
476        }
477
478        /// Change the display name of the user.
479        #[template_callback]
480        async fn change_display_name(&self) {
481            if !self.has_display_name_changed() {
482                // Nothing to do.
483                return;
484            }
485            let Some(session) = self.session.upgrade() else {
486                return;
487            };
488
489            let entry = &self.display_name;
490            let button = &self.display_name_button;
491
492            entry.set_sensitive(false);
493            button.set_state(ActionState::Loading);
494
495            let display_name = entry.text().trim().to_string();
496
497            let (action, weak_action) = OngoingAsyncAction::set(display_name.clone());
498            self.changing_display_name.replace(Some(action));
499
500            let client = session.client();
501            let display_name_clone = display_name.clone();
502            let handle = spawn_tokio!(async move {
503                client
504                    .account()
505                    .set_display_name(Some(&display_name_clone))
506                    .await
507            });
508
509            match handle.await.expect("task was not aborted") {
510                Ok(()) => {
511                    // If the user is in no rooms, we won't receive the update via sync, so change
512                    // the avatar manually if this request succeeds before the avatar is updated.
513                    // Because this action can finish in user_display_name_changed, we must only act
514                    // if this is still the current action.
515                    if weak_action.is_ongoing() {
516                        session.user().set_name(Some(display_name));
517                    }
518                }
519                Err(error) => {
520                    // Because this action can finish in user_display_name_changed, we must only act
521                    // if this is still the current action.
522                    if weak_action.is_ongoing() {
523                        self.changing_display_name.take();
524                        error!("Could not change user display name: {error}");
525                        toast!(self.obj(), gettext("Could not change display name"));
526                        button.set_state(ActionState::Retry);
527                        entry.add_css_class("error");
528                        entry.set_sensitive(true);
529                    }
530                }
531            }
532        }
533    }
534}
535
536glib::wrapper! {
537    /// Account settings page about the user and the session.
538    pub struct GeneralPage(ObjectSubclass<imp::GeneralPage>)
539        @extends gtk::Widget, adw::PreferencesPage, @implements gtk::Accessible;
540}
541
542impl GeneralPage {
543    pub fn new(session: &Session) -> Self {
544        glib::Object::builder().property("session", session).build()
545    }
546}