fractal/session/view/content/room_details/invite_subpage/
list.rs

1use gettextrs::gettext;
2use gtk::{
3    gio, glib,
4    glib::{clone, closure_local},
5    prelude::*,
6    subclass::prelude::*,
7};
8use matrix_sdk::ruma::{
9    api::client::user_directory::search_users::v3::User as SearchUser, OwnedUserId, UserId,
10};
11use tracing::error;
12
13use super::InviteItem;
14use crate::{
15    prelude::*,
16    session::model::{Member, Membership, Room, User},
17    spawn, spawn_tokio,
18};
19
20#[derive(Debug, Default, Eq, PartialEq, Clone, Copy, glib::Enum)]
21#[enum_type(name = "RoomDetailsInviteListState")]
22pub enum InviteListState {
23    #[default]
24    Initial,
25    Loading,
26    NoMatching,
27    Matching,
28    Error,
29}
30
31mod imp {
32    use std::{
33        cell::{Cell, OnceCell, RefCell},
34        collections::HashMap,
35        marker::PhantomData,
36        sync::LazyLock,
37    };
38
39    use glib::subclass::Signal;
40
41    use super::*;
42
43    #[derive(Debug, Default, glib::Properties)]
44    #[properties(wrapper_type = super::InviteList)]
45    pub struct InviteList {
46        list: RefCell<Vec<InviteItem>>,
47        /// The room this invitee list refers to.
48        #[property(get, construct_only)]
49        room: OnceCell<Room>,
50        /// The state of the list.
51        #[property(get, builder(InviteListState::default()))]
52        state: Cell<InviteListState>,
53        /// The search term.
54        #[property(get, set = Self::set_search_term, explicit_notify)]
55        search_term: RefCell<Option<String>>,
56        pub(super) invitee_list: RefCell<HashMap<OwnedUserId, InviteItem>>,
57        abort_handle: RefCell<Option<tokio::task::AbortHandle>>,
58        /// Whether some users are invited.
59        #[property(get = Self::has_invitees)]
60        has_invitees: PhantomData<bool>,
61    }
62
63    #[glib::object_subclass]
64    impl ObjectSubclass for InviteList {
65        const NAME: &'static str = "RoomDetailsInviteList";
66        type Type = super::InviteList;
67        type Interfaces = (gio::ListModel,);
68    }
69
70    #[glib::derived_properties]
71    impl ObjectImpl for InviteList {
72        fn signals() -> &'static [Signal] {
73            static SIGNALS: LazyLock<Vec<Signal>> = LazyLock::new(|| {
74                vec![
75                    Signal::builder("invitee-added")
76                        .param_types([InviteItem::static_type()])
77                        .build(),
78                    Signal::builder("invitee-removed")
79                        .param_types([InviteItem::static_type()])
80                        .build(),
81                ]
82            });
83            SIGNALS.as_ref()
84        }
85    }
86
87    impl ListModelImpl for InviteList {
88        fn item_type(&self) -> glib::Type {
89            InviteItem::static_type()
90        }
91
92        fn n_items(&self) -> u32 {
93            self.list.borrow().len() as u32
94        }
95
96        fn item(&self, position: u32) -> Option<glib::Object> {
97            self.list
98                .borrow()
99                .get(position as usize)
100                .map(glib::object::Cast::upcast_ref::<glib::Object>)
101                .cloned()
102        }
103    }
104
105    impl InviteList {
106        /// The room this invitee list refers to.
107        fn room(&self) -> &Room {
108            self.room.get().expect("room should be initialized")
109        }
110
111        /// Set the search term.
112        fn set_search_term(&self, search_term: Option<String>) {
113            let search_term = search_term.filter(|s| !s.is_empty());
114
115            if search_term == *self.search_term.borrow() {
116                return;
117            }
118
119            self.search_term.replace(search_term);
120
121            spawn!(clone!(
122                #[weak(rename_to = imp)]
123                self,
124                async move {
125                    imp.search_users().await;
126                }
127            ));
128
129            self.obj().notify_search_term();
130        }
131
132        /// Whether some users are invited.
133        fn has_invitees(&self) -> bool {
134            !self.invitee_list.borrow().is_empty()
135        }
136
137        /// Set the state of the list.
138        pub fn set_state(&self, state: InviteListState) {
139            if state == self.state.get() {
140                return;
141            }
142
143            self.state.set(state);
144            self.obj().notify_state();
145        }
146
147        /// Replace this list with the given items.
148        fn replace_list(&self, items: Vec<InviteItem>) {
149            let added = items.len();
150
151            let prev_items = self.list.replace(items);
152
153            self.obj()
154                .items_changed(0, prev_items.len() as u32, added as u32);
155        }
156
157        /// Clear this list.
158        fn clear_list(&self) {
159            self.replace_list(Vec::new());
160        }
161
162        /// Search for the current search term in the user directory.
163        async fn search_users(&self) {
164            let Some(session) = self.room().session() else {
165                return;
166            };
167
168            let Some(search_term) = self.search_term.borrow().clone() else {
169                // Do nothing for no search term, but reset state when currently loading.
170                if self.state.get() == InviteListState::Loading {
171                    self.set_state(InviteListState::Initial);
172                }
173                if let Some(abort_handle) = self.abort_handle.take() {
174                    abort_handle.abort();
175                }
176
177                return;
178            };
179
180            self.set_state(InviteListState::Loading);
181            self.clear_list();
182
183            let client = session.client();
184            let search_term_clone = search_term.clone();
185            let handle =
186                spawn_tokio!(async move { client.search_users(&search_term_clone, 10).await });
187
188            let abort_handle = handle.abort_handle();
189
190            // Keep the abort handle so we can abort the request if the user changes the
191            // search term.
192            if let Some(prev_abort_handle) = self.abort_handle.replace(Some(abort_handle)) {
193                // Abort the previous request.
194                prev_abort_handle.abort();
195            }
196
197            match handle.await {
198                Ok(Ok(response)) => {
199                    // The request succeeded.
200                    if self
201                        .search_term
202                        .borrow()
203                        .as_ref()
204                        .is_some_and(|s| *s == search_term)
205                    {
206                        self.update_from_search_results(response.results);
207                    }
208                }
209                Ok(Err(error)) => {
210                    // The request failed.
211                    error!("Could not search user directory: {error}");
212                    self.set_state(InviteListState::Error);
213                    self.clear_list();
214                }
215                Err(_) => {
216                    // The request was aborted.
217                }
218            }
219
220            self.abort_handle.take();
221        }
222
223        /// Update this list from the given search results.
224        fn update_from_search_results(&self, results: Vec<SearchUser>) {
225            let Some(session) = self.room().session() else {
226                return;
227            };
228            let Some(search_term) = self.search_term.borrow().clone() else {
229                return;
230            };
231
232            // We should have a strong reference to the list in the main page so we can use
233            // `get_or_create_members()`.
234            let member_list = self.room().get_or_create_members();
235
236            // If the search term looks like a user ID and it is not already in the
237            // response, we will insert it in the list.
238            let search_term_user_id = UserId::parse(search_term)
239                .ok()
240                .filter(|user_id| !results.iter().any(|item| item.user_id == *user_id));
241            let search_term_user = search_term_user_id.clone().map(SearchUser::new);
242
243            let new_len = results
244                .len()
245                .saturating_add(search_term_user.is_some().into());
246            if new_len == 0 {
247                self.set_state(InviteListState::NoMatching);
248                self.clear_list();
249                return;
250            }
251
252            let mut list = Vec::with_capacity(new_len);
253            let results = search_term_user.into_iter().chain(results);
254
255            for result in results {
256                let member = member_list.get(&result.user_id);
257
258                // 'Disable' users that can't be invited.
259                let invite_exception = member.as_ref().and_then(|m| match m.membership() {
260                    Membership::Join => Some(gettext("Member")),
261                    Membership::Ban => Some(gettext("Banned")),
262                    Membership::Invite => Some(gettext("Invited")),
263                    _ => None,
264                });
265
266                // If it's an invitee, reuse the item.
267                let invitee = self.invitee_list.borrow().get(&result.user_id).cloned();
268                if let Some(item) = invitee {
269                    let user = item.user();
270
271                    // The profile data may have changed in the meantime, but don't overwrite a
272                    // joined member's data.
273                    if !user
274                        .downcast_ref::<Member>()
275                        .is_some_and(|m| m.membership() == Membership::Join)
276                    {
277                        user.set_avatar_url(result.avatar_url);
278                        user.set_name(result.display_name);
279                    }
280
281                    // The membership state may have changed in the meantime.
282                    item.set_invite_exception(invite_exception);
283
284                    list.push(item);
285                    continue;
286                }
287
288                // If it's a joined room member, reuse the user.
289                if let Some(member) = member.filter(|m| m.membership() == Membership::Join) {
290                    let item = self.create_item(&member, invite_exception);
291                    list.push(item);
292
293                    continue;
294                }
295
296                // If it's the dummy result for the search term user ID, use the remote cache to
297                // fetch its profile.
298                if search_term_user_id
299                    .as_ref()
300                    .is_some_and(|user_id| *user_id == result.user_id)
301                {
302                    let user = session.remote_cache().user(result.user_id);
303                    let item = self.create_item(&user, invite_exception);
304                    list.push(item);
305
306                    continue;
307                }
308
309                // As a last resort, we just use the data of the result.
310                let user = User::new(&session, result.user_id);
311                user.set_avatar_url(result.avatar_url);
312                user.set_name(result.display_name);
313
314                let item = self.create_item(&user, invite_exception);
315                list.push(item);
316            }
317
318            self.replace_list(list);
319            self.set_state(InviteListState::Matching);
320        }
321
322        /// Create an item for the given user and invite exception.
323        fn create_item(
324            &self,
325            user: &impl IsA<User>,
326            invite_exception: Option<String>,
327        ) -> InviteItem {
328            let item = InviteItem::new(user);
329            item.set_invite_exception(invite_exception);
330
331            item.connect_is_invitee_notify(clone!(
332                #[weak(rename_to = imp)]
333                self,
334                move |item| {
335                    imp.update_invitees_for_item(item);
336                }
337            ));
338            item.connect_can_invite_notify(clone!(
339                #[weak(rename_to = imp)]
340                self,
341                move |item| {
342                    imp.update_invitees_for_item(item);
343                }
344            ));
345
346            item
347        }
348
349        /// Update the list of invitees for the current state of the item.
350        fn update_invitees_for_item(&self, item: &InviteItem) {
351            if item.is_invitee() && item.can_invite() {
352                self.add_invitee(item);
353            } else {
354                self.remove_invitee(item.user().user_id());
355            }
356        }
357
358        /// Add the given item as an invitee.
359        fn add_invitee(&self, item: &InviteItem) {
360            let had_invitees = self.has_invitees();
361
362            item.set_is_invitee(true);
363            self.invitee_list
364                .borrow_mut()
365                .insert(item.user().user_id().clone(), item.clone());
366
367            let obj = self.obj();
368            obj.emit_by_name::<()>("invitee-added", &[&item]);
369
370            if !had_invitees {
371                obj.notify_has_invitees();
372            }
373        }
374
375        /// Update the list of invitees so only the invitees with the given user
376        /// IDs remain.
377        pub(super) fn retain_invitees(&self, invitees_ids: &[&UserId]) {
378            if !self.has_invitees() {
379                // Nothing to do.
380                return;
381            }
382
383            let invitee_list = self.invitee_list.take();
384
385            let (invitee_list, removed_invitees) = invitee_list
386                .into_iter()
387                .partition(|(key, _)| invitees_ids.contains(&key.as_ref()));
388            self.invitee_list.replace(invitee_list);
389
390            for item in removed_invitees.values() {
391                self.handle_removed_invitee(item);
392            }
393
394            if !self.has_invitees() {
395                self.obj().notify_has_invitees();
396            }
397        }
398
399        /// Remove the invitee with the given user ID from the list.
400        pub(super) fn remove_invitee(&self, user_id: &UserId) {
401            let Some(item) = self.invitee_list.borrow_mut().remove(user_id) else {
402                return;
403            };
404
405            self.handle_removed_invitee(&item);
406
407            if !self.has_invitees() {
408                self.obj().notify_has_invitees();
409            }
410        }
411
412        /// Handle when the given item was removed from the list of invitees.
413        fn handle_removed_invitee(&self, item: &InviteItem) {
414            item.set_is_invitee(false);
415            self.obj().emit_by_name::<()>("invitee-removed", &[&item]);
416        }
417    }
418}
419
420glib::wrapper! {
421    /// List of users after a search in the user directory.
422    ///
423    /// This also manages invitees.
424    pub struct InviteList(ObjectSubclass<imp::InviteList>)
425        @implements gio::ListModel;
426}
427
428impl InviteList {
429    pub fn new(room: &Room) -> Self {
430        glib::Object::builder().property("room", room).build()
431    }
432
433    /// Return the first invitee in the list, if any.
434    pub(crate) fn first_invitee(&self) -> Option<InviteItem> {
435        self.imp().invitee_list.borrow().values().next().cloned()
436    }
437
438    /// Get the number of invitees.
439    pub(crate) fn n_invitees(&self) -> usize {
440        self.imp().invitee_list.borrow().len()
441    }
442
443    /// Get the list of user IDs of the invitees.
444    pub(crate) fn invitees_ids(&self) -> Vec<OwnedUserId> {
445        self.imp().invitee_list.borrow().keys().cloned().collect()
446    }
447
448    /// Update the list of invitees so only the invitees with the given user IDs
449    /// remain.
450    pub(crate) fn retain_invitees(&self, invitees_ids: &[&UserId]) {
451        self.imp().retain_invitees(invitees_ids);
452    }
453
454    /// Remove the invitee with the given user ID from the list.
455    pub(crate) fn remove_invitee(&self, user_id: &UserId) {
456        self.imp().remove_invitee(user_id);
457    }
458
459    /// Connect to the signal emitted when an invitee is added.
460    pub fn connect_invitee_added<F: Fn(&Self, &InviteItem) + 'static>(
461        &self,
462        f: F,
463    ) -> glib::SignalHandlerId {
464        self.connect_closure(
465            "invitee-added",
466            true,
467            closure_local!(move |obj: Self, invitee: InviteItem| {
468                f(&obj, &invitee);
469            }),
470        )
471    }
472
473    /// Connect to the signal emitted when an invitee is removed.
474    pub fn connect_invitee_removed<F: Fn(&Self, &InviteItem) + 'static>(
475        &self,
476        f: F,
477    ) -> glib::SignalHandlerId {
478        self.connect_closure(
479            "invitee-removed",
480            true,
481            closure_local!(move |obj: Self, invitee: InviteItem| {
482                f(&obj, &invitee);
483            }),
484        )
485    }
486}