Browse Source

room-details: Implement user invitiation

merge-requests/1327/merge
Julian Sparber 4 years ago
parent
commit
a2fd4de501
  1. 3
      data/resources/resources.gresource.xml
  2. 2
      data/resources/style.css
  3. 152
      data/resources/ui/content-invite-subpage.ui
  4. 15
      data/resources/ui/content-invitee-item.ui
  5. 68
      data/resources/ui/content-invitee-row.ui
  6. 2
      data/resources/ui/content-member-page.ui
  7. 1
      data/resources/ui/pill.ui
  8. 4
      po/POTFILES.in
  9. 4
      src/meson.build
  10. 136
      src/session/content/room_details/invite_subpage/invitee.rs
  11. 386
      src/session/content/room_details/invite_subpage/invitee_list.rs
  12. 117
      src/session/content/room_details/invite_subpage/invitee_row.rs
  13. 344
      src/session/content/room_details/invite_subpage/mod.rs
  14. 14
      src/session/content/room_details/member_page.rs
  15. 15
      src/session/content/room_details/mod.rs
  16. 60
      src/session/room/mod.rs

3
data/resources/resources.gresource.xml

@ -13,6 +13,9 @@
<file compressed="true" preprocess="xml-stripblanks" alias="content-message-file.ui">ui/content-message-file.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="content-member-page.ui">ui/content-member-page.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="content-member-row.ui">ui/content-member-row.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="content-invite-subpage.ui">ui/content-invite-subpage.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="content-invitee-item.ui">ui/content-invitee-item.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="content-invitee-row.ui">ui/content-invitee-row.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="content-message-row.ui">ui/content-message-row.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="content-divider-row.ui">ui/content-divider-row.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="content-room-details.ui">ui/content-room-details.ui</file>

2
data/resources/style.css

@ -237,7 +237,7 @@ headerbar.flat {
color: @theme_text_color;
}
.message-entry .view {
.view {
padding: 7px 0;
}

152
data/resources/ui/content-invite-subpage.ui

@ -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>

15
data/resources/ui/content-invitee-item.ui

@ -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>

68
data/resources/ui/content-invitee-row.ui

@ -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>

2
data/resources/ui/content-member-page.ui

@ -23,8 +23,6 @@
<object class="GtkButton" id="invite_button">
<property name="label" translatable="yes">Invite new member</property>
<property name="halign">end</property>
<!-- Make the invite button invisible for now till we implement the invite dialog -->
<property name="visible">False</property>
</object>
</child>
</object>

1
data/resources/ui/pill.ui

@ -16,7 +16,6 @@
</child>
<child>
<object class="GtkLabel" id="display_name">
<property name="ellipsize">middle</property>
<property name="max-width-chars">30</property>
</object>
</child>

4
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

4
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',

136
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<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");
}
}

386
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<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()
}
}

117
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<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");
}
}

344
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<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 &current == 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(&gtk::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),
}
}
}

14
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::<RoomDetails>()
.unwrap();
window.present_invite_subpage();
}));
}
}

15
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<imp::RoomDetails>)
@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();
}
}

60
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<UserId> = 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<User> = 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 <widget> to <widget>. Try again later.")
} else if no_failed == 2 {
gettext("Failed to invite <widget> and some other user to <widget>. Try again later.")
} else {
gettext("Failed to invite <widget> and some other users to <widget>. 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 {

Loading…
Cancel
Save