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

1use adw::subclass::prelude::*;
2use gtk::{gdk, gio, glib, glib::clone, prelude::*, CompositeTemplate};
3
4mod read_receipts_popover;
5
6use self::read_receipts_popover::ReadReceiptsPopover;
7use super::member_timestamp::MemberTimestamp;
8use crate::{
9    components::OverlappingAvatars,
10    i18n::{gettext_f, ngettext_f},
11    prelude::*,
12    session::model::{Member, MemberList, UserReadReceipt},
13    utils::{key_bindings, BoundObjectWeakRef},
14};
15
16// Keep in sync with the `max-avatars` property of the `avatar_list` in the
17// UI file.
18const MAX_RECEIPTS_SHOWN: u32 = 5;
19
20mod imp {
21    use std::cell::{Cell, RefCell};
22
23    use glib::subclass::InitializingObject;
24
25    use super::*;
26
27    #[derive(Debug, CompositeTemplate, glib::Properties)]
28    #[template(
29        resource = "/org/gnome/Fractal/ui/session/view/content/room_history/read_receipts_list/mod.ui"
30    )]
31    #[properties(wrapper_type = super::ReadReceiptsList)]
32    pub struct ReadReceiptsList {
33        #[template_child]
34        content: TemplateChild<gtk::Box>,
35        #[template_child]
36        label: TemplateChild<gtk::Label>,
37        #[template_child]
38        avatar_list: TemplateChild<OverlappingAvatars>,
39        /// Whether this list is active.
40        ///
41        /// This list is active when the popover is displayed.
42        #[property(get)]
43        active: Cell<bool>,
44        /// The list of room members.
45        #[property(get, set = Self::set_members, explicit_notify, nullable)]
46        members: RefCell<Option<MemberList>>,
47        /// The list of read receipts.
48        #[property(get)]
49        list: gio::ListStore,
50        /// The read receipts used as a source.
51        #[property(get, set = Self::set_source, explicit_notify)]
52        source: BoundObjectWeakRef<gio::ListModel>,
53        /// The displayed member if there is only one receipt.
54        receipt_member: BoundObjectWeakRef<Member>,
55    }
56
57    impl Default for ReadReceiptsList {
58        fn default() -> Self {
59            Self {
60                content: Default::default(),
61                label: Default::default(),
62                avatar_list: Default::default(),
63                active: Default::default(),
64                members: Default::default(),
65                list: gio::ListStore::new::<MemberTimestamp>(),
66                source: Default::default(),
67                receipt_member: Default::default(),
68            }
69        }
70    }
71
72    #[glib::object_subclass]
73    impl ObjectSubclass for ReadReceiptsList {
74        const NAME: &'static str = "ContentReadReceiptsList";
75        type Type = super::ReadReceiptsList;
76        type ParentType = gtk::Widget;
77
78        fn class_init(klass: &mut Self::Class) {
79            klass.set_layout_manager_type::<gtk::BinLayout>();
80
81            Self::bind_template(klass);
82            Self::bind_template_callbacks(klass);
83
84            klass.set_css_name("read-receipts-list");
85            klass.set_accessible_role(gtk::AccessibleRole::ToggleButton);
86
87            klass.install_action("read-receipts-list.activate", None, |obj, _, _| {
88                obj.imp().show_popover(1, 0.0, 0.0);
89            });
90
91            key_bindings::add_activate_bindings(klass, "read-receipts-list.activate");
92        }
93
94        fn instance_init(obj: &InitializingObject<Self>) {
95            obj.init_template();
96        }
97    }
98
99    #[glib::derived_properties]
100    impl ObjectImpl for ReadReceiptsList {
101        fn constructed(&self) {
102            self.parent_constructed();
103
104            self.avatar_list.bind_model(Some(&self.list), |item| {
105                item.downcast_ref::<MemberTimestamp>()
106                    .and_then(MemberTimestamp::member)
107                    .expect("item should be a member timestamp with a member")
108                    .avatar_data()
109            });
110
111            self.list.connect_items_changed(clone!(
112                #[weak(rename_to = imp)]
113                self,
114                move |_, _, _, _| {
115                    imp.update_tooltip();
116                    imp.update_label();
117                }
118            ));
119
120            self.set_pressed_state(false);
121        }
122
123        fn dispose(&self) {
124            self.content.unparent();
125        }
126    }
127
128    impl WidgetImpl for ReadReceiptsList {}
129
130    impl AccessibleImpl for ReadReceiptsList {
131        fn first_accessible_child(&self) -> Option<gtk::Accessible> {
132            // Hide the children in the a11y tree.
133            None
134        }
135    }
136
137    #[gtk::template_callbacks]
138    impl ReadReceiptsList {
139        /// Set the list of room members.
140        fn set_members(&self, members: Option<MemberList>) {
141            if *self.members.borrow() == members {
142                return;
143            }
144
145            self.members.replace(members);
146            self.obj().notify_members();
147
148            if let Some(source) = self.source.obj() {
149                self.items_changed(&source, 0, self.list.n_items(), source.n_items());
150            }
151        }
152
153        /// Set whether this list is active.
154        fn set_active(&self, active: bool) {
155            if self.active.get() == active {
156                return;
157            }
158
159            self.active.set(active);
160
161            self.obj().notify_active();
162            self.set_pressed_state(active);
163        }
164
165        /// Set the read receipts that are used as a source of data.
166        fn set_source(&self, source: &gio::ListModel) {
167            if self.source.obj().as_ref() == Some(source) {
168                return;
169            }
170
171            let items_changed_handler_id = source.connect_items_changed(clone!(
172                #[weak(rename_to = imp)]
173                self,
174                move |source, pos, removed, added| {
175                    imp.items_changed(source, pos, removed, added);
176                }
177            ));
178            self.items_changed(source, 0, self.list.n_items(), source.n_items());
179
180            self.source.set(source, vec![items_changed_handler_id]);
181            self.obj().notify_source();
182        }
183
184        /// Set the CSS and a11y states.
185        fn set_pressed_state(&self, pressed: bool) {
186            let obj = self.obj();
187
188            if pressed {
189                obj.set_state_flags(gtk::StateFlags::CHECKED, false);
190            } else {
191                obj.unset_state_flags(gtk::StateFlags::CHECKED);
192            }
193
194            let tristate = if pressed {
195                gtk::AccessibleTristate::True
196            } else {
197                gtk::AccessibleTristate::False
198            };
199            obj.update_state(&[gtk::accessible::State::Pressed(tristate)]);
200        }
201
202        /// Handle when items changed in the source.
203        fn items_changed(&self, source: &gio::ListModel, pos: u32, removed: u32, added: u32) {
204            let Some(members) = &*self.members.borrow() else {
205                return;
206            };
207
208            let mut new_receipts = Vec::with_capacity(added as usize);
209
210            for i in pos..pos + added {
211                let Some(boxed) = source.item(i).and_downcast::<glib::BoxedAnyObject>() else {
212                    break;
213                };
214
215                let source_receipt = boxed.borrow::<UserReadReceipt>();
216                let member = members.get_or_create(source_receipt.user_id.clone());
217                let receipt = MemberTimestamp::new(
218                    &member,
219                    source_receipt.receipt.ts.map(|ts| ts.as_secs().into()),
220                );
221
222                new_receipts.push(receipt);
223            }
224
225            self.list.splice(pos, removed, &new_receipts);
226        }
227
228        /// Update the tooltip of this list.
229        fn update_tooltip(&self) {
230            self.receipt_member.disconnect_signals();
231            let n_items = self.list.n_items();
232
233            if n_items == 1 {
234                if let Some(member) = self
235                    .list
236                    .item(0)
237                    .and_downcast::<MemberTimestamp>()
238                    .and_then(|r| r.member())
239                {
240                    // Listen to changes of the display name.
241                    let handler_id = member.connect_display_name_notify(clone!(
242                        #[weak(rename_to = imp)]
243                        self,
244                        move |member| {
245                            imp.update_member_tooltip(member);
246                        }
247                    ));
248
249                    self.receipt_member.set(&member, vec![handler_id]);
250                    self.update_member_tooltip(&member);
251                    return;
252                }
253            }
254
255            let text = (n_items > 0).then(|| {
256                ngettext_f(
257                    // Translators: Do NOT translate the content between '{' and '}', this is a
258                    // variable name.
259                    "Seen by 1 member",
260                    "Seen by {n} members",
261                    n_items,
262                    &[("n", &n_items.to_string())],
263                )
264            });
265
266            self.obj().set_tooltip_text(text.as_deref());
267        }
268
269        /// Update the tooltip of this list for a single member.
270        fn update_member_tooltip(&self, member: &Member) {
271            // Translators: Do NOT translate the content between '{' and '}', this is a
272            // variable name.
273            let text = gettext_f("Seen by {name}", &[("name", &member.disambiguated_name())]);
274
275            self.obj().set_tooltip_text(Some(&text));
276        }
277
278        /// Update the label of this list.
279        fn update_label(&self) {
280            let n_items = self.list.n_items();
281
282            if n_items > MAX_RECEIPTS_SHOWN {
283                self.label
284                    .set_text(&format!("{} +", n_items - MAX_RECEIPTS_SHOWN));
285                self.label.set_visible(true);
286            } else {
287                self.label.set_visible(false);
288            }
289        }
290
291        /// Handle a click on the container.
292        ///
293        /// Shows a popover with the list of receipts if there are any.
294        #[template_callback]
295        fn show_popover(&self, _n_press: i32, x: f64, y: f64) {
296            if self.list.n_items() == 0 {
297                // No popover.
298                return;
299            }
300            self.set_active(true);
301
302            let popover = ReadReceiptsPopover::new(&self.list);
303            popover.set_parent(&*self.obj());
304            popover.set_pointing_to(Some(&gdk::Rectangle::new(x as i32, y as i32, 0, 0)));
305            popover.connect_closed(clone!(
306                #[weak(rename_to = imp)]
307                self,
308                move |popover| {
309                    popover.unparent();
310                    imp.set_active(false);
311                }
312            ));
313
314            popover.popup();
315        }
316    }
317}
318
319glib::wrapper! {
320    /// A widget displaying the read receipts on a message.
321    pub struct ReadReceiptsList(ObjectSubclass<imp::ReadReceiptsList>)
322        @extends gtk::Widget, @implements gtk::Accessible;
323}
324
325impl ReadReceiptsList {
326    pub fn new(members: &MemberList) -> Self {
327        glib::Object::builder().property("members", members).build()
328    }
329}