16 changed files with 1317 additions and 6 deletions
@ -0,0 +1,152 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<interface> |
||||
<template class="ContentInviteSubpage" parent="AdwBin"> |
||||
<property name="child"> |
||||
<object class="GtkBox"> |
||||
<property name="orientation">vertical</property> |
||||
<child> |
||||
<object class="GtkHeaderBar"> |
||||
<property name="show-title-buttons">false</property> |
||||
<child type="start"> |
||||
<object class="GtkButton" id="cancel_button"> |
||||
<property name="label" translatable="yes">_Cancel</property> |
||||
<property name="use_underline">True</property> |
||||
</object> |
||||
</child> |
||||
<child type="end"> |
||||
<object class="SpinnerButton" id="invite_button"> |
||||
<property name="label" translatable="yes">I_nvite</property> |
||||
<property name="use_underline">True</property> |
||||
<property name="sensitive">False</property> |
||||
<style> |
||||
<class name="suggested-action"/> |
||||
</style> |
||||
</object> |
||||
</child> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkSearchBar"> |
||||
<property name="search-mode-enabled">True</property> |
||||
<child> |
||||
<object class="AdwClamp"> |
||||
<property name="margin-bottom">6</property> |
||||
<property name="margin-end">30</property> |
||||
<property name="margin-start">30</property> |
||||
<property name="margin-top">6</property> |
||||
<property name="hexpand">true</property> |
||||
<child> |
||||
<object class="CustomEntry"> |
||||
<!-- FIXME: inserting a Pill makes the Entry grow, therefore we force more height so that it doens't grow visually |
||||
Would be nice to fix it properly. Including the vertical alignment of Pills in the textview |
||||
--> |
||||
<property name="height-request">74</property> |
||||
<child> |
||||
<object class="GtkBox"> |
||||
<property name="spacing">6</property> |
||||
<child> |
||||
<object class="GtkImage"> |
||||
<property name="icon-name">system-search-symbolic</property> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkScrolledWindow"> |
||||
<child> |
||||
<object class="GtkTextView" id="text_view"> |
||||
<property name="hexpand">true</property> |
||||
<property name="justification">left</property> |
||||
<property name="wrap-mode">word-char</property> |
||||
<property name="accepts-tab">False</property> |
||||
<property name="pixels_above_lines">3</property> |
||||
<property name="pixels_below_lines">3</property> |
||||
<property name="pixels_inside_wrap">6</property> |
||||
<property name="editable" bind-source="invite_button" bind-property="loading" bind-flags="sync-create | invert-boolean"/> |
||||
<property name="buffer"> |
||||
<object class="GtkTextBuffer" id="text_buffer"/> |
||||
</property> |
||||
</object> |
||||
</child> |
||||
</object> |
||||
</child> |
||||
</object> |
||||
</child> |
||||
</object> |
||||
</child> |
||||
</object> |
||||
</child> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkStack" id="stack"> |
||||
<child> |
||||
<object class="AdwStatusPage" id="no_search_page"> |
||||
<property name="visible">True</property> |
||||
<property name="hexpand">True</property> |
||||
<property name="vexpand">True</property> |
||||
<property name="icon-name">system-search-symbolic</property> |
||||
<property name="description" translatable="yes">Search for users to invite them to this room.</property> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkScrolledWindow" id="matching_page"> |
||||
<property name="propagate-natural-height">True</property> |
||||
<property name="child"> |
||||
<object class="AdwClampScrollable"> |
||||
<property name="child"> |
||||
<object class="GtkListView" id="list_view"> |
||||
<property name="margin-bottom">24</property> |
||||
<property name="margin-end">12</property> |
||||
<property name="margin-start">12</property> |
||||
<property name="margin-top">24</property> |
||||
<property name="show-separators">True</property> |
||||
<property name="single-click-activate">True</property> |
||||
<property name="factory"> |
||||
<object class="GtkBuilderListItemFactory"> |
||||
<property name="resource">/org/gnome/FractalNext/content-invitee-item.ui</property> |
||||
</object> |
||||
</property> |
||||
<style> |
||||
<class name="content"/> |
||||
</style> |
||||
</object> |
||||
</property> |
||||
</object> |
||||
</property> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="AdwStatusPage" id="no_matching_page"> |
||||
<property name="visible">True</property> |
||||
<property name="hexpand">True</property> |
||||
<property name="vexpand">True</property> |
||||
<property name="icon-name">system-search-symbolic</property> |
||||
<property name="description" translatable="yes">No users matching the search where found.</property> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="AdwStatusPage" id="error_page"> |
||||
<property name="visible">True</property> |
||||
<property name="hexpand">True</property> |
||||
<property name="vexpand">True</property> |
||||
<property name="icon-name">dialog-error-symbolic</property> |
||||
<property name="description" translatable="yes">An error occured while searching for matches</property> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkSpinner" id="loading_page"> |
||||
<property name="spinning">True</property> |
||||
<property name="valign">center</property> |
||||
<property name="halign">center</property> |
||||
<property name="vexpand">True</property> |
||||
<style> |
||||
<class name="session-loading-spinner"/> |
||||
</style> |
||||
</object> |
||||
</child> |
||||
</object> |
||||
</child> |
||||
</object> |
||||
</property> |
||||
</template> |
||||
</interface> |
||||
|
||||
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<interface> |
||||
<template class="GtkListItem"> |
||||
<property name="activatable">True</property> |
||||
<property name="selectable">False</property> |
||||
<property name="child"> |
||||
<object class="ContentInviteInviteeRow" id="row"> |
||||
<binding name="user"> |
||||
<lookup name="item">GtkListItem</lookup> |
||||
</binding> |
||||
</object> |
||||
</property> |
||||
</template> |
||||
</interface> |
||||
|
||||
@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<interface> |
||||
<template class="ContentInviteInviteeRow" parent="AdwBin"> |
||||
<property name="margin-top">12</property> |
||||
<property name="margin-bottom">12</property> |
||||
<property name="margin-start">12</property> |
||||
<property name="margin-end">12</property> |
||||
<property name="child"> |
||||
<object class="GtkBox" id="header"> |
||||
<property name="spacing">12</property> |
||||
<style> |
||||
<class name="header"/> |
||||
</style> |
||||
<child> |
||||
<object class="ComponentsAvatar"> |
||||
<property name="size">32</property> |
||||
<binding name="item"> |
||||
<lookup name="avatar" type="Invitee"> |
||||
<lookup name="user">ContentInviteInviteeRow</lookup> |
||||
</lookup> |
||||
</binding> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkBox"> |
||||
<property name="orientation">vertical</property> |
||||
<style> |
||||
<class name="title"/> |
||||
</style> |
||||
<child> |
||||
<object class="GtkLabel" id="display-name"> |
||||
<property name="halign">start</property> |
||||
<property name="ellipsize">end</property> |
||||
<binding name="label"> |
||||
<lookup name="display-name" type="Invitee"> |
||||
<lookup name="user">ContentInviteInviteeRow</lookup> |
||||
</lookup> |
||||
</binding> |
||||
<style> |
||||
<class name="title"/> |
||||
</style> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkLabel" id="subtitle"> |
||||
<property name="hexpand">True</property> |
||||
<property name="halign">start</property> |
||||
<property name="ellipsize">end</property> |
||||
<binding name="label"> |
||||
<lookup name="user-id" type="Invitee"> |
||||
<lookup name="user">ContentInviteInviteeRow</lookup> |
||||
</lookup> |
||||
</binding> |
||||
<style> |
||||
<class name="subtitle"/> |
||||
</style> |
||||
</object> |
||||
</child> |
||||
</object> |
||||
</child> |
||||
<child> |
||||
<object class="GtkCheckButton" id="check_button" /> |
||||
</child> |
||||
</object> |
||||
</property> |
||||
</template> |
||||
</interface> |
||||
|
||||
@ -0,0 +1,136 @@
|
||||
use gtk::glib; |
||||
use gtk::prelude::*; |
||||
use gtk::subclass::prelude::*; |
||||
use matrix_sdk::ruma::identifiers::{MxcUri, UserId}; |
||||
|
||||
use crate::session::user::UserExt; |
||||
use crate::session::{Session, User}; |
||||
|
||||
mod imp { |
||||
use super::*; |
||||
use once_cell::sync::Lazy; |
||||
use std::cell::{Cell, RefCell}; |
||||
|
||||
#[derive(Debug, Default)] |
||||
pub struct Invitee { |
||||
pub invited: Cell<bool>, |
||||
pub anchor: RefCell<Option<gtk::TextChildAnchor>>, |
||||
} |
||||
|
||||
#[glib::object_subclass] |
||||
impl ObjectSubclass for Invitee { |
||||
const NAME: &'static str = "Invitee"; |
||||
type Type = super::Invitee; |
||||
type ParentType = User; |
||||
} |
||||
|
||||
impl ObjectImpl for Invitee { |
||||
fn properties() -> &'static [glib::ParamSpec] { |
||||
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| { |
||||
vec![ |
||||
glib::ParamSpec::new_boolean( |
||||
"invited", |
||||
"Invited", |
||||
"Whether this Invitee is actually invited", |
||||
false, |
||||
glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, |
||||
), |
||||
glib::ParamSpec::new_object( |
||||
"anchor", |
||||
"Anchor", |
||||
"The anchor location in the text buffer", |
||||
gtk::TextChildAnchor::static_type(), |
||||
glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, |
||||
), |
||||
] |
||||
}); |
||||
|
||||
PROPERTIES.as_ref() |
||||
} |
||||
|
||||
fn set_property( |
||||
&self, |
||||
obj: &Self::Type, |
||||
_id: usize, |
||||
value: &glib::Value, |
||||
pspec: &glib::ParamSpec, |
||||
) { |
||||
match pspec.name() { |
||||
"invited" => obj.set_invited(value.get().unwrap()), |
||||
"anchor" => obj.set_anchor(value.get().unwrap()), |
||||
_ => unimplemented!(), |
||||
} |
||||
} |
||||
|
||||
fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { |
||||
match pspec.name() { |
||||
"invited" => obj.is_invited().to_value(), |
||||
"anchor" => obj.anchor().to_value(), |
||||
_ => unimplemented!(), |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
glib::wrapper! { |
||||
/// A User in the context of a given room.
|
||||
pub struct Invitee(ObjectSubclass<imp::Invitee>) @extends User; |
||||
} |
||||
|
||||
impl Invitee { |
||||
pub fn new( |
||||
session: &Session, |
||||
user_id: &UserId, |
||||
display_name: Option<&str>, |
||||
avatar_url: Option<MxcUri>, |
||||
) -> Self { |
||||
let obj: Self = glib::Object::new(&[ |
||||
("session", session), |
||||
("user-id", &user_id.as_str()), |
||||
("display-name", &display_name), |
||||
]) |
||||
.expect("Failed to create Invitee"); |
||||
// FIXME: we should make the avatar_url settable as property
|
||||
obj.set_avatar_url(avatar_url); |
||||
obj |
||||
} |
||||
|
||||
pub fn is_invited(&self) -> bool { |
||||
let priv_ = imp::Invitee::from_instance(self); |
||||
priv_.invited.get() |
||||
} |
||||
|
||||
pub fn set_invited(&self, invited: bool) { |
||||
let priv_ = imp::Invitee::from_instance(self); |
||||
|
||||
if self.is_invited() == invited { |
||||
return; |
||||
} |
||||
|
||||
priv_.invited.set(invited); |
||||
self.notify("invited"); |
||||
} |
||||
|
||||
pub fn anchor(&self) -> Option<gtk::TextChildAnchor> { |
||||
let priv_ = imp::Invitee::from_instance(self); |
||||
priv_.anchor.borrow().clone() |
||||
} |
||||
|
||||
pub fn take_anchor(&self) -> Option<gtk::TextChildAnchor> { |
||||
let priv_ = imp::Invitee::from_instance(self); |
||||
let anchor = priv_.anchor.take(); |
||||
self.notify("anchor"); |
||||
anchor |
||||
} |
||||
|
||||
pub fn set_anchor(&self, anchor: Option<gtk::TextChildAnchor>) { |
||||
let priv_ = imp::Invitee::from_instance(self); |
||||
|
||||
if self.anchor() == anchor { |
||||
return; |
||||
} |
||||
|
||||
priv_.anchor.replace(anchor); |
||||
self.notify("anchor"); |
||||
} |
||||
} |
||||
@ -0,0 +1,386 @@
|
||||
use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*}; |
||||
use log::error; |
||||
use matrix_sdk::ruma::{api::client::r0::user_directory::search_users, identifiers::UserId}; |
||||
use matrix_sdk::HttpError; |
||||
|
||||
use crate::session::user::UserExt; |
||||
use crate::{session::Room, spawn, spawn_tokio}; |
||||
|
||||
use super::Invitee; |
||||
|
||||
#[derive(Debug, Eq, PartialEq, Clone, Copy, glib::GEnum)] |
||||
#[repr(u32)] |
||||
#[genum(type_name = "ContentInviteeListState")] |
||||
pub enum InviteeListState { |
||||
Initial = 0, |
||||
Loading = 1, |
||||
NoMatching = 2, |
||||
Matching = 3, |
||||
Error = 4, |
||||
} |
||||
|
||||
impl Default for InviteeListState { |
||||
fn default() -> Self { |
||||
Self::Initial |
||||
} |
||||
} |
||||
|
||||
mod imp { |
||||
use futures::future::AbortHandle; |
||||
use glib::subclass::Signal; |
||||
use once_cell::{sync::Lazy, unsync::OnceCell}; |
||||
use std::cell::{Cell, RefCell}; |
||||
use std::collections::HashMap; |
||||
|
||||
use super::*; |
||||
|
||||
#[derive(Debug, Default)] |
||||
pub struct InviteeList { |
||||
pub list: RefCell<Vec<Invitee>>, |
||||
pub room: OnceCell<Room>, |
||||
pub state: Cell<InviteeListState>, |
||||
pub search_term: RefCell<Option<String>>, |
||||
pub invitee_list: RefCell<HashMap<UserId, Invitee>>, |
||||
pub abort_handle: RefCell<Option<AbortHandle>>, |
||||
} |
||||
|
||||
#[glib::object_subclass] |
||||
impl ObjectSubclass for InviteeList { |
||||
const NAME: &'static str = "InviteeList"; |
||||
type Type = super::InviteeList; |
||||
type ParentType = glib::Object; |
||||
type Interfaces = (gio::ListModel,); |
||||
} |
||||
|
||||
impl ObjectImpl for InviteeList { |
||||
fn properties() -> &'static [glib::ParamSpec] { |
||||
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| { |
||||
vec![ |
||||
glib::ParamSpec::new_object( |
||||
"room", |
||||
"Room", |
||||
"The room this invitee list refers to", |
||||
Room::static_type(), |
||||
glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY, |
||||
), |
||||
glib::ParamSpec::new_string( |
||||
"search-term", |
||||
"Search Term", |
||||
"The search term", |
||||
None, |
||||
glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, |
||||
), |
||||
glib::ParamSpec::new_boolean( |
||||
"has-selected", |
||||
"Has Selected", |
||||
"Whether the user has selected some users", |
||||
false, |
||||
glib::ParamFlags::READABLE, |
||||
), |
||||
glib::ParamSpec::new_enum( |
||||
"state", |
||||
"InviteeListState", |
||||
"The state of the list", |
||||
InviteeListState::static_type(), |
||||
InviteeListState::default() as i32, |
||||
glib::ParamFlags::READABLE, |
||||
), |
||||
] |
||||
}); |
||||
|
||||
PROPERTIES.as_ref() |
||||
} |
||||
|
||||
fn signals() -> &'static [Signal] { |
||||
static SIGNALS: Lazy<Vec<Signal>> = Lazy::new(|| { |
||||
vec![ |
||||
Signal::builder( |
||||
"invitee-added", |
||||
&[Invitee::static_type().into()], |
||||
<()>::static_type().into(), |
||||
) |
||||
.build(), |
||||
Signal::builder( |
||||
"invitee-removed", |
||||
&[Invitee::static_type().into()], |
||||
<()>::static_type().into(), |
||||
) |
||||
.build(), |
||||
] |
||||
}); |
||||
SIGNALS.as_ref() |
||||
} |
||||
|
||||
fn set_property( |
||||
&self, |
||||
obj: &Self::Type, |
||||
_id: usize, |
||||
value: &glib::Value, |
||||
pspec: &glib::ParamSpec, |
||||
) { |
||||
match pspec.name() { |
||||
"room" => self.room.set(value.get::<Room>().unwrap()).unwrap(), |
||||
"search-term" => obj.set_search_term(value.get().unwrap()), |
||||
_ => unimplemented!(), |
||||
} |
||||
} |
||||
|
||||
fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { |
||||
match pspec.name() { |
||||
"room" => obj.room().to_value(), |
||||
"search-term" => obj.search_term().to_value(), |
||||
"has-selected" => obj.has_selected().to_value(), |
||||
"state" => obj.state().to_value(), |
||||
_ => unimplemented!(), |
||||
} |
||||
} |
||||
} |
||||
|
||||
impl ListModelImpl for InviteeList { |
||||
fn item_type(&self, _list_model: &Self::Type) -> glib::Type { |
||||
Invitee::static_type() |
||||
} |
||||
fn n_items(&self, _list_model: &Self::Type) -> u32 { |
||||
self.list.borrow().len() as u32 |
||||
} |
||||
fn item(&self, _list_model: &Self::Type, position: u32) -> Option<glib::Object> { |
||||
self.list |
||||
.borrow() |
||||
.get(position as usize) |
||||
.map(glib::object::Cast::upcast_ref::<glib::Object>) |
||||
.cloned() |
||||
} |
||||
} |
||||
} |
||||
|
||||
glib::wrapper! { |
||||
/// List of users matching the `search term`.
|
||||
pub struct InviteeList(ObjectSubclass<imp::InviteeList>) |
||||
@implements gio::ListModel; |
||||
} |
||||
|
||||
impl InviteeList { |
||||
pub fn new(room: &Room) -> Self { |
||||
glib::Object::new(&[("room", room)]).expect("Failed to create InviteeList") |
||||
} |
||||
|
||||
pub fn room(&self) -> &Room { |
||||
let priv_ = imp::InviteeList::from_instance(self); |
||||
priv_.room.get().unwrap() |
||||
} |
||||
|
||||
pub fn set_search_term(&self, search_term: Option<String>) { |
||||
let priv_ = imp::InviteeList::from_instance(self); |
||||
|
||||
if search_term.as_ref() == priv_.search_term.borrow().as_ref() { |
||||
return; |
||||
} |
||||
|
||||
if search_term.as_ref().map_or(false, |s| s.is_empty()) { |
||||
priv_.search_term.replace(None); |
||||
} else { |
||||
priv_.search_term.replace(search_term); |
||||
} |
||||
|
||||
self.search_users(); |
||||
self.notify("search_term"); |
||||
} |
||||
|
||||
fn search_term(&self) -> Option<String> { |
||||
let priv_ = imp::InviteeList::from_instance(self); |
||||
priv_.search_term.borrow().clone() |
||||
} |
||||
|
||||
fn set_state(&self, state: InviteeListState) { |
||||
let priv_ = imp::InviteeList::from_instance(self); |
||||
|
||||
if state == self.state() { |
||||
return; |
||||
} |
||||
|
||||
priv_.state.set(state); |
||||
self.notify("state"); |
||||
} |
||||
|
||||
pub fn state(&self) -> InviteeListState { |
||||
let priv_ = imp::InviteeList::from_instance(self); |
||||
priv_.state.get() |
||||
} |
||||
|
||||
fn set_list(&self, users: Vec<Invitee>) { |
||||
let priv_ = imp::InviteeList::from_instance(self); |
||||
let added = users.len(); |
||||
|
||||
let prev_users = priv_.list.replace(users); |
||||
|
||||
self.items_changed(0, prev_users.len() as u32, added as u32); |
||||
} |
||||
|
||||
fn clear_list(&self) { |
||||
self.set_list(Vec::new()); |
||||
} |
||||
|
||||
fn finish_search( |
||||
&self, |
||||
search_term: String, |
||||
response: Result<search_users::Response, HttpError>, |
||||
) { |
||||
let session = self.room().session(); |
||||
|
||||
if Some(search_term) != self.search_term() { |
||||
return; |
||||
} |
||||
|
||||
match response { |
||||
Ok(response) if response.results.len() == 0 => { |
||||
self.set_state(InviteeListState::NoMatching); |
||||
self.clear_list(); |
||||
} |
||||
Ok(response) => { |
||||
let users: Vec<Invitee> = response |
||||
.results |
||||
.into_iter() |
||||
.map(|item| { |
||||
if let Some(user) = self.get_invitee(&item.user_id) { |
||||
// The avatar or the display name may have changed in the mean time
|
||||
user.set_avatar_url(item.avatar_url); |
||||
user.set_display_name(item.display_name); |
||||
user |
||||
} else { |
||||
let user = Invitee::new( |
||||
&session, |
||||
&item.user_id, |
||||
item.display_name.as_deref(), |
||||
item.avatar_url, |
||||
); |
||||
|
||||
user.connect_notify_local( |
||||
Some("invited"), |
||||
clone!(@weak self as obj => move |user, _| { |
||||
if user.is_invited() { |
||||
obj.add_invitee(user.clone()); |
||||
} else { |
||||
obj.remove_invitee(user.user_id()) |
||||
} |
||||
}), |
||||
); |
||||
|
||||
user |
||||
} |
||||
}) |
||||
.collect(); |
||||
|
||||
self.set_list(users); |
||||
self.set_state(InviteeListState::Matching); |
||||
} |
||||
Err(error) => { |
||||
error!("Couldn't load matching users: {}", error); |
||||
self.set_state(InviteeListState::Error); |
||||
self.clear_list(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
fn search_users(&self) { |
||||
let priv_ = imp::InviteeList::from_instance(self); |
||||
let client = self.room().session().client(); |
||||
let search_term = if let Some(search_term) = self.search_term() { |
||||
search_term |
||||
} else { |
||||
// Do nothing for no search term execpt when currently loading
|
||||
if self.state() == InviteeListState::Loading { |
||||
self.set_state(InviteeListState::Initial); |
||||
} |
||||
return; |
||||
}; |
||||
|
||||
self.set_state(InviteeListState::Loading); |
||||
self.clear_list(); |
||||
|
||||
let search_term_clone = search_term.clone(); |
||||
let handle = spawn_tokio!(async move { |
||||
let request = search_users::Request::new(&search_term_clone); |
||||
client.send(request, None).await |
||||
}); |
||||
|
||||
let (future, handle) = futures::future::abortable(handle); |
||||
|
||||
if let Some(abort_handle) = priv_.abort_handle.replace(Some(handle)) { |
||||
abort_handle.abort(); |
||||
} |
||||
|
||||
spawn!(clone!(@weak self as obj => async move { |
||||
match future.await { |
||||
Ok(result) => obj.finish_search(search_term, result.unwrap()), |
||||
Err(_) => {}, |
||||
} |
||||
})); |
||||
} |
||||
|
||||
fn get_invitee(&self, user_id: &UserId) -> Option<Invitee> { |
||||
let priv_ = imp::InviteeList::from_instance(self); |
||||
priv_.invitee_list.borrow().get(user_id).cloned() |
||||
} |
||||
|
||||
pub fn add_invitee(&self, user: Invitee) { |
||||
let priv_ = imp::InviteeList::from_instance(self); |
||||
user.set_invited(true); |
||||
priv_ |
||||
.invitee_list |
||||
.borrow_mut() |
||||
.insert(user.user_id().to_owned(), user.clone()); |
||||
self.emit_by_name("invitee-added", &[&user]).unwrap(); |
||||
self.notify("has-selected"); |
||||
} |
||||
|
||||
pub fn invitees(&self) -> Vec<Invitee> { |
||||
let priv_ = imp::InviteeList::from_instance(self); |
||||
priv_ |
||||
.invitee_list |
||||
.borrow() |
||||
.values() |
||||
.map(Clone::clone) |
||||
.collect() |
||||
} |
||||
|
||||
fn remove_invitee(&self, user_id: &UserId) { |
||||
let priv_ = imp::InviteeList::from_instance(self); |
||||
let removed = priv_.invitee_list.borrow_mut().remove(user_id); |
||||
if let Some(user) = removed { |
||||
user.set_invited(false); |
||||
self.emit_by_name("invitee-removed", &[&user]).unwrap(); |
||||
self.notify("has-selected"); |
||||
} |
||||
} |
||||
|
||||
pub fn has_selected(&self) -> bool { |
||||
let priv_ = imp::InviteeList::from_instance(self); |
||||
!priv_.invitee_list.borrow().is_empty() |
||||
} |
||||
|
||||
pub fn connect_invitee_added<F: Fn(&Self, &Invitee) + 'static>( |
||||
&self, |
||||
f: F, |
||||
) -> glib::SignalHandlerId { |
||||
self.connect_local("invitee-added", true, move |values| { |
||||
let obj = values[0].get::<Self>().unwrap(); |
||||
let invitee = values[1].get::<Invitee>().unwrap(); |
||||
f(&obj, &invitee); |
||||
None |
||||
}) |
||||
.unwrap() |
||||
} |
||||
|
||||
pub fn connect_invitee_removed<F: Fn(&Self, &Invitee) + 'static>( |
||||
&self, |
||||
f: F, |
||||
) -> glib::SignalHandlerId { |
||||
self.connect_local("invitee-removed", true, move |values| { |
||||
let obj = values[0].get::<Self>().unwrap(); |
||||
let invitee = values[1].get::<Invitee>().unwrap(); |
||||
f(&obj, &invitee); |
||||
None |
||||
}) |
||||
.unwrap() |
||||
} |
||||
} |
||||
@ -0,0 +1,117 @@
|
||||
use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate}; |
||||
|
||||
use super::Invitee; |
||||
use adw::subclass::prelude::BinImpl; |
||||
|
||||
mod imp { |
||||
use super::*; |
||||
use glib::subclass::InitializingObject; |
||||
use once_cell::sync::Lazy; |
||||
use std::cell::RefCell; |
||||
|
||||
#[derive(Debug, Default, CompositeTemplate)] |
||||
#[template(resource = "/org/gnome/FractalNext/content-invitee-row.ui")] |
||||
pub struct InviteeRow { |
||||
pub user: RefCell<Option<Invitee>>, |
||||
pub binding: RefCell<Option<glib::Binding>>, |
||||
#[template_child] |
||||
pub check_button: TemplateChild<gtk::CheckButton>, |
||||
} |
||||
|
||||
#[glib::object_subclass] |
||||
impl ObjectSubclass for InviteeRow { |
||||
const NAME: &'static str = "ContentInviteInviteeRow"; |
||||
type Type = super::InviteeRow; |
||||
type ParentType = adw::Bin; |
||||
|
||||
fn class_init(klass: &mut Self::Class) { |
||||
Self::bind_template(klass); |
||||
} |
||||
|
||||
fn instance_init(obj: &InitializingObject<Self>) { |
||||
obj.init_template(); |
||||
} |
||||
} |
||||
|
||||
impl ObjectImpl for InviteeRow { |
||||
fn properties() -> &'static [glib::ParamSpec] { |
||||
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| { |
||||
vec![glib::ParamSpec::new_object( |
||||
"user", |
||||
"User", |
||||
"The user this row is showing", |
||||
Invitee::static_type(), |
||||
glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, |
||||
)] |
||||
}); |
||||
|
||||
PROPERTIES.as_ref() |
||||
} |
||||
|
||||
fn set_property( |
||||
&self, |
||||
obj: &Self::Type, |
||||
_id: usize, |
||||
value: &glib::Value, |
||||
pspec: &glib::ParamSpec, |
||||
) { |
||||
match pspec.name() { |
||||
"user" => { |
||||
obj.set_user(value.get().unwrap()); |
||||
} |
||||
_ => unimplemented!(), |
||||
} |
||||
} |
||||
|
||||
fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { |
||||
match pspec.name() { |
||||
"user" => obj.user().to_value(), |
||||
_ => unimplemented!(), |
||||
} |
||||
} |
||||
} |
||||
impl WidgetImpl for InviteeRow {} |
||||
impl BinImpl for InviteeRow {} |
||||
} |
||||
|
||||
glib::wrapper! { |
||||
pub struct InviteeRow(ObjectSubclass<imp::InviteeRow>) |
||||
@extends gtk::Widget, adw::Bin, @implements gtk::Accessible; |
||||
} |
||||
|
||||
impl InviteeRow { |
||||
pub fn new(user: &Invitee) -> Self { |
||||
glib::Object::new(&[("user", user)]).expect("Failed to create InviteeRow") |
||||
} |
||||
|
||||
pub fn user(&self) -> Option<Invitee> { |
||||
let priv_ = imp::InviteeRow::from_instance(self); |
||||
priv_.user.borrow().clone() |
||||
} |
||||
|
||||
pub fn set_user(&self, user: Option<Invitee>) { |
||||
let priv_ = imp::InviteeRow::from_instance(self); |
||||
|
||||
if self.user() == user { |
||||
return; |
||||
} |
||||
|
||||
if let Some(binding) = priv_.binding.take() { |
||||
binding.unbind(); |
||||
} |
||||
|
||||
if let Some(ref user) = user { |
||||
// We can't use `gtk::Expression` because we need a bidirectional binding
|
||||
let binding = user |
||||
.bind_property("invited", &*priv_.check_button, "active") |
||||
.flags(glib::BindingFlags::BIDIRECTIONAL | glib::BindingFlags::SYNC_CREATE) |
||||
.build() |
||||
.unwrap(); |
||||
|
||||
priv_.binding.replace(Some(binding)); |
||||
} |
||||
|
||||
priv_.user.replace(user); |
||||
self.notify("user"); |
||||
} |
||||
} |
||||
@ -0,0 +1,344 @@
|
||||
use adw::subclass::prelude::*; |
||||
use gtk::{gdk, glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate}; |
||||
|
||||
mod invitee; |
||||
use self::invitee::Invitee; |
||||
mod invitee_list; |
||||
mod invitee_row; |
||||
use self::invitee_list::{InviteeList, InviteeListState}; |
||||
use self::invitee_row::InviteeRow; |
||||
use crate::components::Pill; |
||||
|
||||
use crate::components::SpinnerButton; |
||||
use crate::session::User; |
||||
use crate::spawn; |
||||
|
||||
use crate::session::content::RoomDetails; |
||||
use crate::session::Room; |
||||
|
||||
mod imp { |
||||
use super::*; |
||||
use glib::subclass::InitializingObject; |
||||
use std::cell::RefCell; |
||||
|
||||
#[derive(Debug, Default, CompositeTemplate)] |
||||
#[template(resource = "/org/gnome/FractalNext/content-invite-subpage.ui")] |
||||
pub struct InviteSubpage { |
||||
pub room: RefCell<Option<Room>>, |
||||
#[template_child] |
||||
pub list_view: TemplateChild<gtk::ListView>, |
||||
#[template_child] |
||||
pub text_buffer: TemplateChild<gtk::TextBuffer>, |
||||
#[template_child] |
||||
pub invite_button: TemplateChild<SpinnerButton>, |
||||
#[template_child] |
||||
pub cancel_button: TemplateChild<gtk::Button>, |
||||
#[template_child] |
||||
pub text_view: TemplateChild<gtk::TextView>, |
||||
#[template_child] |
||||
pub stack: TemplateChild<gtk::Stack>, |
||||
#[template_child] |
||||
pub matching_page: TemplateChild<gtk::ScrolledWindow>, |
||||
#[template_child] |
||||
pub no_matching_page: TemplateChild<adw::StatusPage>, |
||||
#[template_child] |
||||
pub no_search_page: TemplateChild<adw::StatusPage>, |
||||
#[template_child] |
||||
pub error_page: TemplateChild<adw::StatusPage>, |
||||
#[template_child] |
||||
pub loading_page: TemplateChild<gtk::Spinner>, |
||||
} |
||||
|
||||
#[glib::object_subclass] |
||||
impl ObjectSubclass for InviteSubpage { |
||||
const NAME: &'static str = "ContentInviteSubpage"; |
||||
type Type = super::InviteSubpage; |
||||
type ParentType = adw::Bin; |
||||
|
||||
fn class_init(klass: &mut Self::Class) { |
||||
InviteeRow::static_type(); |
||||
Self::bind_template(klass); |
||||
|
||||
klass.add_binding( |
||||
gdk::keys::constants::Escape, |
||||
gdk::ModifierType::empty(), |
||||
|obj, _| { |
||||
obj.close(); |
||||
true |
||||
}, |
||||
None, |
||||
); |
||||
} |
||||
|
||||
fn instance_init(obj: &InitializingObject<Self>) { |
||||
obj.init_template(); |
||||
} |
||||
} |
||||
|
||||
impl ObjectImpl for InviteSubpage { |
||||
fn properties() -> &'static [glib::ParamSpec] { |
||||
use once_cell::sync::Lazy; |
||||
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| { |
||||
vec![glib::ParamSpec::new_object( |
||||
"room", |
||||
"Room", |
||||
"The room users will be invited to", |
||||
Room::static_type(), |
||||
glib::ParamFlags::READWRITE, |
||||
)] |
||||
}); |
||||
|
||||
PROPERTIES.as_ref() |
||||
} |
||||
|
||||
fn set_property( |
||||
&self, |
||||
obj: &Self::Type, |
||||
_id: usize, |
||||
value: &glib::Value, |
||||
pspec: &glib::ParamSpec, |
||||
) { |
||||
match pspec.name() { |
||||
"room" => obj.set_room(value.get().unwrap()), |
||||
_ => unimplemented!(), |
||||
} |
||||
} |
||||
|
||||
fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { |
||||
match pspec.name() { |
||||
"room" => obj.room().to_value(), |
||||
_ => unimplemented!(), |
||||
} |
||||
} |
||||
|
||||
fn constructed(&self, obj: &Self::Type) { |
||||
self.parent_constructed(obj); |
||||
|
||||
self.cancel_button |
||||
.connect_clicked(clone!(@weak obj => move |_| { |
||||
obj.close(); |
||||
})); |
||||
|
||||
self.text_buffer.connect_delete_range(clone!(@weak obj => move |_, start, end| { |
||||
let mut current = start.clone(); |
||||
loop { |
||||
if let Some(anchor) = current.child_anchor() { |
||||
let user = anchor.widgets()[0].downcast_ref::<Pill>().unwrap().user().unwrap().downcast::<Invitee>().unwrap(); |
||||
user.take_anchor(); |
||||
user.set_invited(false); |
||||
} |
||||
|
||||
current.forward_char(); |
||||
|
||||
if ¤t == end { |
||||
break; |
||||
} |
||||
} |
||||
})); |
||||
|
||||
self.text_buffer.connect_insert_text( |
||||
clone!(@weak obj => move |text_buffer, location, text| { |
||||
let mut changed = false; |
||||
|
||||
// We don't allow adding chars before and between pills
|
||||
loop { |
||||
if location.child_anchor().is_some() { |
||||
changed = true; |
||||
if !location.forward_char() { |
||||
break; |
||||
} |
||||
} else { |
||||
break; |
||||
} |
||||
} |
||||
|
||||
if changed { |
||||
text_buffer.place_cursor(location); |
||||
text_buffer.stop_signal_emission("insert-text"); |
||||
text_buffer.insert(location, text); |
||||
} |
||||
}), |
||||
); |
||||
|
||||
self.invite_button |
||||
.connect_clicked(clone!(@weak obj => move |_| { |
||||
obj.invite(); |
||||
})); |
||||
|
||||
self.list_view.connect_activate(|list_view, index| { |
||||
let invitee = list_view |
||||
.model() |
||||
.unwrap() |
||||
.item(index) |
||||
.unwrap() |
||||
.downcast::<Invitee>() |
||||
.unwrap(); |
||||
|
||||
invitee.set_invited(!invitee.is_invited()); |
||||
}); |
||||
} |
||||
} |
||||
|
||||
impl WidgetImpl for InviteSubpage {} |
||||
impl BinImpl for InviteSubpage {} |
||||
} |
||||
|
||||
glib::wrapper! { |
||||
/// Preference Window to display and update room details.
|
||||
pub struct InviteSubpage(ObjectSubclass<imp::InviteSubpage>) |
||||
@extends gtk::Widget, gtk::Window, adw::Window, adw::Bin, @implements gtk::Accessible; |
||||
} |
||||
|
||||
impl InviteSubpage { |
||||
pub fn new(room: &Room) -> Self { |
||||
glib::Object::new(&[("room", room)]).expect("Failed to create InviteSubpage") |
||||
} |
||||
|
||||
pub fn room(&self) -> Option<Room> { |
||||
let priv_ = imp::InviteSubpage::from_instance(self); |
||||
priv_.room.borrow().clone() |
||||
} |
||||
|
||||
fn set_room(&self, room: Option<Room>) { |
||||
let priv_ = imp::InviteSubpage::from_instance(self); |
||||
|
||||
if self.room() == room { |
||||
return; |
||||
} |
||||
|
||||
if let Some(ref room) = room { |
||||
let user_list = InviteeList::new(&room); |
||||
user_list.connect_invitee_added(clone!(@weak self as obj => move |_, invitee| { |
||||
obj.add_user_pill(invitee); |
||||
})); |
||||
|
||||
user_list.connect_invitee_removed(clone!(@weak self as obj => move |_, invitee| { |
||||
obj.remove_user_pill(invitee); |
||||
})); |
||||
|
||||
user_list.connect_notify_local( |
||||
Some("state"), |
||||
clone!(@weak self as obj => move |_, _| { |
||||
obj.update_view(); |
||||
}), |
||||
); |
||||
|
||||
priv_ |
||||
.text_buffer |
||||
.bind_property("text", &user_list, "search-term") |
||||
.flags(glib::BindingFlags::SYNC_CREATE) |
||||
.build() |
||||
.unwrap(); |
||||
|
||||
user_list |
||||
.bind_property("has-selected", &*priv_.invite_button, "sensitive") |
||||
.flags(glib::BindingFlags::SYNC_CREATE) |
||||
.build() |
||||
.unwrap(); |
||||
|
||||
priv_ |
||||
.list_view |
||||
.set_model(Some(>k::NoSelection::new(Some(&user_list)))); |
||||
} else { |
||||
priv_.list_view.set_model(gtk::NONE_SELECTION_MODEL); |
||||
} |
||||
|
||||
priv_.room.replace(room); |
||||
self.notify("room"); |
||||
} |
||||
|
||||
fn close(&self) { |
||||
let window = self.root().unwrap().downcast::<RoomDetails>().unwrap(); |
||||
window.close_invite_subpage(); |
||||
} |
||||
|
||||
fn add_user_pill(&self, user: &Invitee) { |
||||
let priv_ = imp::InviteSubpage::from_instance(self); |
||||
|
||||
let pill = Pill::new(); |
||||
pill.set_margin_start(3); |
||||
pill.set_margin_end(3); |
||||
pill.set_user(Some(user.clone().upcast())); |
||||
|
||||
let (mut start_iter, mut end_iter) = priv_.text_buffer.bounds(); |
||||
|
||||
// We don't allow adding chars before and between pills
|
||||
loop { |
||||
if start_iter.child_anchor().is_some() { |
||||
start_iter.forward_char(); |
||||
} else { |
||||
break; |
||||
} |
||||
} |
||||
|
||||
priv_.text_buffer.delete(&mut start_iter, &mut end_iter); |
||||
let anchor = priv_.text_buffer.create_child_anchor(&mut start_iter); |
||||
priv_.text_view.add_child_at_anchor(&pill, &anchor); |
||||
user.set_anchor(Some(anchor)); |
||||
|
||||
priv_.text_view.grab_focus(); |
||||
} |
||||
|
||||
fn remove_user_pill(&self, user: &Invitee) { |
||||
let priv_ = imp::InviteSubpage::from_instance(self); |
||||
|
||||
if let Some(anchor) = user.take_anchor() { |
||||
if !anchor.is_deleted() { |
||||
let mut start_iter = priv_.text_buffer.iter_at_child_anchor(&anchor); |
||||
let mut end_iter = start_iter.clone(); |
||||
end_iter.forward_char(); |
||||
priv_.text_buffer.delete(&mut start_iter, &mut end_iter); |
||||
} |
||||
} |
||||
} |
||||
|
||||
fn invitee_list(&self) -> Option<InviteeList> { |
||||
let priv_ = imp::InviteSubpage::from_instance(self); |
||||
|
||||
priv_ |
||||
.list_view |
||||
.model()? |
||||
.downcast::<gtk::NoSelection>() |
||||
.unwrap() |
||||
.model() |
||||
.unwrap() |
||||
.downcast::<InviteeList>() |
||||
.ok() |
||||
} |
||||
|
||||
fn invite(&self) { |
||||
let priv_ = imp::InviteSubpage::from_instance(self); |
||||
|
||||
priv_.invite_button.set_loading(true); |
||||
if let Some(room) = self.room() { |
||||
if let Some(user_list) = self.invitee_list() { |
||||
let invitees: Vec<User> = user_list |
||||
.invitees() |
||||
.into_iter() |
||||
.map(glib::object::Cast::upcast) |
||||
.collect(); |
||||
spawn!(clone!(@weak self as obj => async move { |
||||
let priv_ = imp::InviteSubpage::from_instance(&obj); |
||||
room.invite(invitees.as_slice()).await; |
||||
obj.close(); |
||||
priv_.invite_button.set_loading(false); |
||||
})); |
||||
} |
||||
} |
||||
} |
||||
|
||||
fn update_view(&self) { |
||||
let priv_ = imp::InviteSubpage::from_instance(self); |
||||
match self |
||||
.invitee_list() |
||||
.expect("Can't update view without an InviteeList") |
||||
.state() |
||||
{ |
||||
InviteeListState::Initial => priv_.stack.set_visible_child(&*priv_.no_search_page), |
||||
InviteeListState::Loading => priv_.stack.set_visible_child(&*priv_.loading_page), |
||||
InviteeListState::NoMatching => priv_.stack.set_visible_child(&*priv_.no_matching_page), |
||||
InviteeListState::Matching => priv_.stack.set_visible_child(&*priv_.matching_page), |
||||
InviteeListState::Error => priv_.stack.set_visible_child(&*priv_.error_page), |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue