fractal/session/view/content/room_details/members_page/members_list_view/
mod.rs

1use 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        /// The room containing the members to present.
57        #[property(get, set = Self::set_room, construct_only)]
58        room: glib::WeakRef<Room>,
59        /// The lists of members for the room.
60        #[property(get, set = Self::set_members, construct_only)]
61        members: BoundObjectWeakRef<MemberList>,
62        /// The items to add to the membership list, if any.
63        extra_items: OnceCell<gtk::FilterListModel>,
64        /// The model with the search filter.
65        filtered_model: gtk::FilterListModel,
66        /// The kind of the membership list.
67        #[property(get, set = Self::set_kind, construct_only, builder(MembershipListKind::default()))]
68        kind: Cell<MembershipListKind>,
69        /// Whether our own user can send an invite in the current room.
70        #[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            // Needed because the GtkSearchEntry is not the direct child of the
103            // GtkSearchBear.
104            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(&gtk::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        /// Set the room containing the members to present.
153        fn set_room(&self, room: &Room) {
154            self.room.set(Some(room));
155
156            // Show the invite button when we can invite but it is not a direct room.
157            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        /// Set the room containing the members to present.
167        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        /// Set the kind of the membership list.
180        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        /// Set whether our own user can send an invite in the current room.
187        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        /// Initialize the members list used for this view.
197        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            // Sort the members list by power level, then display name.
219            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            // We need to notify when a watched property changes so the sorter can update
232            // the list.
233            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        /// Initialize the items to add to the membership list, if necessary.
256        fn init_extra_items(&self) {
257            let Some(members) = self.members.obj() else {
258                return;
259            };
260
261            // Only the list of joined members displays extra items.
262            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 the loading row visibility changed, so does the filtering.
288                    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 the list is or was empty, the filtering changed.
313                        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        /// Update the given loading row for the given loading state.
339        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        /// Update the view for the current state.
348        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            // Hide the search button and bar if the list is empty, since there is no search
391            // possible.
392            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        /// Update the "empty" page for the current state.
399        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        /// Update the `GtkListBox` of the "empty" page for the current state.
432        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        /// Activate the row of the members `GtkListView` at the given position.
441        #[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        /// Activate the given row from the `GtkListBox`.
464        #[template_callback]
465        fn activate_listbox_row(&self, row: &gtk::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        /// Reload the list of members of the room.
483        #[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    /// A page to display a list of members.
496    pub struct MembersListView(ObjectSubclass<imp::MembersListView>)
497        @extends gtk::Widget, adw::NavigationPage;
498}
499
500impl MembersListView {
501    /// Construct a new `MembersListView` with the given room, members list and
502    /// kind.
503    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}