From a2fd4de50102efdb6fab1127a7eb2f82aaae4bbd Mon Sep 17 00:00:00 2001 From: Julian Sparber Date: Mon, 6 Dec 2021 11:40:47 +0100 Subject: [PATCH] room-details: Implement user invitiation --- data/resources/resources.gresource.xml | 3 + data/resources/style.css | 2 +- data/resources/ui/content-invite-subpage.ui | 152 +++++++ data/resources/ui/content-invitee-item.ui | 15 + data/resources/ui/content-invitee-row.ui | 68 +++ data/resources/ui/content-member-page.ui | 2 - data/resources/ui/pill.ui | 1 - po/POTFILES.in | 4 + src/meson.build | 4 + .../room_details/invite_subpage/invitee.rs | 136 ++++++ .../invite_subpage/invitee_list.rs | 386 ++++++++++++++++++ .../invite_subpage/invitee_row.rs | 117 ++++++ .../room_details/invite_subpage/mod.rs | 344 ++++++++++++++++ .../content/room_details/member_page.rs | 14 +- src/session/content/room_details/mod.rs | 15 +- src/session/room/mod.rs | 60 +++ 16 files changed, 1317 insertions(+), 6 deletions(-) create mode 100644 data/resources/ui/content-invite-subpage.ui create mode 100644 data/resources/ui/content-invitee-item.ui create mode 100644 data/resources/ui/content-invitee-row.ui create mode 100644 src/session/content/room_details/invite_subpage/invitee.rs create mode 100644 src/session/content/room_details/invite_subpage/invitee_list.rs create mode 100644 src/session/content/room_details/invite_subpage/invitee_row.rs create mode 100644 src/session/content/room_details/invite_subpage/mod.rs diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index 8dd8a9e9..8840f519 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -13,6 +13,9 @@ ui/content-message-file.ui ui/content-member-page.ui ui/content-member-row.ui + ui/content-invite-subpage.ui + ui/content-invitee-item.ui + ui/content-invitee-row.ui ui/content-message-row.ui ui/content-divider-row.ui ui/content-room-details.ui diff --git a/data/resources/style.css b/data/resources/style.css index 07a5249d..23b12be7 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -237,7 +237,7 @@ headerbar.flat { color: @theme_text_color; } -.message-entry .view { +.view { padding: 7px 0; } diff --git a/data/resources/ui/content-invite-subpage.ui b/data/resources/ui/content-invite-subpage.ui new file mode 100644 index 00000000..1bc2c339 --- /dev/null +++ b/data/resources/ui/content-invite-subpage.ui @@ -0,0 +1,152 @@ + + + + + diff --git a/data/resources/ui/content-invitee-item.ui b/data/resources/ui/content-invitee-item.ui new file mode 100644 index 00000000..07f1ad3c --- /dev/null +++ b/data/resources/ui/content-invitee-item.ui @@ -0,0 +1,15 @@ + + + + + diff --git a/data/resources/ui/content-invitee-row.ui b/data/resources/ui/content-invitee-row.ui new file mode 100644 index 00000000..b721bfa5 --- /dev/null +++ b/data/resources/ui/content-invitee-row.ui @@ -0,0 +1,68 @@ + + + + + diff --git a/data/resources/ui/content-member-page.ui b/data/resources/ui/content-member-page.ui index 91b97611..b7672eb0 100644 --- a/data/resources/ui/content-member-page.ui +++ b/data/resources/ui/content-member-page.ui @@ -23,8 +23,6 @@ Invite new member end - - False diff --git a/data/resources/ui/pill.ui b/data/resources/ui/pill.ui index 08c3d298..4592c9c4 100644 --- a/data/resources/ui/pill.ui +++ b/data/resources/ui/pill.ui @@ -16,7 +16,6 @@ - middle 30 diff --git a/po/POTFILES.in b/po/POTFILES.in index eabeb42a..ff8fc197 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -77,6 +77,10 @@ src/session/categories/mod.rs src/session/content/invite.rs src/session/content/markdown_popover.rs src/session/content/mod.rs +src/session/content/room_details/invite_subpage/invitee.rs +src/session/content/room_details/invite_subpage/mod.rs +src/session/content/room_details/invite_subpage/invitee_list.rs +src/session/content/room_details/invite_subpage/invitee_row.rs src/session/content/room_details/member_page.rs src/session/content/room_details/mod.rs src/session/content/room_history/divider_row.rs diff --git a/src/meson.build b/src/meson.build index 8de9ad9c..5b1a394d 100644 --- a/src/meson.build +++ b/src/meson.build @@ -71,6 +71,10 @@ sources = files( 'session/content/room_history/state_row/mod.rs', 'session/content/room_history/state_row/tombstone.rs', 'session/content/mod.rs', + 'session/content/room_details/invite_subpage/invitee.rs', + 'session/content/room_details/invite_subpage/mod.rs', + 'session/content/room_details/invite_subpage/invitee_list.rs', + 'session/content/room_details/invite_subpage/invitee_row.rs', 'session/content/room_details/member_page.rs', 'session/content/room_details/mod.rs', 'session/media_viewer.rs', diff --git a/src/session/content/room_details/invite_subpage/invitee.rs b/src/session/content/room_details/invite_subpage/invitee.rs new file mode 100644 index 00000000..16928116 --- /dev/null +++ b/src/session/content/room_details/invite_subpage/invitee.rs @@ -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, + pub anchor: RefCell>, + } + + #[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> = 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) @extends User; +} + +impl Invitee { + pub fn new( + session: &Session, + user_id: &UserId, + display_name: Option<&str>, + avatar_url: Option, + ) -> 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 { + let priv_ = imp::Invitee::from_instance(self); + priv_.anchor.borrow().clone() + } + + pub fn take_anchor(&self) -> Option { + let priv_ = imp::Invitee::from_instance(self); + let anchor = priv_.anchor.take(); + self.notify("anchor"); + anchor + } + + pub fn set_anchor(&self, anchor: Option) { + let priv_ = imp::Invitee::from_instance(self); + + if self.anchor() == anchor { + return; + } + + priv_.anchor.replace(anchor); + self.notify("anchor"); + } +} diff --git a/src/session/content/room_details/invite_subpage/invitee_list.rs b/src/session/content/room_details/invite_subpage/invitee_list.rs new file mode 100644 index 00000000..cac22810 --- /dev/null +++ b/src/session/content/room_details/invite_subpage/invitee_list.rs @@ -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>, + pub room: OnceCell, + pub state: Cell, + pub search_term: RefCell>, + pub invitee_list: RefCell>, + pub abort_handle: RefCell>, + } + + #[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> = 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> = 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::().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 { + self.list + .borrow() + .get(position as usize) + .map(glib::object::Cast::upcast_ref::) + .cloned() + } + } +} + +glib::wrapper! { + /// List of users matching the `search term`. + pub struct InviteeList(ObjectSubclass) + @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) { + 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 { + 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) { + 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, + ) { + 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 = 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 { + 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 { + 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( + &self, + f: F, + ) -> glib::SignalHandlerId { + self.connect_local("invitee-added", true, move |values| { + let obj = values[0].get::().unwrap(); + let invitee = values[1].get::().unwrap(); + f(&obj, &invitee); + None + }) + .unwrap() + } + + pub fn connect_invitee_removed( + &self, + f: F, + ) -> glib::SignalHandlerId { + self.connect_local("invitee-removed", true, move |values| { + let obj = values[0].get::().unwrap(); + let invitee = values[1].get::().unwrap(); + f(&obj, &invitee); + None + }) + .unwrap() + } +} diff --git a/src/session/content/room_details/invite_subpage/invitee_row.rs b/src/session/content/room_details/invite_subpage/invitee_row.rs new file mode 100644 index 00000000..40c4a21e --- /dev/null +++ b/src/session/content/room_details/invite_subpage/invitee_row.rs @@ -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>, + pub binding: RefCell>, + #[template_child] + pub check_button: TemplateChild, + } + + #[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) { + obj.init_template(); + } + } + + impl ObjectImpl for InviteeRow { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = 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) + @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 { + let priv_ = imp::InviteeRow::from_instance(self); + priv_.user.borrow().clone() + } + + pub fn set_user(&self, user: Option) { + 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"); + } +} diff --git a/src/session/content/room_details/invite_subpage/mod.rs b/src/session/content/room_details/invite_subpage/mod.rs new file mode 100644 index 00000000..a8490b74 --- /dev/null +++ b/src/session/content/room_details/invite_subpage/mod.rs @@ -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>, + #[template_child] + pub list_view: TemplateChild, + #[template_child] + pub text_buffer: TemplateChild, + #[template_child] + pub invite_button: TemplateChild, + #[template_child] + pub cancel_button: TemplateChild, + #[template_child] + pub text_view: TemplateChild, + #[template_child] + pub stack: TemplateChild, + #[template_child] + pub matching_page: TemplateChild, + #[template_child] + pub no_matching_page: TemplateChild, + #[template_child] + pub no_search_page: TemplateChild, + #[template_child] + pub error_page: TemplateChild, + #[template_child] + pub loading_page: TemplateChild, + } + + #[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) { + obj.init_template(); + } + } + + impl ObjectImpl for InviteSubpage { + fn properties() -> &'static [glib::ParamSpec] { + use once_cell::sync::Lazy; + static PROPERTIES: Lazy> = 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::().unwrap().user().unwrap().downcast::().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::() + .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) + @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 { + let priv_ = imp::InviteSubpage::from_instance(self); + priv_.room.borrow().clone() + } + + fn set_room(&self, room: Option) { + 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::().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 { + let priv_ = imp::InviteSubpage::from_instance(self); + + priv_ + .list_view + .model()? + .downcast::() + .unwrap() + .model() + .unwrap() + .downcast::() + .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_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), + } + } +} diff --git a/src/session/content/room_details/member_page.rs b/src/session/content/room_details/member_page.rs index 8c464a29..155d9fda 100644 --- a/src/session/content/room_details/member_page.rs +++ b/src/session/content/room_details/member_page.rs @@ -1,12 +1,13 @@ +use adw::prelude::*; use adw::subclass::prelude::*; use gettextrs::ngettext; use gtk::glib::{self, clone}; -use gtk::prelude::*; use gtk::subclass::prelude::*; use gtk::CompositeTemplate; use crate::components::{Avatar, Badge}; use crate::prelude::*; +use crate::session::content::RoomDetails; use crate::session::room::{Member, RoomAction}; use crate::session::Room; @@ -194,5 +195,16 @@ impl MemberPage { let invite_possible = self.room().new_allowed_expr(RoomAction::Invite); const NONE_OBJECT: Option<&glib::Object> = None; invite_possible.bind(&*priv_.invite_button, "sensitive", NONE_OBJECT); + + priv_ + .invite_button + .connect_clicked(clone!(@weak self as obj => move |_| { + let window = obj + .root() + .unwrap() + .downcast::() + .unwrap(); + window.present_invite_subpage(); + })); } } diff --git a/src/session/content/room_details/mod.rs b/src/session/content/room_details/mod.rs index a1d1ec7c..14d85bce 100644 --- a/src/session/content/room_details/mod.rs +++ b/src/session/content/room_details/mod.rs @@ -1,3 +1,4 @@ +mod invite_subpage; mod member_page; use adw::prelude::*; @@ -11,6 +12,7 @@ use gtk::{ }; use matrix_sdk::ruma::events::EventType; +pub use self::invite_subpage::InviteSubpage; pub use self::member_page::MemberPage; use crate::components::CustomEntry; use crate::session::room::RoomAction; @@ -117,7 +119,7 @@ mod imp { glib::wrapper! { /// Preference Window to display and update room details. pub struct RoomDetails(ObjectSubclass) - @extends gtk::Widget, gtk::Window, adw::Window, adw::PreferencesWindow, @implements gtk::Accessible; + @extends gtk::Widget, gtk::Window, adw::Window, gtk::Root, adw::PreferencesWindow, @implements gtk::Accessible; } impl RoomDetails { @@ -245,4 +247,15 @@ impl RoomDetails { fn open_avatar_chooser(&self) { self.avatar_chooser().show(); } + + pub fn present_invite_subpage(&self) { + self.set_title(Some(&gettext("Invite new Members"))); + let subpage = InviteSubpage::new(self.room()); + self.present_subpage(&subpage); + } + + pub fn close_invite_subpage(&self) { + self.set_title(Some(&gettext("Room Details"))); + self.close_subpage(); + } } diff --git a/src/session/room/mod.rs b/src/session/room/mod.rs index 89c3209d..085536d8 100644 --- a/src/session/room/mod.rs +++ b/src/session/room/mod.rs @@ -21,6 +21,7 @@ pub use self::power_levels::{ }; pub use self::room_type::RoomType; pub use self::timeline::Timeline; +use crate::session::User; use gettextrs::gettext; use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*}; @@ -1086,6 +1087,65 @@ impl Room { Some(()) } + + pub async fn invite(&self, users: &[User]) { + let matrix_room = self.matrix_room(); + let user_ids: Vec = users.iter().map(|user| user.user_id().to_owned()).collect(); + + if let MatrixRoom::Joined(matrix_room) = matrix_room { + let handle = spawn_tokio!(async move { + let invitiations = user_ids + .iter() + .map(|user_id| matrix_room.invite_user_by_id(user_id)); + futures::future::join_all(invitiations).await + }); + + let mut failed_invites: Vec = Vec::new(); + for (index, result) in handle.await.unwrap().iter().enumerate() { + match result { + Ok(_) => {} + Err(error) => { + error!( + "Failed to invite user with id {}: {}", + users[index].user_id(), + error + ); + failed_invites.push(users[index].clone()); + } + } + } + + if !failed_invites.is_empty() { + let no_failed = failed_invites.len(); + let first_failed = failed_invites.first().unwrap(); + let error = Error::new( + clone!(@strong self as room, @strong first_failed => move |_| { + // TODO: should we show all the failed users? + let error_message = if no_failed == 1 { + gettext("Failed to invite to . Try again later.") + } else if no_failed == 2 { + gettext("Failed to invite and some other user to . Try again later.") + } else { + gettext("Failed to invite and some other users to . Try again later.") + }; + + let user_pill = Pill::new(); + user_pill.set_user(Some(first_failed.clone())); + let room_pill = Pill::new(); + room_pill.set_room(Some(room.clone())); + let error_label = LabelWithWidgets::new(&error_message, vec![user_pill, room_pill]); + Some(error_label.upcast()) + }), + ); + + if let Some(window) = self.session().parent_window() { + window.append_error(&error); + } + } + } else { + error!("Can’t invite users, because this room isn’t a joined room"); + } + } } trait GlibDateTime {