fractal/session/view/content/room_history/sender_avatar/
mod.rs

1use adw::{prelude::*, subclass::prelude::*};
2use gettextrs::{gettext, ngettext};
3use gtk::{CompositeTemplate, gdk, glib, glib::clone};
4use ruma::{OwnedEventId, events::room::power_levels::PowerLevelUserAction};
5
6use crate::{
7    Window,
8    components::{
9        Avatar, RoomMemberDestructiveAction, UserProfileDialog, confirm_mute_room_member_dialog,
10        confirm_room_member_destructive_action_dialog,
11    },
12    gettext_f,
13    prelude::*,
14    session::{
15        model::{Member, MemberRole, Membership, User},
16        view::content::RoomHistory,
17    },
18    toast,
19    utils::{BoundObject, key_bindings},
20};
21
22mod imp {
23    use std::cell::{Cell, RefCell};
24
25    use glib::subclass::InitializingObject;
26
27    use super::*;
28
29    #[derive(Debug, Default, CompositeTemplate, glib::Properties)]
30    #[template(
31        resource = "/org/gnome/Fractal/ui/session/view/content/room_history/sender_avatar/mod.ui"
32    )]
33    #[properties(wrapper_type = super::SenderAvatar)]
34    pub struct SenderAvatar {
35        #[template_child]
36        avatar: TemplateChild<Avatar>,
37        #[template_child]
38        user_id_btn: TemplateChild<gtk::Button>,
39        /// Whether this avatar is active.
40        ///
41        /// This avatar is active when the popover is displayed.
42        #[property(get)]
43        active: Cell<bool>,
44        direct_member_handler: RefCell<Option<glib::SignalHandlerId>>,
45        permissions_handler: RefCell<Option<glib::SignalHandlerId>>,
46        /// The displayed member.
47        #[property(get, set = Self::set_sender, explicit_notify, nullable)]
48        sender: BoundObject<Member>,
49        /// The popover of this avatar.
50        popover: BoundObject<gtk::PopoverMenu>,
51    }
52
53    #[glib::object_subclass]
54    impl ObjectSubclass for SenderAvatar {
55        const NAME: &'static str = "ContentSenderAvatar";
56        type Type = super::SenderAvatar;
57        type ParentType = gtk::Widget;
58
59        fn class_init(klass: &mut Self::Class) {
60            Self::bind_template(klass);
61            Self::bind_template_callbacks(klass);
62
63            klass.set_layout_manager_type::<gtk::BinLayout>();
64            klass.set_css_name("sender-avatar");
65            klass.set_accessible_role(gtk::AccessibleRole::ToggleButton);
66
67            klass.install_action("sender-avatar.copy-user-id", None, |obj, _, _| {
68                if let Some(popover) = obj.imp().popover.obj() {
69                    popover.popdown();
70                }
71
72                let Some(sender) = obj.sender() else {
73                    return;
74                };
75
76                obj.clipboard().set_text(sender.user_id().as_str());
77                toast!(obj, gettext("Matrix user ID copied to clipboard"));
78            });
79
80            klass.install_action("sender-avatar.mention", None, |obj, _, _| {
81                obj.imp().mention();
82            });
83
84            klass.install_action_async(
85                "sender-avatar.open-direct-chat",
86                None,
87                |obj, _, _| async move {
88                    obj.imp().open_direct_chat().await;
89                },
90            );
91
92            klass.install_action("sender-avatar.permalink", None, |obj, _, _| {
93                let Some(sender) = obj.sender() else {
94                    return;
95                };
96
97                obj.clipboard()
98                    .set_text(&sender.matrix_to_uri().to_string());
99                toast!(obj, gettext("Link copied to clipboard"));
100            });
101
102            klass.install_action_async("sender-avatar.invite", None, |obj, _, _| async move {
103                obj.imp().invite().await;
104            });
105
106            klass.install_action_async(
107                "sender-avatar.revoke-invite",
108                None,
109                |obj, _, _| async move {
110                    obj.imp().kick().await;
111                },
112            );
113
114            klass.install_action_async("sender-avatar.mute", None, |obj, _, _| async move {
115                obj.imp().toggle_muted().await;
116            });
117
118            klass.install_action_async("sender-avatar.unmute", None, |obj, _, _| async move {
119                obj.imp().toggle_muted().await;
120            });
121
122            klass.install_action_async("sender-avatar.kick", None, |obj, _, _| async move {
123                obj.imp().kick().await;
124            });
125
126            klass.install_action_async("sender-avatar.deny-access", None, |obj, _, _| async move {
127                obj.imp().kick().await;
128            });
129
130            klass.install_action_async("sender-avatar.ban", None, |obj, _, _| async move {
131                obj.imp().ban().await;
132            });
133
134            klass.install_action_async("sender-avatar.unban", None, |obj, _, _| async move {
135                obj.imp().unban().await;
136            });
137
138            klass.install_action_async(
139                "sender-avatar.remove-messages",
140                None,
141                |obj, _, _| async move {
142                    obj.imp().remove_messages().await;
143                },
144            );
145
146            klass.install_action_async("sender-avatar.ignore", None, |obj, _, _| async move {
147                obj.imp().toggle_ignored().await;
148            });
149
150            klass.install_action_async(
151                "sender-avatar.stop-ignoring",
152                None,
153                |obj, _, _| async move {
154                    obj.imp().toggle_ignored().await;
155                },
156            );
157
158            klass.install_action("sender-avatar.view-details", None, |obj, _, _| {
159                obj.imp().view_details();
160            });
161
162            klass.install_action("sender-avatar.activate", None, |obj, _, _| {
163                obj.imp().show_popover(1, 0.0, 0.0);
164            });
165
166            key_bindings::add_activate_bindings(klass, "sender-avatar.activate");
167        }
168
169        fn instance_init(obj: &InitializingObject<Self>) {
170            obj.init_template();
171        }
172    }
173
174    #[glib::derived_properties]
175    impl ObjectImpl for SenderAvatar {
176        fn constructed(&self) {
177            self.parent_constructed();
178
179            self.set_pressed_state(false);
180        }
181
182        fn dispose(&self) {
183            self.disconnect_signals();
184
185            if let Some(popover) = self.popover.obj() {
186                popover.unparent();
187                popover.remove_child(&*self.user_id_btn);
188            }
189
190            self.avatar.unparent();
191        }
192    }
193
194    impl WidgetImpl for SenderAvatar {}
195
196    impl AccessibleImpl for SenderAvatar {
197        fn first_accessible_child(&self) -> Option<gtk::Accessible> {
198            // Hide the children in the a11y tree.
199            None
200        }
201    }
202
203    #[gtk::template_callbacks]
204    impl SenderAvatar {
205        /// Set the list of room members.
206        fn set_sender(&self, sender: Option<Member>) {
207            let prev_sender = self.sender.obj();
208
209            if prev_sender == sender {
210                return;
211            }
212
213            self.disconnect_signals();
214
215            if let Some(sender) = sender {
216                let room = sender.room();
217                let direct_member_handler = room.connect_direct_member_notify(clone!(
218                    #[weak(rename_to = imp)]
219                    self,
220                    move |_| {
221                        imp.update_actions();
222                    }
223                ));
224                self.direct_member_handler
225                    .replace(Some(direct_member_handler));
226
227                let permissions_handler = room.permissions().connect_changed(clone!(
228                    #[weak(rename_to = imp)]
229                    self,
230                    move |_| {
231                        imp.update_actions();
232                    }
233                ));
234                self.permissions_handler.replace(Some(permissions_handler));
235
236                let display_name_handler = sender.connect_display_name_notify(clone!(
237                    #[weak(rename_to = imp)]
238                    self,
239                    move |_| {
240                        imp.update_accessible_label();
241                    }
242                ));
243
244                let membership_handler = sender.connect_membership_notify(clone!(
245                    #[weak(rename_to = imp)]
246                    self,
247                    move |_| {
248                        imp.update_actions();
249                    }
250                ));
251
252                let power_level_handler = sender.connect_power_level_notify(clone!(
253                    #[weak(rename_to = imp)]
254                    self,
255                    move |_| {
256                        imp.update_actions();
257                    }
258                ));
259
260                let is_ignored_handler = sender.connect_is_ignored_notify(clone!(
261                    #[weak(rename_to = imp)]
262                    self,
263                    move |_| {
264                        imp.update_actions();
265                    }
266                ));
267
268                self.sender.set(
269                    sender,
270                    vec![
271                        display_name_handler,
272                        membership_handler,
273                        power_level_handler,
274                        is_ignored_handler,
275                    ],
276                );
277                self.update_accessible_label();
278                self.update_actions();
279            }
280
281            self.obj().notify_sender();
282        }
283
284        /// Disconnect all the signals.
285        fn disconnect_signals(&self) {
286            if let Some(sender) = self.sender.obj() {
287                let room = sender.room();
288
289                if let Some(handler) = self.direct_member_handler.take() {
290                    room.disconnect(handler);
291                }
292                if let Some(handler) = self.permissions_handler.take() {
293                    room.permissions().disconnect(handler);
294                }
295            }
296
297            self.sender.disconnect_signals();
298        }
299
300        /// Update the accessible label for the current sender.
301        fn update_accessible_label(&self) {
302            let Some(sender) = self.sender.obj() else {
303                return;
304            };
305
306            let label = gettext_f("{user}’s avatar", &[("user", &sender.display_name())]);
307            self.obj()
308                .update_property(&[gtk::accessible::Property::Label(&label)]);
309        }
310
311        /// Update the actions for the current state.
312        fn update_actions(&self) {
313            let Some(sender) = self.sender.obj() else {
314                return;
315            };
316            let obj = self.obj();
317
318            let room = sender.room();
319            let is_direct_chat = room.direct_member().is_some();
320            let permissions = room.permissions();
321            let membership = sender.membership();
322            let sender_id = sender.user_id();
323            let is_own_user = sender.is_own_user();
324            let power_level = sender.power_level();
325            let role = permissions.role(power_level);
326
327            obj.action_set_enabled(
328                "sender-avatar.mention",
329                !is_own_user && membership == Membership::Join && permissions.can_send_message(),
330            );
331
332            obj.action_set_enabled(
333                "sender-avatar.open-direct-chat",
334                !is_direct_chat && !is_own_user,
335            );
336
337            obj.action_set_enabled(
338                "sender-avatar.invite",
339                !is_own_user
340                    && matches!(membership, Membership::Leave | Membership::Knock)
341                    && permissions.can_do_to_user(sender_id, PowerLevelUserAction::Kick),
342            );
343
344            obj.action_set_enabled(
345                "sender-avatar.revoke-invite",
346                !is_own_user
347                    && membership == Membership::Invite
348                    && permissions.can_do_to_user(sender_id, PowerLevelUserAction::Kick),
349            );
350
351            obj.action_set_enabled(
352                "sender-avatar.mute",
353                !is_own_user
354                    && role != MemberRole::Muted
355                    && permissions.default_power_level() > permissions.mute_power_level()
356                    && permissions
357                        .can_do_to_user(sender_id, PowerLevelUserAction::ChangePowerLevel),
358            );
359
360            obj.action_set_enabled(
361                "sender-avatar.unmute",
362                !is_own_user
363                    && role == MemberRole::Muted
364                    && permissions.default_power_level() > permissions.mute_power_level()
365                    && permissions
366                        .can_do_to_user(sender_id, PowerLevelUserAction::ChangePowerLevel),
367            );
368
369            obj.action_set_enabled(
370                "sender-avatar.kick",
371                !is_own_user
372                    && membership == Membership::Join
373                    && permissions.can_do_to_user(sender_id, PowerLevelUserAction::Kick),
374            );
375
376            obj.action_set_enabled(
377                "sender-avatar.deny-access",
378                !is_own_user
379                    && membership == Membership::Knock
380                    && permissions.can_do_to_user(sender_id, PowerLevelUserAction::Kick),
381            );
382
383            obj.action_set_enabled(
384                "sender-avatar.ban",
385                !is_own_user
386                    && membership != Membership::Ban
387                    && permissions.can_do_to_user(sender_id, PowerLevelUserAction::Ban),
388            );
389
390            obj.action_set_enabled(
391                "sender-avatar.unban",
392                !is_own_user
393                    && membership == Membership::Ban
394                    && permissions.can_do_to_user(sender_id, PowerLevelUserAction::Unban),
395            );
396
397            obj.action_set_enabled(
398                "sender-avatar.remove-messages",
399                !is_own_user && permissions.can_redact_other(),
400            );
401
402            obj.action_set_enabled("sender-avatar.ignore", !is_own_user && !sender.is_ignored());
403
404            obj.action_set_enabled(
405                "sender-avatar.stop-ignoring",
406                !is_own_user && sender.is_ignored(),
407            );
408        }
409
410        /// Set the popover of this avatar.
411        fn set_popover(&self, popover: Option<gtk::PopoverMenu>) {
412            let old_popover = self.popover.obj();
413
414            if old_popover == popover {
415                return;
416            }
417
418            // Reset the state.
419            if let Some(popover) = old_popover {
420                popover.unparent();
421                popover.remove_child(&*self.user_id_btn);
422            }
423            self.popover.disconnect_signals();
424            self.set_active(false);
425
426            if let Some(popover) = popover {
427                // We need to remove the popover from the previous button, if any.
428                if popover.parent().is_some() {
429                    popover.unparent();
430                }
431
432                let parent_handler = popover.connect_parent_notify(clone!(
433                    #[weak(rename_to = imp)]
434                    self,
435                    move |popover| {
436                        if popover.parent().is_none_or(|w| w != *imp.obj()) {
437                            imp.popover.disconnect_signals();
438                            popover.remove_child(&*imp.user_id_btn);
439                        }
440                    }
441                ));
442                let closed_handler = popover.connect_closed(clone!(
443                    #[weak(rename_to = imp)]
444                    self,
445                    move |_| {
446                        imp.set_active(false);
447                    }
448                ));
449
450                popover.add_child(&*self.user_id_btn, "user-id");
451                popover.set_parent(&*self.obj());
452
453                self.popover
454                    .set(popover, vec![parent_handler, closed_handler]);
455            }
456        }
457
458        /// Set whether this avatar is active.
459        fn set_active(&self, active: bool) {
460            if self.active.get() == active {
461                return;
462            }
463
464            self.active.set(active);
465
466            self.obj().notify_active();
467            self.set_pressed_state(active);
468        }
469
470        /// Set the CSS and a11 states.
471        fn set_pressed_state(&self, pressed: bool) {
472            let obj = self.obj();
473
474            if pressed {
475                obj.set_state_flags(gtk::StateFlags::CHECKED, false);
476            } else {
477                obj.unset_state_flags(gtk::StateFlags::CHECKED);
478            }
479
480            let tristate = if pressed {
481                gtk::AccessibleTristate::True
482            } else {
483                gtk::AccessibleTristate::False
484            };
485            obj.update_state(&[gtk::accessible::State::Pressed(tristate)]);
486        }
487
488        /// The `RoomHistory` that is an ancestor of this avatar.
489        fn room_history(&self) -> Option<RoomHistory> {
490            self.obj()
491                .ancestor(RoomHistory::static_type())
492                .and_downcast()
493        }
494
495        /// Handle a click on the container.
496        ///
497        /// Shows a popover with the room member menu.
498        #[template_callback]
499        fn show_popover(&self, _n_press: i32, x: f64, y: f64) {
500            let Some(room_history) = self.room_history() else {
501                return;
502            };
503
504            self.set_active(true);
505
506            let popover = room_history.sender_context_menu();
507            self.set_popover(Some(popover.clone()));
508
509            popover.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 0, 0)));
510            popover.popup();
511        }
512
513        /// Add a mention of the sender to the message composer.
514        fn mention(&self) {
515            let Some(sender) = self.sender.obj() else {
516                return;
517            };
518            let Some(room_history) = self.room_history() else {
519                return;
520            };
521
522            room_history.message_toolbar().mention_member(&sender);
523        }
524
525        /// View the sender details.
526        fn view_details(&self) {
527            let Some(sender) = self.sender.obj() else {
528                return;
529            };
530
531            let dialog = UserProfileDialog::new();
532            dialog.set_room_member(sender);
533            dialog.present(Some(&*self.obj()));
534        }
535
536        /// Open a direct chat with the current sender.
537        ///
538        /// If one doesn't exist already, it is created.
539        async fn open_direct_chat(&self) {
540            let Some(sender) = self.sender.obj().and_upcast::<User>() else {
541                return;
542            };
543            let obj = self.obj();
544
545            let room = if let Some(room) = sender.direct_chat() {
546                room
547            } else {
548                toast!(obj, &gettext("Creating a new Direct Chat…"));
549
550                if let Ok(room) = sender.get_or_create_direct_chat().await {
551                    room
552                } else {
553                    toast!(obj, &gettext("Could not create a new Direct Chat"));
554                    return;
555                }
556            };
557
558            let Some(main_window) = obj.root().and_downcast::<Window>() else {
559                return;
560            };
561
562            main_window.session_view().select_room(room);
563        }
564
565        /// Invite the sender to the room.
566        async fn invite(&self) {
567            let Some(sender) = self.sender.obj() else {
568                return;
569            };
570            let obj = self.obj();
571
572            toast!(obj, gettext("Inviting user…"));
573
574            let room = sender.room();
575            let user_id = sender.user_id().clone();
576            if room.invite(&[user_id]).await.is_err() {
577                toast!(obj, gettext("Could not invite user"));
578            }
579        }
580
581        /// Kick the user from the room.
582        async fn kick(&self) {
583            let Some(sender) = self.sender.obj() else {
584                return;
585            };
586            let obj = self.obj();
587
588            let Some(response) = confirm_room_member_destructive_action_dialog(
589                &sender,
590                RoomMemberDestructiveAction::Kick,
591                &*obj,
592            )
593            .await
594            else {
595                return;
596            };
597
598            let membership = sender.membership();
599
600            let label = match membership {
601                Membership::Invite => gettext("Revoking invite…"),
602                Membership::Knock => gettext("Denying access…"),
603                _ => gettext("Kicking user…"),
604            };
605            toast!(obj, label);
606
607            let room = sender.room();
608            let user_id = sender.user_id().clone();
609            if room.kick(&[(user_id, response.reason)]).await.is_err() {
610                let error = match membership {
611                    Membership::Invite => gettext("Could not revoke invite of user"),
612                    Membership::Knock => gettext("Could not deny access to user"),
613                    _ => gettext("Could not kick user"),
614                };
615                toast!(obj, error);
616            }
617        }
618
619        /// (Un)mute the user in the room.
620        async fn toggle_muted(&self) {
621            let Some(sender) = self.sender.obj() else {
622                return;
623            };
624            let obj = self.obj();
625
626            let old_power_level = sender.power_level();
627            let permissions = sender.room().permissions();
628
629            // Warn if user is muted but was not before.
630            let mute_power_level = permissions.mute_power_level();
631            let mute = old_power_level > mute_power_level;
632            if mute && !confirm_mute_room_member_dialog(&sender, &*obj).await {
633                return;
634            }
635
636            let user_id = sender.user_id().clone();
637
638            let (new_power_level, text) = if mute {
639                (mute_power_level, gettext("Muting member…"))
640            } else {
641                (
642                    permissions.default_power_level(),
643                    gettext("Unmuting member…"),
644                )
645            };
646            toast!(obj, text);
647
648            let text = if permissions
649                .set_user_power_level(user_id, new_power_level)
650                .await
651                .is_ok()
652            {
653                if mute {
654                    gettext("Member muted")
655                } else {
656                    gettext("Member unmuted")
657                }
658            } else if mute {
659                gettext("Could not mute member")
660            } else {
661                gettext("Could not unmute member")
662            };
663            toast!(obj, text);
664        }
665
666        /// Ban the room member.
667        async fn ban(&self) {
668            let Some(sender) = self.sender.obj() else {
669                return;
670            };
671            let obj = self.obj();
672
673            let permissions = sender.room().permissions();
674            let redactable_events = if permissions.can_redact_other() {
675                sender.redactable_events()
676            } else {
677                vec![]
678            };
679
680            let Some(response) = confirm_room_member_destructive_action_dialog(
681                &sender,
682                RoomMemberDestructiveAction::Ban(redactable_events.len()),
683                &*obj,
684            )
685            .await
686            else {
687                return;
688            };
689
690            toast!(obj, gettext("Banning user…"));
691
692            let room = sender.room();
693            let user_id = sender.user_id().clone();
694            if room
695                .ban(&[(user_id, response.reason.clone())])
696                .await
697                .is_err()
698            {
699                toast!(obj, gettext("Could not ban user"));
700            }
701
702            if response.remove_events {
703                self.remove_known_messages_inner(&sender, redactable_events, response.reason)
704                    .await;
705            }
706        }
707
708        /// Unban the room member.
709        async fn unban(&self) {
710            let Some(sender) = self.sender.obj() else {
711                return;
712            };
713            let obj = self.obj();
714
715            toast!(obj, gettext("Unbanning user…"));
716
717            let room = sender.room();
718            let user_id = sender.user_id().clone();
719            if room.unban(&[(user_id, None)]).await.is_err() {
720                toast!(obj, gettext("Could not unban user"));
721            }
722        }
723
724        /// Remove the known events of the room member.
725        async fn remove_messages(&self) {
726            let Some(sender) = self.sender.obj() else {
727                return;
728            };
729
730            let redactable_events = sender.redactable_events();
731
732            let Some(response) = confirm_room_member_destructive_action_dialog(
733                &sender,
734                RoomMemberDestructiveAction::RemoveMessages(redactable_events.len()),
735                &*self.obj(),
736            )
737            .await
738            else {
739                return;
740            };
741
742            self.remove_known_messages_inner(&sender, redactable_events, response.reason)
743                .await;
744        }
745
746        async fn remove_known_messages_inner(
747            &self,
748            sender: &Member,
749            events: Vec<OwnedEventId>,
750            reason: Option<String>,
751        ) {
752            let obj = self.obj();
753            let n = u32::try_from(events.len()).unwrap_or(u32::MAX);
754            toast!(
755                obj,
756                ngettext(
757                    // Translators: Do NOT translate the content between '{' and '}',
758                    // this is a variable name.
759                    "Removing 1 message sent by the user…",
760                    "Removing {n} messages sent by the user…",
761                    n,
762                ),
763                n,
764            );
765
766            let room = sender.room();
767
768            if let Err(failed_events) = room.redact(&events, reason).await {
769                let n = u32::try_from(failed_events.len()).unwrap_or(u32::MAX);
770                toast!(
771                    obj,
772                    ngettext(
773                        // Translators: Do NOT translate the content between '{' and '}',
774                        // this is a variable name.
775                        "Could not remove 1 message sent by the user",
776                        "Could not remove {n} messages sent by the user",
777                        n,
778                    ),
779                    n,
780                );
781            }
782        }
783
784        /// Toggle whether the user is ignored or not.
785        async fn toggle_ignored(&self) {
786            let Some(sender) = self.sender.obj().and_upcast::<User>() else {
787                return;
788            };
789            let obj = self.obj();
790            let is_ignored = sender.is_ignored();
791
792            let label = if is_ignored {
793                gettext("Stop ignoring user…")
794            } else {
795                gettext("Ignoring user…")
796            };
797            toast!(obj, label);
798
799            if is_ignored {
800                if sender.stop_ignoring().await.is_err() {
801                    toast!(obj, gettext("Could not stop ignoring user"));
802                }
803            } else if sender.ignore().await.is_err() {
804                toast!(obj, gettext("Could not ignore user"));
805            }
806        }
807    }
808}
809
810glib::wrapper! {
811    /// An avatar with a popover menu for room members.
812    pub struct SenderAvatar(ObjectSubclass<imp::SenderAvatar>)
813        @extends gtk::Widget, @implements gtk::Accessible;
814}
815
816impl SenderAvatar {
817    pub fn new() -> Self {
818        glib::Object::new()
819    }
820}