fractal/session/view/content/room_details/invite_subpage/
list.rs1use 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 #[property(get, construct_only)]
49 room: OnceCell<Room>,
50 #[property(get, builder(InviteListState::default()))]
52 state: Cell<InviteListState>,
53 #[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 #[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 fn room(&self) -> &Room {
108 self.room.get().expect("room should be initialized")
109 }
110
111 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 fn has_invitees(&self) -> bool {
134 !self.invitee_list.borrow().is_empty()
135 }
136
137 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 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 fn clear_list(&self) {
159 self.replace_list(Vec::new());
160 }
161
162 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 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 if let Some(prev_abort_handle) = self.abort_handle.replace(Some(abort_handle)) {
193 prev_abort_handle.abort();
195 }
196
197 match handle.await {
198 Ok(Ok(response)) => {
199 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 error!("Could not search user directory: {error}");
212 self.set_state(InviteListState::Error);
213 self.clear_list();
214 }
215 Err(_) => {
216 }
218 }
219
220 self.abort_handle.take();
221 }
222
223 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 let member_list = self.room().get_or_create_members();
235
236 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 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 let invitee = self.invitee_list.borrow().get(&result.user_id).cloned();
268 if let Some(item) = invitee {
269 let user = item.user();
270
271 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 item.set_invite_exception(invite_exception);
283
284 list.push(item);
285 continue;
286 }
287
288 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 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 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 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 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 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 pub(super) fn retain_invitees(&self, invitees_ids: &[&UserId]) {
378 if !self.has_invitees() {
379 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 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 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 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 pub(crate) fn first_invitee(&self) -> Option<InviteItem> {
435 self.imp().invitee_list.borrow().values().next().cloned()
436 }
437
438 pub(crate) fn n_invitees(&self) -> usize {
440 self.imp().invitee_list.borrow().len()
441 }
442
443 pub(crate) fn invitees_ids(&self) -> Vec<OwnedUserId> {
445 self.imp().invitee_list.borrow().keys().cloned().collect()
446 }
447
448 pub(crate) fn retain_invitees(&self, invitees_ids: &[&UserId]) {
451 self.imp().retain_invitees(invitees_ids);
452 }
453
454 pub(crate) fn remove_invitee(&self, user_id: &UserId) {
456 self.imp().remove_invitee(user_id);
457 }
458
459 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 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}