fractal/components/
user_page.rs

1use adw::{prelude::*, subclass::prelude::*};
2use gettextrs::{gettext, ngettext, pgettext};
3use gtk::{
4    CompositeTemplate, glib,
5    glib::{clone, closure_local},
6};
7use ruma::{OwnedEventId, events::room::power_levels::PowerLevelUserAction};
8
9use super::{Avatar, LoadingButton, LoadingButtonRow, PowerLevelSelectionRow};
10use crate::{
11    Window,
12    components::{
13        RoomMemberDestructiveAction, confirm_mute_room_member_dialog,
14        confirm_room_member_destructive_action_dialog,
15        confirm_set_room_member_power_level_same_as_own_dialog,
16    },
17    gettext_f,
18    prelude::*,
19    session::model::{Member, Membership, Permissions, Room, User},
20    toast,
21    utils::BoundObject,
22};
23
24mod imp {
25    use std::{cell::RefCell, sync::LazyLock};
26
27    use glib::subclass::{InitializingObject, Signal};
28
29    use super::*;
30
31    #[derive(Debug, Default, CompositeTemplate, glib::Properties)]
32    #[template(resource = "/org/gnome/Fractal/ui/components/user_page.ui")]
33    #[properties(wrapper_type = super::UserPage)]
34    pub struct UserPage {
35        #[template_child]
36        avatar: TemplateChild<Avatar>,
37        #[template_child]
38        direct_chat_box: TemplateChild<gtk::ListBox>,
39        #[template_child]
40        direct_chat_button: TemplateChild<LoadingButtonRow>,
41        #[template_child]
42        verified_row: TemplateChild<adw::ActionRow>,
43        #[template_child]
44        verified_stack: TemplateChild<gtk::Stack>,
45        #[template_child]
46        verify_button: TemplateChild<LoadingButton>,
47        #[template_child]
48        room_box: TemplateChild<gtk::Box>,
49        #[template_child]
50        room_title: TemplateChild<gtk::Label>,
51        #[template_child]
52        membership_row: TemplateChild<adw::ActionRow>,
53        #[template_child]
54        membership_label: TemplateChild<gtk::Label>,
55        #[template_child]
56        power_level_row: TemplateChild<PowerLevelSelectionRow>,
57        #[template_child]
58        invite_button: TemplateChild<LoadingButtonRow>,
59        #[template_child]
60        kick_button: TemplateChild<LoadingButtonRow>,
61        #[template_child]
62        ban_button: TemplateChild<LoadingButtonRow>,
63        #[template_child]
64        unban_button: TemplateChild<LoadingButtonRow>,
65        #[template_child]
66        remove_messages_button: TemplateChild<LoadingButtonRow>,
67        #[template_child]
68        ignored_row: TemplateChild<adw::ActionRow>,
69        #[template_child]
70        ignored_button: TemplateChild<LoadingButton>,
71        /// The current user.
72        #[property(get, set = Self::set_user, explicit_notify, nullable)]
73        user: BoundObject<User>,
74        bindings: RefCell<Vec<glib::Binding>>,
75        permissions_handler: RefCell<Option<glib::SignalHandlerId>>,
76        room_handlers: RefCell<Vec<glib::SignalHandlerId>>,
77    }
78
79    #[glib::object_subclass]
80    impl ObjectSubclass for UserPage {
81        const NAME: &'static str = "UserPage";
82        type Type = super::UserPage;
83        type ParentType = adw::NavigationPage;
84
85        fn class_init(klass: &mut Self::Class) {
86            Self::bind_template(klass);
87            Self::bind_template_callbacks(klass);
88
89            klass.set_css_name("user-page");
90        }
91
92        fn instance_init(obj: &InitializingObject<Self>) {
93            obj.init_template();
94        }
95    }
96
97    #[glib::derived_properties]
98    impl ObjectImpl for UserPage {
99        fn signals() -> &'static [Signal] {
100            static SIGNALS: LazyLock<Vec<Signal>> =
101                LazyLock::new(|| vec![Signal::builder("close").build()]);
102            SIGNALS.as_ref()
103        }
104
105        fn dispose(&self) {
106            self.disconnect_signals();
107        }
108    }
109
110    impl WidgetImpl for UserPage {}
111    impl NavigationPageImpl for UserPage {}
112
113    #[gtk::template_callbacks]
114    impl UserPage {
115        /// Set the current user.
116        fn set_user(&self, user: Option<User>) {
117            if self.user.obj() == user {
118                return;
119            }
120            let obj = self.obj();
121
122            self.disconnect_signals();
123            self.power_level_row.set_permissions(None::<Permissions>);
124
125            if let Some(user) = user {
126                let title_binding = user
127                    .bind_property("display-name", &*obj, "title")
128                    .sync_create()
129                    .build();
130                let avatar_binding = user
131                    .bind_property("avatar-data", &*self.avatar, "data")
132                    .sync_create()
133                    .build();
134                let bindings = vec![title_binding, avatar_binding];
135
136                let verified_handler = user.connect_is_verified_notify(clone!(
137                    #[weak(rename_to = imp)]
138                    self,
139                    move |_| {
140                        imp.update_verified();
141                    }
142                ));
143                let ignored_handler = user.connect_is_ignored_notify(clone!(
144                    #[weak(rename_to = imp)]
145                    self,
146                    move |_| {
147                        imp.update_direct_chat();
148                        imp.update_ignored();
149                    }
150                ));
151                let mut handlers = vec![verified_handler, ignored_handler];
152
153                if let Some(member) = user.downcast_ref::<Member>() {
154                    let room = member.room();
155
156                    let permissions = room.permissions();
157                    let permissions_handler = permissions.connect_changed(clone!(
158                        #[weak(rename_to = imp)]
159                        self,
160                        move |_| {
161                            imp.update_room();
162                        }
163                    ));
164                    self.permissions_handler.replace(Some(permissions_handler));
165                    self.power_level_row.set_permissions(Some(permissions));
166
167                    let room_display_name_handler = room.connect_display_name_notify(clone!(
168                        #[weak(rename_to = imp)]
169                        self,
170                        move |_| {
171                            imp.update_room();
172                        }
173                    ));
174                    let room_direct_member_handler = room.connect_direct_member_notify(clone!(
175                        #[weak(rename_to = imp)]
176                        self,
177                        move |_| {
178                            imp.update_direct_chat();
179                        }
180                    ));
181                    self.room_handlers
182                        .replace(vec![room_display_name_handler, room_direct_member_handler]);
183
184                    let membership_handler = member.connect_membership_notify(clone!(
185                        #[weak(rename_to = imp)]
186                        self,
187                        move |member| {
188                            if member.membership() == Membership::Leave {
189                                imp.obj().emit_by_name::<()>("close", &[]);
190                            } else {
191                                imp.update_room();
192                            }
193                        }
194                    ));
195                    let power_level_handler = member.connect_power_level_notify(clone!(
196                        #[weak(rename_to = imp)]
197                        self,
198                        move |_| {
199                            imp.update_room();
200                        }
201                    ));
202                    handlers.extend([membership_handler, power_level_handler]);
203                }
204
205                // We do not need to listen to changes of the property, it never changes after
206                // construction.
207                let is_own_user = user.is_own_user();
208                self.ignored_row.set_visible(!is_own_user);
209
210                self.user.set(user, handlers);
211                self.bindings.replace(bindings);
212            }
213
214            self.load_direct_chat();
215            self.update_direct_chat();
216            self.update_room();
217            self.update_verified();
218            self.update_ignored();
219            obj.notify_user();
220        }
221
222        /// Disconnect all the signals.
223        fn disconnect_signals(&self) {
224            if let Some(member) = self.user.obj().and_downcast::<Member>() {
225                let room = member.room();
226
227                for handler in self.room_handlers.take() {
228                    room.disconnect(handler);
229                }
230                if let Some(handler) = self.permissions_handler.take() {
231                    room.permissions().disconnect(handler);
232                }
233            }
234
235            for binding in self.bindings.take() {
236                binding.unbind();
237            }
238
239            self.user.disconnect_signals();
240        }
241
242        /// Copy the user ID to the clipboard.
243        #[template_callback]
244        fn copy_user_id(&self) {
245            let Some(user) = self.user.obj() else {
246                return;
247            };
248
249            let obj = self.obj();
250            obj.clipboard().set_text(user.user_id().as_str());
251            toast!(obj, gettext("Matrix user ID copied to clipboard"));
252        }
253
254        /// Update the visibility of the direct chat button.
255        fn update_direct_chat(&self) {
256            let user = self.user.obj();
257            let is_other_user = user
258                .as_ref()
259                .is_some_and(|u| !u.is_own_user() && !u.is_ignored());
260            let is_direct_chat = user
261                .and_downcast::<Member>()
262                .is_some_and(|m| m.room().direct_member().is_some());
263            self.direct_chat_box
264                .set_visible(is_other_user && !is_direct_chat);
265        }
266
267        /// Load whether the current user has a direct chat or not.
268        fn load_direct_chat(&self) {
269            self.direct_chat_button.set_is_loading(true);
270
271            let Some(user) = self.user.obj() else {
272                return;
273            };
274
275            let direct_chat = user.direct_chat();
276
277            let title = if direct_chat.is_some() {
278                gettext("Open Direct Chat")
279            } else {
280                gettext("Create Direct Chat")
281            };
282            self.direct_chat_button.set_title(&title);
283
284            self.direct_chat_button.set_is_loading(false);
285        }
286
287        /// Open a direct chat with the current user.
288        ///
289        /// If one doesn't exist already, it is created.
290        #[template_callback]
291        async fn open_direct_chat(&self) {
292            let Some(user) = self.user.obj() else {
293                return;
294            };
295
296            self.direct_chat_button.set_is_loading(true);
297            let obj = self.obj();
298
299            let Ok(room) = user.get_or_create_direct_chat().await else {
300                toast!(obj, &gettext("Could not create a new Direct Chat"));
301                self.direct_chat_button.set_is_loading(false);
302
303                return;
304            };
305
306            let Some(parent_window) = obj.root().and_downcast::<gtk::Window>() else {
307                return;
308            };
309
310            if let Some(main_window) = parent_window.transient_for().and_downcast::<Window>() {
311                main_window.session_view().select_room(room);
312            }
313
314            parent_window.close();
315        }
316
317        /// Update the room section.
318        fn update_room(&self) {
319            let Some(member) = self.user.obj().and_downcast::<Member>() else {
320                self.room_box.set_visible(false);
321                return;
322            };
323
324            let membership = member.membership();
325            if membership == Membership::Leave {
326                self.room_box.set_visible(false);
327                return;
328            }
329
330            let room = member.room();
331            let room_title = gettext_f("In {room_name}", &[("room_name", &room.display_name())]);
332            self.room_title.set_label(&room_title);
333
334            let label = match membership {
335                Membership::Leave => unreachable!(),
336                Membership::Join => {
337                    // Nothing to update, it should show the role row.
338                    None
339                }
340                Membership::Invite => {
341                    // Translators: As in, 'The room member was invited'.
342                    Some(pgettext("member", "Invited"))
343                }
344                Membership::Ban => {
345                    // Translators: As in, 'The room member was banned'.
346                    Some(pgettext("member", "Banned"))
347                }
348                Membership::Knock => {
349                    // Translators: As in, 'The room member knocked to request access to the room'.
350                    Some(pgettext("member", "Knocked"))
351                }
352                Membership::Unsupported => {
353                    // Translators: As in, 'The room member has an unknown role'.
354                    Some(pgettext("member", "Unknown"))
355                }
356            };
357            if let Some(label) = label {
358                self.membership_label.set_label(&label);
359            }
360
361            let is_role = membership == Membership::Join;
362            self.membership_row.set_visible(!is_role);
363            self.power_level_row.set_visible(is_role);
364
365            let permissions = room.permissions();
366            let user_id = member.user_id();
367
368            self.power_level_row.set_is_loading(false);
369            self.power_level_row
370                .set_selected_power_level(member.power_level());
371
372            let can_change_power_level = !member.is_own_user()
373                && permissions.can_do_to_user(user_id, PowerLevelUserAction::ChangePowerLevel);
374            self.power_level_row.set_read_only(!can_change_power_level);
375
376            let can_invite = matches!(membership, Membership::Knock) && permissions.can_invite();
377            if can_invite {
378                self.invite_button.set_title(&gettext("Allow Access"));
379                self.invite_button.set_visible(true);
380            } else {
381                self.invite_button.set_visible(false);
382            }
383
384            let can_kick = matches!(
385                membership,
386                Membership::Join | Membership::Invite | Membership::Knock
387            ) && permissions.can_do_to_user(user_id, PowerLevelUserAction::Kick);
388            if can_kick {
389                let label = match membership {
390                    Membership::Invite => gettext("Revoke Invite"),
391                    Membership::Knock => gettext("Deny Access"),
392                    // Translators: As in, 'Kick room member'.
393                    _ => gettext("Kick"),
394                };
395                self.kick_button.set_title(&label);
396            }
397            self.kick_button.set_visible(can_kick);
398
399            let can_ban = membership != Membership::Ban
400                && permissions.can_do_to_user(user_id, PowerLevelUserAction::Ban);
401            self.ban_button.set_visible(can_ban);
402
403            let can_unban = matches!(membership, Membership::Ban)
404                && permissions.can_do_to_user(user_id, PowerLevelUserAction::Unban);
405            self.unban_button.set_visible(can_unban);
406
407            let can_redact = !member.is_own_user() && permissions.can_redact_other();
408            self.remove_messages_button.set_visible(can_redact);
409
410            self.room_box.set_visible(true);
411        }
412
413        /// Reset the initial state of the buttons of the room section.
414        fn reset_room(&self) {
415            self.kick_button.set_is_loading(false);
416            self.kick_button.set_sensitive(true);
417
418            self.invite_button.set_is_loading(false);
419            self.invite_button.set_sensitive(true);
420
421            self.ban_button.set_is_loading(false);
422            self.ban_button.set_sensitive(true);
423
424            self.unban_button.set_is_loading(false);
425            self.unban_button.set_sensitive(true);
426
427            self.remove_messages_button.set_is_loading(false);
428            self.remove_messages_button.set_sensitive(true);
429        }
430
431        /// Set the power level of the user.
432        #[template_callback]
433        async fn set_power_level(&self) {
434            let Some(member) = self.user.obj().and_downcast::<Member>() else {
435                return;
436            };
437
438            let row = &self.power_level_row;
439            let power_level = row.selected_power_level();
440            let old_power_level = member.power_level();
441
442            if old_power_level == power_level {
443                // Nothing to do.
444                return;
445            }
446
447            row.set_is_loading(true);
448            row.set_read_only(true);
449
450            let obj = self.obj();
451            let permissions = member.room().permissions();
452
453            // Warn if user is muted but was not before.
454            let mute_power_level = permissions.mute_power_level();
455            let is_muted = power_level <= mute_power_level && old_power_level > mute_power_level;
456            if is_muted && !confirm_mute_room_member_dialog(&member, &*obj).await {
457                self.update_room();
458                return;
459            }
460
461            // Warn if power level is set at same level as own power level.
462            let is_own_power_level = power_level == permissions.own_power_level();
463            if is_own_power_level
464                && !confirm_set_room_member_power_level_same_as_own_dialog(&member, &*obj).await
465            {
466                self.update_room();
467                return;
468            }
469
470            let user_id = member.user_id().clone();
471
472            if permissions
473                .set_user_power_level(user_id, power_level)
474                .await
475                .is_err()
476            {
477                toast!(obj, gettext("Could not change the role"));
478                self.update_room();
479            }
480        }
481
482        /// Invite the user to the room.
483        #[template_callback]
484        async fn invite_user(&self) {
485            let Some(member) = self.user.obj().and_downcast::<Member>() else {
486                return;
487            };
488
489            self.invite_button.set_is_loading(true);
490            self.kick_button.set_sensitive(false);
491            self.ban_button.set_sensitive(false);
492            self.unban_button.set_sensitive(false);
493
494            let room = member.room();
495            let user_id = member.user_id().clone();
496
497            if room.invite(&[user_id]).await.is_err() {
498                toast!(self.obj(), gettext("Could not invite user"));
499            }
500
501            self.reset_room();
502        }
503
504        /// Kick the user from the room.
505        #[template_callback]
506        async fn kick_user(&self) {
507            let Some(member) = self.user.obj().and_downcast::<Member>() else {
508                return;
509            };
510            let obj = self.obj();
511
512            self.kick_button.set_is_loading(true);
513            self.invite_button.set_sensitive(false);
514            self.ban_button.set_sensitive(false);
515            self.unban_button.set_sensitive(false);
516
517            let Some(response) = confirm_room_member_destructive_action_dialog(
518                &member,
519                RoomMemberDestructiveAction::Kick,
520                &*obj,
521            )
522            .await
523            else {
524                self.reset_room();
525                return;
526            };
527
528            let room = member.room();
529            let user_id = member.user_id().clone();
530            if room.kick(&[(user_id, response.reason)]).await.is_err() {
531                let error = match member.membership() {
532                    Membership::Invite => gettext("Could not revoke invite of user"),
533                    Membership::Knock => gettext("Could not deny access to user"),
534                    _ => gettext("Could not kick user"),
535                };
536                toast!(obj, error);
537
538                self.reset_room();
539            }
540        }
541
542        /// Ban the room member.
543        #[template_callback]
544        async fn ban_user(&self) {
545            let Some(member) = self.user.obj().and_downcast::<Member>() else {
546                return;
547            };
548            let obj = self.obj();
549
550            self.ban_button.set_is_loading(true);
551            self.invite_button.set_sensitive(false);
552            self.kick_button.set_sensitive(false);
553            self.unban_button.set_sensitive(false);
554
555            let permissions = member.room().permissions();
556            let redactable_events = if permissions.can_redact_other() {
557                member.redactable_events()
558            } else {
559                vec![]
560            };
561
562            let Some(response) = confirm_room_member_destructive_action_dialog(
563                &member,
564                RoomMemberDestructiveAction::Ban(redactable_events.len()),
565                &*obj,
566            )
567            .await
568            else {
569                self.reset_room();
570                return;
571            };
572
573            let room = member.room();
574            let user_id = member.user_id().clone();
575            if room
576                .ban(&[(user_id, response.reason.clone())])
577                .await
578                .is_err()
579            {
580                toast!(obj, gettext("Could not ban user"));
581            }
582
583            if response.remove_events {
584                self.remove_known_messages_inner(
585                    &member.room(),
586                    redactable_events,
587                    response.reason,
588                )
589                .await;
590            }
591
592            self.reset_room();
593        }
594
595        /// Unban the room member.
596        #[template_callback]
597        async fn unban_user(&self) {
598            let Some(member) = self.user.obj().and_downcast::<Member>() else {
599                return;
600            };
601
602            self.unban_button.set_is_loading(true);
603            self.invite_button.set_sensitive(false);
604            self.kick_button.set_sensitive(false);
605            self.ban_button.set_sensitive(false);
606
607            let room = member.room();
608            let user_id = member.user_id().clone();
609
610            if room.unban(&[(user_id, None)]).await.is_err() {
611                toast!(self.obj(), gettext("Could not unban user"));
612            }
613
614            self.reset_room();
615        }
616
617        /// Remove the known events of the room member.
618        #[template_callback]
619        async fn remove_messages(&self) {
620            let Some(member) = self.user.obj().and_downcast::<Member>() else {
621                return;
622            };
623
624            self.remove_messages_button.set_is_loading(true);
625
626            let redactable_events = member.redactable_events();
627
628            let Some(response) = confirm_room_member_destructive_action_dialog(
629                &member,
630                RoomMemberDestructiveAction::RemoveMessages(redactable_events.len()),
631                &*self.obj(),
632            )
633            .await
634            else {
635                self.reset_room();
636                return;
637            };
638
639            self.remove_known_messages_inner(&member.room(), redactable_events, response.reason)
640                .await;
641
642            self.reset_room();
643        }
644
645        async fn remove_known_messages_inner(
646            &self,
647            room: &Room,
648            events: Vec<OwnedEventId>,
649            reason: Option<String>,
650        ) {
651            if let Err(events) = room.redact(&events, reason).await {
652                let n = u32::try_from(events.len()).unwrap_or(u32::MAX);
653
654                toast!(
655                    self.obj(),
656                    ngettext(
657                        // Translators: Do NOT translate the content between '{' and '}',
658                        // this is a variable name.
659                        "Could not remove 1 message sent by the user",
660                        "Could not remove {n} messages sent by the user",
661                        n,
662                    ),
663                    n,
664                );
665            }
666        }
667
668        /// Update the verified row.
669        fn update_verified(&self) {
670            let Some(user) = self.user.obj() else {
671                return;
672            };
673
674            if user.is_verified() {
675                self.verified_row.set_title(&gettext("Identity verified"));
676                self.verified_stack.set_visible_child_name("icon");
677                self.verify_button.set_sensitive(false);
678            } else {
679                self.verify_button.set_sensitive(true);
680                self.verified_stack.set_visible_child_name("button");
681                self.verified_row
682                    .set_title(&gettext("Identity not verified"));
683            }
684        }
685
686        /// Launch the verification for the current user.
687        #[template_callback]
688        async fn verify_user(&self) {
689            let Some(user) = self.user.obj() else {
690                return;
691            };
692            let obj = self.obj();
693
694            self.verify_button.set_is_loading(true);
695
696            let Ok(verification) = user.verify_identity().await else {
697                toast!(obj, gettext("Could not start user verification"));
698                self.verify_button.set_is_loading(false);
699                return;
700            };
701
702            let Some(parent_window) = obj.root().and_downcast::<gtk::Window>() else {
703                return;
704            };
705
706            if let Some(main_window) = parent_window.transient_for().and_downcast::<Window>() {
707                main_window
708                    .session_view()
709                    .select_identity_verification(verification);
710            }
711
712            parent_window.close();
713        }
714
715        /// Update the ignored row.
716        fn update_ignored(&self) {
717            let Some(user) = self.user.obj() else {
718                return;
719            };
720
721            if user.is_ignored() {
722                self.ignored_row.set_title(&gettext("Ignored"));
723                self.ignored_button
724                    .set_content_label(gettext("Stop Ignoring"));
725                self.ignored_button.remove_css_class("destructive-action");
726            } else {
727                self.ignored_row.set_title(&gettext("Not Ignored"));
728                self.ignored_button.set_content_label(gettext("Ignore"));
729                self.ignored_button.add_css_class("destructive-action");
730            }
731        }
732
733        /// Toggle whether the user is ignored or not.
734        #[template_callback]
735        async fn toggle_ignored(&self) {
736            let Some(user) = self.user.obj() else {
737                return;
738            };
739
740            let obj = self.obj();
741            self.ignored_button.set_is_loading(true);
742
743            if user.is_ignored() {
744                if user.stop_ignoring().await.is_err() {
745                    toast!(obj, gettext("Could not stop ignoring user"));
746                }
747            } else if user.ignore().await.is_err() {
748                toast!(obj, gettext("Could not ignore user"));
749            }
750
751            self.ignored_button.set_is_loading(false);
752        }
753    }
754}
755
756glib::wrapper! {
757    /// Page to view details about a user.
758    pub struct UserPage(ObjectSubclass<imp::UserPage>)
759        @extends gtk::Widget, adw::NavigationPage, @implements gtk::Accessible;
760}
761
762impl UserPage {
763    /// Construct a new `UserPage` for the given user.
764    pub fn new(user: &impl IsA<User>) -> Self {
765        glib::Object::builder().property("user", user).build()
766    }
767
768    /// Connect to the signal emitted when the page should be closed.
769    pub fn connect_close<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
770        self.connect_closure(
771            "close",
772            true,
773            closure_local!(|obj: Self| {
774                f(&obj);
775            }),
776        )
777    }
778}