Skip to main content

fractal/components/
user_page.rs

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