fractal/session/view/content/room_history/read_receipts_list/
mod.rs1use 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
16const 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 #[property(get)]
43 active: Cell<bool>,
44 #[property(get, set = Self::set_members, explicit_notify, nullable)]
46 members: RefCell<Option<MemberList>>,
47 #[property(get)]
49 list: gio::ListStore,
50 #[property(get, set = Self::set_source, explicit_notify)]
52 source: BoundObjectWeakRef<gio::ListModel>,
53 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 None
134 }
135 }
136
137 #[gtk::template_callbacks]
138 impl ReadReceiptsList {
139 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 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 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 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 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 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 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 "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 fn update_member_tooltip(&self, member: &Member) {
271 let text = gettext_f("Seen by {name}", &[("name", &member.disambiguated_name())]);
274
275 self.obj().set_tooltip_text(Some(&text));
276 }
277
278 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 #[template_callback]
295 fn show_popover(&self, _n_press: i32, x: f64, y: f64) {
296 if self.list.n_items() == 0 {
297 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 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}