fractal/session/view/content/room_details/members_page/members_list_view/
mod.rs1use adw::{prelude::*, subclass::prelude::*};
2use gettextrs::{gettext, ngettext};
3use gtk::{
4 CompositeTemplate, gio, glib,
5 glib::{clone, closure},
6};
7
8mod item_row;
9mod membership_subpage_row;
10
11use self::{item_row::ItemRow, membership_subpage_row::MembershipSubpageRow};
12use crate::{
13 components::LoadingRow,
14 prelude::*,
15 session::{
16 model::{Member, MemberList, MembershipListKind, Room},
17 view::content::room_details::MembershipSubpageItem,
18 },
19 utils::{BoundObjectWeakRef, ExpressionListModel, LoadingState, expression},
20};
21
22mod imp {
23 use std::{
24 cell::{Cell, OnceCell, RefCell},
25 collections::HashMap,
26 };
27
28 use glib::subclass::InitializingObject;
29
30 use super::*;
31
32 #[derive(Debug, Default, CompositeTemplate, glib::Properties)]
33 #[template(
34 resource = "/org/gnome/Fractal/ui/session/view/content/room_details/members_page/members_list_view/mod.ui"
35 )]
36 #[properties(wrapper_type = super::MembersListView)]
37 pub struct MembersListView {
38 #[template_child]
39 search_button: TemplateChild<gtk::ToggleButton>,
40 #[template_child]
41 search_bar: TemplateChild<gtk::SearchBar>,
42 #[template_child]
43 search_entry: TemplateChild<gtk::SearchEntry>,
44 #[template_child]
45 stack: TemplateChild<gtk::Stack>,
46 #[template_child]
47 empty_stack_page: TemplateChild<gtk::StackPage>,
48 #[template_child]
49 empty_page: TemplateChild<adw::StatusPage>,
50 #[template_child]
51 empty_listbox: TemplateChild<gtk::ListBox>,
52 #[template_child]
53 members_stack_page: TemplateChild<gtk::StackPage>,
54 #[template_child]
55 list_view: TemplateChild<gtk::ListView>,
56 #[property(get, set = Self::set_room, construct_only)]
58 room: glib::WeakRef<Room>,
59 #[property(get, set = Self::set_members, construct_only)]
61 members: BoundObjectWeakRef<MemberList>,
62 extra_items: OnceCell<gtk::FilterListModel>,
64 filtered_model: gtk::FilterListModel,
66 #[property(get, set = Self::set_kind, construct_only, builder(MembershipListKind::default()))]
68 kind: Cell<MembershipListKind>,
69 #[property(get, set = Self::set_can_invite, explicit_notify)]
71 can_invite: Cell<bool>,
72 extra_members_state_handler: RefCell<Option<glib::SignalHandlerId>>,
73 membership_items_changed_handlers:
74 RefCell<HashMap<MembershipListKind, glib::SignalHandlerId>>,
75 }
76
77 #[glib::object_subclass]
78 impl ObjectSubclass for MembersListView {
79 const NAME: &'static str = "ContentMembersListView";
80 type Type = super::MembersListView;
81 type ParentType = adw::NavigationPage;
82
83 fn class_init(klass: &mut Self::Class) {
84 ItemRow::ensure_type();
85
86 Self::bind_template(klass);
87 Self::bind_template_callbacks(klass);
88
89 klass.set_css_name("members-list");
90 }
91
92 fn instance_init(obj: &InitializingObject<Self>) {
93 obj.init_template();
94 }
95 }
96
97 #[glib::derived_properties]
98 impl ObjectImpl for MembersListView {
99 fn constructed(&self) {
100 self.parent_constructed();
101
102 self.search_bar.connect_entry(&*self.search_entry);
105
106 let member_expr = gtk::ClosureExpression::new::<String>(
107 &[] as &[gtk::Expression],
108 closure!(|item: Option<glib::Object>| {
109 item.and_downcast_ref()
110 .map(Member::search_string)
111 .unwrap_or_default()
112 }),
113 );
114 let search_filter = gtk::StringFilter::builder()
115 .match_mode(gtk::StringFilterMatchMode::Substring)
116 .expression(expression::normalize_string(member_expr))
117 .ignore_case(true)
118 .build();
119
120 expression::normalize_string(self.search_entry.property_expression("text")).bind(
121 &search_filter,
122 "search",
123 None::<&glib::Object>,
124 );
125
126 self.filtered_model.set_filter(Some(&search_filter));
127 self.list_view.set_model(Some(>k::NoSelection::new(Some(
128 self.filtered_model.clone(),
129 ))));
130
131 self.init_members_list();
132 }
133
134 fn dispose(&self) {
135 if let Some(members) = self.members.obj() {
136 if let Some(handler) = self.extra_members_state_handler.take() {
137 members.disconnect(handler);
138 }
139
140 for (kind, handler) in self.membership_items_changed_handlers.take() {
141 members.membership_list(kind).disconnect(handler);
142 }
143 }
144 }
145 }
146
147 impl WidgetImpl for MembersListView {}
148 impl NavigationPageImpl for MembersListView {}
149
150 #[gtk::template_callbacks]
151 impl MembersListView {
152 fn set_room(&self, room: &Room) {
154 self.room.set(Some(room));
155
156 let can_invite_expr = room.permissions().property_expression("can-invite");
158 let is_direct_expr = room.property_expression("is-direct");
159 expression::and(can_invite_expr, expression::not(is_direct_expr)).bind(
160 &*self.obj(),
161 "can-invite",
162 None::<&glib::Object>,
163 );
164 }
165
166 fn set_members(&self, members: &MemberList) {
168 let state_handler = members.connect_state_notify(clone!(
169 #[weak(rename_to = imp)]
170 self,
171 move |_| {
172 imp.update_view();
173 }
174 ));
175
176 self.members.set(members, vec![state_handler]);
177 }
178
179 fn set_kind(&self, kind: MembershipListKind) {
181 self.kind.set(kind);
182 self.obj().set_tag(Some(kind.as_ref()));
183 self.update_empty_page();
184 }
185
186 fn set_can_invite(&self, can_invite: bool) {
188 if self.can_invite.get() == can_invite {
189 return;
190 }
191
192 self.can_invite.set(can_invite);
193 self.obj().notify_can_invite();
194 }
195
196 fn init_members_list(&self) {
198 let Some(members) = self.members.obj() else {
199 return;
200 };
201
202 self.init_extra_items();
203
204 let kind = self.kind.get();
205 let membership_list = members.membership_list(kind);
206
207 let items_changed_handler = membership_list.connect_items_changed(clone!(
208 #[weak(rename_to = imp)]
209 self,
210 move |_, _, _, _| {
211 imp.update_view();
212 }
213 ));
214 self.membership_items_changed_handlers
215 .borrow_mut()
216 .insert(kind, items_changed_handler);
217
218 let power_level_expr = Member::this_expression("power-level");
220 let sorter = gtk::MultiSorter::new();
221 sorter.append(
222 gtk::NumericSorter::builder()
223 .expression(&power_level_expr)
224 .sort_order(gtk::SortType::Descending)
225 .build(),
226 );
227
228 let display_name_expr = Member::this_expression("display-name");
229 sorter.append(gtk::StringSorter::new(Some(&display_name_expr)));
230
231 let expr_members = ExpressionListModel::new();
234 expr_members
235 .set_expressions(vec![power_level_expr.upcast(), display_name_expr.upcast()]);
236 expr_members.set_model(Some(membership_list));
237
238 let sorted_members = gtk::SortListModel::new(Some(expr_members), Some(sorter));
239
240 let full_model = if let Some(extra_items) = self.extra_items.get() {
241 let model_list = gio::ListStore::new::<gio::ListModel>();
242 model_list.append(extra_items);
243 model_list.append(&sorted_members);
244
245 gtk::FlattenListModel::new(Some(model_list)).upcast::<gio::ListModel>()
246 } else {
247 sorted_members.upcast()
248 };
249 self.filtered_model.set_model(Some(&full_model));
250
251 self.update_view();
252 self.update_empty_listbox();
253 }
254
255 fn init_extra_items(&self) {
257 let Some(members) = self.members.obj() else {
258 return;
259 };
260
261 if self.kind.get() != MembershipListKind::Join {
263 return;
264 }
265
266 let filter = gtk::CustomFilter::new(|item| {
267 if let Some(loading_row) = item.downcast_ref::<LoadingRow>() {
268 loading_row.is_visible()
269 } else if let Some(subpage_item) = item.downcast_ref::<MembershipSubpageItem>() {
270 subpage_item.model().n_items() != 0
271 } else {
272 false
273 }
274 });
275
276 let loading_row = LoadingRow::new();
277 let extra_members_state_handler = members.connect_state_notify(clone!(
278 #[weak]
279 loading_row,
280 #[weak]
281 filter,
282 move |members| {
283 let was_row_visible = loading_row.is_visible();
284
285 Self::update_loading_row(&loading_row, members.state());
286
287 if loading_row.is_visible() != was_row_visible {
289 filter.changed(gtk::FilterChange::Different);
290 }
291 }
292 ));
293 self.extra_members_state_handler
294 .replace(Some(extra_members_state_handler));
295 Self::update_loading_row(&loading_row, members.state());
296
297 let base_model = gio::ListStore::new::<glib::Object>();
298 base_model.append(&loading_row);
299
300 for &kind in &[
301 MembershipListKind::Knock,
302 MembershipListKind::Invite,
303 MembershipListKind::Ban,
304 ] {
305 let list = members.membership_list(kind);
306 let items_changed_handler = list.connect_items_changed(clone!(
307 #[weak]
308 filter,
309 move |list, _, _, added| {
310 let n_items = list.n_items();
311
312 if n_items == 0 || n_items == added {
314 filter.changed(gtk::FilterChange::Different);
315 }
316 }
317 ));
318 self.membership_items_changed_handlers
319 .borrow_mut()
320 .insert(kind, items_changed_handler);
321
322 base_model.append(&MembershipSubpageItem::new(kind, &list));
323 }
324
325 let extra_items = self
326 .extra_items
327 .get_or_init(|| gtk::FilterListModel::new(Some(base_model), Some(filter)));
328
329 extra_items.connect_items_changed(clone!(
330 #[weak(rename_to = imp)]
331 self,
332 move |_, _, _, _| {
333 imp.update_empty_listbox();
334 }
335 ));
336 }
337
338 fn update_loading_row(loading_row: &LoadingRow, state: LoadingState) {
340 let error = (state == LoadingState::Error)
341 .then(|| gettext("Could not load the full list of room members"));
342 loading_row.set_error(error.as_deref());
343
344 loading_row.set_visible(state != LoadingState::Ready);
345 }
346
347 fn update_view(&self) {
349 let Some(members) = self.members.obj() else {
350 self.stack.set_visible_child_name("no-members");
351 return;
352 };
353
354 let kind = self.kind.get();
355 let membership_list = members.membership_list(kind);
356 let count = membership_list.n_items();
357 let is_empty = count == 0;
358
359 let title = match kind {
360 MembershipListKind::Join => ngettext("Room Member", "Room Members", count),
361 MembershipListKind::Invite => {
362 ngettext("Invited Room Member", "Invited Room Members", count)
363 }
364 MembershipListKind::Ban => {
365 ngettext("Banned Room Member", "Banned Room Members", count)
366 }
367 MembershipListKind::Knock => ngettext("Invite Request", "Invite Requests", count),
368 };
369
370 self.obj().set_title(&title);
371 self.members_stack_page.set_title(&title);
372
373 let (visible_page, extra_items) = if is_empty {
374 match members.state() {
375 LoadingState::Initial | LoadingState::Loading => ("loading", None),
376 LoadingState::Error => ("error", None),
377 LoadingState::Ready => ("empty", self.extra_items.get()),
378 }
379 } else {
380 ("members", None)
381 };
382
383 self.empty_listbox.bind_model(extra_items, |item| {
384 let row = MembershipSubpageRow::new();
385 row.set_item(item.downcast_ref::<MembershipSubpageItem>().cloned());
386
387 row.upcast()
388 });
389
390 self.search_button.set_visible(!is_empty);
393 self.search_bar.set_visible(!is_empty);
394
395 self.stack.set_visible_child_name(visible_page);
396 }
397
398 fn update_empty_page(&self) {
400 let kind = self.kind.get();
401
402 let (title, description) = match kind {
403 MembershipListKind::Join => {
404 let title = gettext("No Room Members");
405 let description = gettext("There are no members in this room");
406 (title, description)
407 }
408 MembershipListKind::Invite => {
409 let title = gettext("No Invited Room Members");
410 let description = gettext("There are no invited members in this room");
411 (title, description)
412 }
413 MembershipListKind::Ban => {
414 let title = gettext("No Banned Room Members");
415 let description = gettext("There are no banned members in this room");
416 (title, description)
417 }
418 MembershipListKind::Knock => {
419 let title = gettext("No Invite Requests");
420 let description = gettext("There are no invite requests in this room");
421 (title, description)
422 }
423 };
424
425 self.empty_stack_page.set_title(&title);
426 self.empty_page.set_title(&title);
427 self.empty_page.set_description(Some(&description));
428 self.empty_page.set_icon_name(Some(kind.icon_name()));
429 }
430
431 fn update_empty_listbox(&self) {
433 let has_extra_items = self
434 .extra_items
435 .get()
436 .is_some_and(|model| model.n_items() > 0);
437 self.empty_listbox.set_visible(has_extra_items);
438 }
439
440 #[template_callback]
442 fn activate_listview_row(&self, pos: u32) {
443 let Some(item) = self.filtered_model.item(pos) else {
444 return;
445 };
446 let obj = self.obj();
447
448 if let Some(member) = item.downcast_ref::<Member>() {
449 obj.activate_action(
450 "details.show-member",
451 Some(&member.user_id().as_str().to_variant()),
452 )
453 .expect("action exists");
454 } else if let Some(item) = item.downcast_ref::<MembershipSubpageItem>() {
455 obj.activate_action(
456 "members.show-membership-list",
457 Some(&item.kind().to_variant()),
458 )
459 .expect("action exists");
460 }
461 }
462
463 #[template_callback]
465 fn activate_listbox_row(&self, row: >k::ListBoxRow) {
466 let row = row
467 .downcast_ref::<MembershipSubpageRow>()
468 .expect("list box contains only membership subpage rows");
469
470 let Some(item) = row.item() else {
471 return;
472 };
473
474 self.obj()
475 .activate_action(
476 "members.show-membership-list",
477 Some(&item.kind().to_variant()),
478 )
479 .expect("action exists");
480 }
481
482 #[template_callback]
484 fn reload_members(&self) {
485 let Some(members) = self.members.obj() else {
486 return;
487 };
488
489 members.reload();
490 }
491 }
492}
493
494glib::wrapper! {
495 pub struct MembersListView(ObjectSubclass<imp::MembersListView>)
497 @extends gtk::Widget, adw::NavigationPage;
498}
499
500impl MembersListView {
501 pub fn new(room: &Room, members: &MemberList, kind: MembershipListKind) -> Self {
504 glib::Object::builder()
505 .property("room", room)
506 .property("members", members)
507 .property("kind", kind)
508 .build()
509 }
510}