fractal/session/view/content/room_details/invite_subpage/
list.rsuse gettextrs::gettext;
use gtk::{
gio, glib,
glib::{clone, closure_local},
prelude::*,
subclass::prelude::*,
};
use matrix_sdk::ruma::{
api::client::user_directory::search_users::v3::User as SearchUser, OwnedUserId, UserId,
};
use tracing::error;
use super::InviteItem;
use crate::{
prelude::*,
session::model::{Member, Membership, RemoteUser, Room, User},
spawn, spawn_tokio,
};
#[derive(Debug, Default, Eq, PartialEq, Clone, Copy, glib::Enum)]
#[repr(u32)]
#[enum_type(name = "RoomDetailsInviteListState")]
pub enum InviteListState {
#[default]
Initial = 0,
Loading = 1,
NoMatching = 2,
Matching = 3,
Error = 4,
}
mod imp {
use std::{
cell::{Cell, OnceCell, RefCell},
collections::HashMap,
marker::PhantomData,
sync::LazyLock,
};
use glib::subclass::Signal;
use super::*;
#[derive(Debug, Default, glib::Properties)]
#[properties(wrapper_type = super::InviteList)]
pub struct InviteList {
pub list: RefCell<Vec<InviteItem>>,
#[property(get, construct_only)]
pub room: OnceCell<Room>,
#[property(get, builder(InviteListState::default()))]
pub state: Cell<InviteListState>,
#[property(get, set = Self::set_search_term, explicit_notify)]
pub search_term: RefCell<Option<String>>,
pub invitee_list: RefCell<HashMap<OwnedUserId, InviteItem>>,
pub abort_handle: RefCell<Option<tokio::task::AbortHandle>>,
#[property(get = Self::has_invitees)]
pub has_invitees: PhantomData<bool>,
}
#[glib::object_subclass]
impl ObjectSubclass for InviteList {
const NAME: &'static str = "RoomDetailsInviteList";
type Type = super::InviteList;
type Interfaces = (gio::ListModel,);
}
#[glib::derived_properties]
impl ObjectImpl for InviteList {
fn signals() -> &'static [Signal] {
static SIGNALS: LazyLock<Vec<Signal>> = LazyLock::new(|| {
vec![
Signal::builder("invitee-added")
.param_types([InviteItem::static_type()])
.build(),
Signal::builder("invitee-removed")
.param_types([InviteItem::static_type()])
.build(),
]
});
SIGNALS.as_ref()
}
}
impl ListModelImpl for InviteList {
fn item_type(&self) -> glib::Type {
InviteItem::static_type()
}
fn n_items(&self) -> u32 {
self.list.borrow().len() as u32
}
fn item(&self, position: u32) -> Option<glib::Object> {
self.list
.borrow()
.get(position as usize)
.map(glib::object::Cast::upcast_ref::<glib::Object>)
.cloned()
}
}
impl InviteList {
fn set_search_term(&self, search_term: Option<String>) {
let search_term = search_term.filter(|s| !s.is_empty());
if search_term == *self.search_term.borrow() {
return;
}
let obj = self.obj();
self.search_term.replace(search_term);
spawn!(clone!(
#[weak]
obj,
async move {
obj.search_users().await;
}
));
obj.notify_search_term();
}
fn has_invitees(&self) -> bool {
!self.invitee_list.borrow().is_empty()
}
pub(super) fn set_state(&self, state: InviteListState) {
if state == self.state.get() {
return;
}
self.state.set(state);
self.obj().notify_state();
}
}
}
glib::wrapper! {
pub struct InviteList(ObjectSubclass<imp::InviteList>)
@implements gio::ListModel;
}
impl InviteList {
pub fn new(room: &Room) -> Self {
glib::Object::builder().property("room", room).build()
}
fn replace_list(&self, items: Vec<InviteItem>) {
let added = items.len();
let prev_items = self.imp().list.replace(items);
self.items_changed(0, prev_items.len() as u32, added as u32);
}
fn clear_list(&self) {
self.replace_list(Vec::new());
}
async fn search_users(&self) {
let Some(session) = self.room().session() else {
return;
};
let imp = self.imp();
let Some(search_term) = self.search_term() else {
if self.state() == InviteListState::Loading {
imp.set_state(InviteListState::Initial);
}
if let Some(abort_handle) = imp.abort_handle.take() {
abort_handle.abort();
}
return;
};
imp.set_state(InviteListState::Loading);
self.clear_list();
let client = session.client();
let search_term_clone = search_term.clone();
let handle = spawn_tokio!(async move { client.search_users(&search_term_clone, 10).await });
let abort_handle = handle.abort_handle();
if let Some(prev_abort_handle) = imp.abort_handle.replace(Some(abort_handle)) {
prev_abort_handle.abort();
}
match handle.await {
Ok(Ok(response)) => {
if self.search_term().is_some_and(|s| s == search_term) {
self.update_from_search_results(response.results);
}
}
Ok(Err(error)) => {
error!("Could not search user directory: {error}");
imp.set_state(InviteListState::Error);
self.clear_list();
}
Err(_) => {
}
}
imp.abort_handle.take();
}
fn update_from_search_results(&self, results: Vec<SearchUser>) {
let Some(session) = self.room().session() else {
return;
};
let Some(search_term) = self.search_term() else {
return;
};
let imp = self.imp();
let member_list = self.room().get_or_create_members();
let search_term_user_id = UserId::parse(search_term)
.ok()
.filter(|user_id| !results.iter().any(|item| item.user_id == *user_id));
let search_term_user = search_term_user_id.clone().map(SearchUser::new);
let new_len = results
.len()
.saturating_add(search_term_user.is_some().into());
if new_len == 0 {
imp.set_state(InviteListState::NoMatching);
self.clear_list();
return;
}
let mut list = Vec::with_capacity(new_len);
let results = search_term_user.into_iter().chain(results);
for result in results {
let member = member_list.get(&result.user_id);
let invite_exception = member.as_ref().and_then(|m| match m.membership() {
Membership::Join => Some(gettext("Member")),
Membership::Ban => Some(gettext("Banned")),
Membership::Invite => Some(gettext("Invited")),
_ => None,
});
if let Some(item) = self.invitee(&result.user_id) {
let user = item.user();
if !user
.downcast_ref::<Member>()
.is_some_and(|m| m.membership() == Membership::Join)
{
user.set_avatar_url(result.avatar_url);
user.set_name(result.display_name);
}
item.set_invite_exception(invite_exception);
list.push(item);
continue;
}
if let Some(member) = member.filter(|m| m.membership() == Membership::Join) {
let item = self.create_item(&member, invite_exception);
list.push(item);
continue;
}
if search_term_user_id
.as_ref()
.is_some_and(|user_id| *user_id == result.user_id)
{
let user = RemoteUser::new(&session, result.user_id);
let item = self.create_item(&user, invite_exception);
list.push(item);
spawn!(async move { user.load_profile().await });
continue;
}
let user = User::new(&session, result.user_id);
user.set_avatar_url(result.avatar_url);
user.set_name(result.display_name);
let item = self.create_item(&user, invite_exception);
list.push(item);
}
self.replace_list(list);
imp.set_state(InviteListState::Matching);
}
fn create_item(&self, user: &impl IsA<User>, invite_exception: Option<String>) -> InviteItem {
let item = InviteItem::new(user);
item.set_invite_exception(invite_exception);
item.connect_is_invitee_notify(clone!(
#[weak(rename_to = obj)]
self,
move |item| {
obj.update_invitees_for_item(item);
}
));
item.connect_can_invite_notify(clone!(
#[weak(rename_to = obj)]
self,
move |item| {
obj.update_invitees_for_item(item);
}
));
item
}
fn update_invitees_for_item(&self, item: &InviteItem) {
if item.is_invitee() && item.can_invite() {
self.add_invitee(item);
} else {
self.remove_invitee(item.user().user_id());
}
}
pub fn invitee(&self, user_id: &UserId) -> Option<InviteItem> {
self.imp().invitee_list.borrow().get(user_id).cloned()
}
pub fn first_invitee(&self) -> Option<InviteItem> {
self.imp().invitee_list.borrow().values().next().cloned()
}
fn add_invitee(&self, item: &InviteItem) {
let had_invitees = self.has_invitees();
item.set_is_invitee(true);
self.imp()
.invitee_list
.borrow_mut()
.insert(item.user().user_id().clone(), item.clone());
self.emit_by_name::<()>("invitee-added", &[&item]);
if !had_invitees {
self.notify_has_invitees();
}
}
pub fn n_invitees(&self) -> usize {
self.imp().invitee_list.borrow().len()
}
pub fn invitees_ids(&self) -> Vec<OwnedUserId> {
self.imp().invitee_list.borrow().keys().cloned().collect()
}
pub fn retain_invitees(&self, invitees_ids: &[&UserId]) {
if !self.has_invitees() {
return;
}
let invitee_list = self.imp().invitee_list.take();
let (invitee_list, removed_invitees) = invitee_list
.into_iter()
.partition(|(key, _)| invitees_ids.contains(&key.as_ref()));
self.imp().invitee_list.replace(invitee_list);
for item in removed_invitees.values() {
self.handle_removed_invitee(item);
}
if !self.has_invitees() {
self.notify_has_invitees();
}
}
pub fn remove_invitee(&self, user_id: &UserId) {
let Some(item) = self.imp().invitee_list.borrow_mut().remove(user_id) else {
return;
};
self.handle_removed_invitee(&item);
if !self.has_invitees() {
self.notify_has_invitees();
}
}
fn handle_removed_invitee(&self, item: &InviteItem) {
item.set_is_invitee(false);
self.emit_by_name::<()>("invitee-removed", &[&item]);
}
pub fn connect_invitee_added<F: Fn(&Self, &InviteItem) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
self.connect_closure(
"invitee-added",
true,
closure_local!(move |obj: Self, invitee: InviteItem| {
f(&obj, &invitee);
}),
)
}
pub fn connect_invitee_removed<F: Fn(&Self, &InviteItem) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
self.connect_closure(
"invitee-removed",
true,
closure_local!(move |obj: Self, invitee: InviteItem| {
f(&obj, &invitee);
}),
)
}
}