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 #[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 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 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 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 #[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 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 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 #[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 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 None
339 }
340 Membership::Invite => {
341 Some(pgettext("member", "Invited"))
343 }
344 Membership::Ban => {
345 Some(pgettext("member", "Banned"))
347 }
348 Membership::Knock => {
349 Some(pgettext("member", "Knocked"))
351 }
352 Membership::Unsupported => {
353 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 _ => 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 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 #[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 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 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 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 #[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 #[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 #[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 #[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 #[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 "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 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 #[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 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 #[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 pub struct UserPage(ObjectSubclass<imp::UserPage>)
759 @extends gtk::Widget, adw::NavigationPage, @implements gtk::Accessible;
760}
761
762impl UserPage {
763 pub fn new(user: &impl IsA<User>) -> Self {
765 glib::Object::builder().property("user", user).build()
766 }
767
768 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}