Browse Source

content: Add invitation widget and implement accept/reject

To fully work this will need some work on the sdk side, since the
inviter isn't available after dropping a SyncResponse, therefore after a
restart the inviter is forgotten. Also the display name isn't shown
always correclty because the sdk doesn't calculate it correclty.
merge-requests/1327/merge
Julian Sparber 5 years ago
parent
commit
fc5f97448a
  1. 1
      data/resources/resources.gresource.xml
  2. 4
      data/resources/style.css
  3. 138
      data/resources/ui/content-invite.ui
  4. 10
      data/resources/ui/content.ui
  5. 2
      po/POTFILES.in
  6. 1
      src/meson.build
  7. 43
      src/session/content/content.rs
  8. 261
      src/session/content/invite.rs
  9. 2
      src/session/content/mod.rs
  10. 21
      src/session/mod.rs
  11. 83
      src/session/room/room.rs
  12. 39
      src/session/user.rs

1
data/resources/resources.gresource.xml

@ -9,6 +9,7 @@
<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-state-row.ui">ui/content-state-row.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="content-markdown-popover.ui">ui/content-markdown-popover.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="content-invite.ui">ui/content-invite.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="login.ui">ui/login.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="session.ui">ui/session.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="sidebar.ui">ui/sidebar.ui</file>

4
data/resources/style.css

@ -145,3 +145,7 @@ headerbar.flat {
border: 2px solid @theme_selected_bg_color;
padding: 5px;
}
.invite-room-name {
font-size: 24px;
}

138
data/resources/ui/content-invite.ui

@ -0,0 +1,138 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="ContentInvite" parent="AdwBin">
<property name="vexpand">True</property>
<property name="hexpand">True</property>
<child>
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="AdwHeaderBar" id="headerbar">
<property name="show-start-title-buttons" bind-source="ContentInvite" bind-property="compact" bind-flags="sync-create"/>
<child type="start">
<object class="GtkButton" id="back">
<property name="visible" bind-source="ContentInvite" bind-property="compact" bind-flags="sync-create"/>
<property name="icon-name">go-previous-symbolic</property>
<property name="action-name">content.go-back</property>
</object>
</child>
<child type="title">
<object class="AdwWindowTitle">
<property name="title" translatable="yes">Invite</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="vexpand">True</property>
<property name="hscrollbar-policy">never</property>
<property name="child">
<object class="AdwClamp">
<property name="maximum-size">400</property>
<property name="tightening-threshold">200</property>
<property name="vexpand">True</property>
<property name="margin-top">24</property>
<property name="margin-bottom">24</property>
<property name="margin-start">24</property>
<property name="margin-end">24</property>
<property name="child">
<object class="GtkBox">
<property name="valign">center</property>
<property name="halign">center</property>
<property name="spacing">24</property>
<property name="orientation">vertical</property>
<accessibility>
<property name="label" translatable="yes">Invite</property>
</accessibility>
<child>
<object class="AdwAvatar">
<property name="show-initials">True</property>
<property name="size">150</property>
<property name="text" bind-source="display_name" bind-property="label" bind-flags="sync-create"/>
</object>
</child>
<child>
<object class="GtkLabel" id="display_name">
<property name="ellipsize">end</property>
<binding name="label">
<lookup name="display-name">
<lookup name="room">ContentInvite</lookup>
</lookup>
</binding>
<style>
<class name="invite-room-name"/>
</style>
</object>
</child>
<child>
<object class="GtkLabel" id="room_topic">
<property name="wrap">True</property>
<property name="justify">center</property>
<binding name="label">
<lookup name="topic">
<lookup name="room">ContentInvite</lookup>
</lookup>
</binding>
<style>
<class name="dim-label"/>
</style>
</object>
</child>
<child>
<object class="GtkBox">
<property name="halign">center</property>
<child>
<object class="UserPill" id="inviter">
<binding name="user">
<lookup name="inviter">
<lookup name="room">ContentInvite</lookup>
</lookup>
</binding>
</object>
</child>
<child>
<object class="GtkLabel">
<!-- Translators: the space at the beginning is there on purpose -->
<property name="label" translatable="yes"> invited you</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox">
<property name="halign">center</property>
<property name="spacing">24</property>
<property name="margin-top">24</property>
<child>
<object class="SpinnerButton" id="reject_button">
<property name="label" translatable="yes">_Reject</property>
<property name="action-name">invite.reject</property>
<style>
<class name="pill-button"/>
</style>
</object>
</child>
<child>
<object class="SpinnerButton" id="accept_button">
<property name="label" translatable="yes">_Accept</property>
<property name="action-name">invite.accept</property>
<style>
<class name="suggested-action"/>
<class name="pill-button"/>
</style>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</property>
</object>
</child>
</object>
</child>
</template>
</interface>

10
data/resources/ui/content.ui

@ -4,9 +4,15 @@
<property name="vexpand">True</property>
<property name="hexpand">True</property>
<property name="child">
<object class="GtkStack">
<object class="GtkStack" id="stack">
<child>
<object class="ContentRoomHistory">
<object class="ContentRoomHistory" id="room_history">
<property name="compact" bind-source="Content" bind-property="compact" bind-flags="sync-create"/>
<property name="room" bind-source="Content" bind-property="room" bind-flags="sync-create"/>
</object>
</child>
<child>
<object class="ContentInvite" id="invite">
<property name="compact" bind-source="Content" bind-property="compact" bind-flags="sync-create"/>
<property name="room" bind-source="Content" bind-property="room" bind-flags="sync-create"/>
</object>

2
po/POTFILES.in

@ -8,6 +8,7 @@ data/org.gnome.FractalNext.metainfo.xml.in.in
data/resources/ui/content-divider-row.ui
data/resources/ui/content-item-row-menu.ui
data/resources/ui/content-item.ui
data/resources/ui/content-invite.ui
data/resources/ui/content-markdown-popover.ui
data/resources/ui/content-message-row.ui
data/resources/ui/content-room-history.ui
@ -41,6 +42,7 @@ src/session/categories/mod.rs
src/session/content/content.rs
src/session/content/divider_row.rs
src/session/content/item_row.rs
src/session/content/invite.rs
src/session/content/markdown_popover.rs
src/session/content/message_row.rs
src/session/content/mod.rs

1
src/meson.build

@ -39,6 +39,7 @@ sources = files(
'session/content/content.rs',
'session/content/divider_row.rs',
'session/content/item_row.rs',
'session/content/invite.rs',
'session/content/markdown_popover.rs',
'session/content/message_row.rs',
'session/content/mod.rs',

43
src/session/content/content.rs

@ -1,10 +1,10 @@
use crate::session::{content::RoomHistory, room::Room};
use crate::session::{categories::CategoryType, content::Invite, content::RoomHistory, room::Room};
use adw::subclass::prelude::*;
use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate};
use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate};
mod imp {
use super::*;
use glib::subclass::InitializingObject;
use glib::{signal::SignalHandlerId, subclass::InitializingObject};
use std::cell::{Cell, RefCell};
#[derive(Debug, Default, CompositeTemplate)]
@ -12,6 +12,13 @@ mod imp {
pub struct Content {
pub compact: Cell<bool>,
pub room: RefCell<Option<Room>>,
pub category_handler: RefCell<Option<SignalHandlerId>>,
#[template_child]
pub stack: TemplateChild<gtk::Stack>,
#[template_child]
pub room_history: TemplateChild<RoomHistory>,
#[template_child]
pub invite: TemplateChild<Invite>,
}
#[glib::object_subclass]
@ -22,6 +29,7 @@ mod imp {
fn class_init(klass: &mut Self::Class) {
RoomHistory::static_type();
Invite::static_type();
Self::bind_template(klass);
klass.set_accessible_role(gtk::AccessibleRole::Group);
@ -110,7 +118,26 @@ impl Content {
return;
}
if let Some(category_handler) = priv_.category_handler.take() {
if let Some(room) = self.room() {
room.disconnect(category_handler);
}
}
if let Some(ref room) = room {
let handler_id = room.connect_notify_local(
Some("category"),
clone!(@weak self as obj => move |room, _| {
obj.set_visible_child(room);
}),
);
self.set_visible_child(&room);
priv_.category_handler.replace(Some(handler_id));
}
priv_.room.replace(room);
self.notify("room");
}
@ -118,4 +145,14 @@ impl Content {
let priv_ = imp::Content::from_instance(self);
priv_.room.borrow().clone()
}
fn set_visible_child(&self, room: &Room) {
let priv_ = imp::Content::from_instance(self);
if room.category() == CategoryType::Invited {
priv_.stack.set_visible_child(&*priv_.invite);
} else {
priv_.stack.set_visible_child(&*priv_.room_history);
}
}
}

261
src/session/content/invite.rs

@ -0,0 +1,261 @@
use crate::{
components::{SpinnerButton, UserPill},
session::{categories::CategoryType, room::Room},
};
use adw::subclass::prelude::*;
use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate};
use gtk_macros::spawn;
use log::error;
mod imp {
use super::*;
use glib::{signal::SignalHandlerId, subclass::InitializingObject};
use std::cell::{Cell, RefCell};
use std::collections::HashSet;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/org/gnome/FractalNext/content-invite.ui")]
pub struct Invite {
pub compact: Cell<bool>,
pub room: RefCell<Option<Room>>,
pub accept_requests: RefCell<HashSet<Room>>,
pub reject_requests: RefCell<HashSet<Room>>,
pub category_handler: RefCell<Option<SignalHandlerId>>,
#[template_child]
pub headerbar: TemplateChild<adw::HeaderBar>,
#[template_child]
pub inviter: TemplateChild<adw::HeaderBar>,
#[template_child]
pub room_topic: TemplateChild<gtk::Label>,
#[template_child]
pub accept_button: TemplateChild<SpinnerButton>,
#[template_child]
pub reject_button: TemplateChild<SpinnerButton>,
}
#[glib::object_subclass]
impl ObjectSubclass for Invite {
const NAME: &'static str = "ContentInvite";
type Type = super::Invite;
type ParentType = adw::Bin;
fn class_init(klass: &mut Self::Class) {
UserPill::static_type();
SpinnerButton::static_type();
Self::bind_template(klass);
klass.set_accessible_role(gtk::AccessibleRole::Group);
klass.install_action("invite.reject", None, move |widget, _, _| {
widget.reject();
});
klass.install_action("invite.accept", None, move |widget, _, _| {
widget.accept();
});
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for Invite {
fn properties() -> &'static [glib::ParamSpec] {
use once_cell::sync::Lazy;
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
vec![
glib::ParamSpec::new_boolean(
"compact",
"Compact",
"Wheter a compact view is used or not",
false,
glib::ParamFlags::READWRITE,
),
glib::ParamSpec::new_object(
"room",
"Room",
"The room currently shown",
Room::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() {
"compact" => {
let compact = value.get().unwrap();
self.compact.set(compact);
}
"room" => {
let room = value.get().unwrap();
obj.set_room(room);
}
_ => unimplemented!(),
}
}
fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
match pspec.name() {
"compact" => self.compact.get().to_value(),
"room" => obj.room().to_value(),
_ => unimplemented!(),
}
}
fn constructed(&self, obj: &Self::Type) {
self.parent_constructed(obj);
self.room_topic
.connect_notify_local(Some("label"), |room_topic, _| {
room_topic.set_visible(!room_topic.label().is_empty());
});
self.room_topic
.set_visible(!self.room_topic.label().is_empty());
}
}
impl WidgetImpl for Invite {}
impl BinImpl for Invite {}
}
glib::wrapper! {
pub struct Invite(ObjectSubclass<imp::Invite>)
@extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
}
impl Invite {
pub fn new() -> Self {
glib::Object::new(&[]).expect("Failed to create Invite")
}
pub fn set_room(&self, room: Option<Room>) {
let priv_ = imp::Invite::from_instance(self);
if self.room() == room {
return;
}
match room {
Some(ref room) if priv_.accept_requests.borrow().contains(room) => {
self.action_set_enabled("invite.accept", false);
self.action_set_enabled("invite.reject", false);
priv_.accept_button.set_loading(true);
}
Some(ref room) if priv_.reject_requests.borrow().contains(room) => {
self.action_set_enabled("invite.accept", false);
self.action_set_enabled("invite.reject", false);
priv_.reject_button.set_loading(true);
}
_ => self.reset(),
}
if let Some(category_handler) = priv_.category_handler.take() {
if let Some(room) = self.room() {
room.disconnect(category_handler);
}
}
// FIXME: remove clousure when room changes
if let Some(ref room) = room {
let handler_id = room.connect_notify_local(
Some("category"),
clone!(@weak self as obj => move |room, _| {
if room.category() != CategoryType::Invited {
let priv_ = imp::Invite::from_instance(&obj);
priv_.reject_requests.borrow_mut().remove(&room);
priv_.accept_requests.borrow_mut().remove(&room);
obj.reset();
if let Some(category_handler) = priv_.category_handler.take() {
room.disconnect(category_handler);
}
}
}),
);
priv_.category_handler.replace(Some(handler_id));
}
priv_.room.replace(room);
self.notify("room");
}
pub fn room(&self) -> Option<Room> {
let priv_ = imp::Invite::from_instance(self);
priv_.room.borrow().clone()
}
fn reset(&self) {
let priv_ = imp::Invite::from_instance(self);
priv_.accept_button.set_loading(false);
priv_.reject_button.set_loading(false);
self.action_set_enabled("invite.accept", true);
self.action_set_enabled("invite.reject", true);
}
fn accept(&self) -> Option<()> {
let priv_ = imp::Invite::from_instance(self);
let room = self.room()?;
self.action_set_enabled("invite.accept", false);
self.action_set_enabled("invite.reject", false);
priv_.accept_button.set_loading(true);
priv_.accept_requests.borrow_mut().insert(room.clone());
spawn!(
clone!(@weak self as obj, @strong room => move || async move {
let priv_ = imp::Invite::from_instance(&obj);
let result = room.accept_invite().await;
match result {
Ok(_) => {},
Err(error) => {
// FIXME: display an error to the user
error!("Accepting invitiation failed: {}", error);
priv_.accept_requests.borrow_mut().remove(&room);
obj.reset();
},
}
})()
);
Some(())
}
fn reject(&self) -> Option<()> {
let priv_ = imp::Invite::from_instance(self);
let room = self.room()?;
self.action_set_enabled("invite.accept", false);
self.action_set_enabled("invite.reject", false);
priv_.reject_button.set_loading(true);
priv_.reject_requests.borrow_mut().insert(room.clone());
spawn!(
clone!(@weak self as obj, @strong room => move || async move {
let priv_ = imp::Invite::from_instance(&obj);
let result = room.reject_invite().await;
match result {
Ok(_) => {},
Err(error) => {
// FIXME: display an error to the user
error!("Rejecting invitiation failed: {}", error);
priv_.reject_requests.borrow_mut().remove(&room);
obj.reset();
},
}
})()
);
Some(())
}
}

2
src/session/content/mod.rs

@ -1,5 +1,6 @@
mod content;
mod divider_row;
mod invite;
mod item_row;
mod markdown_popover;
mod message_row;
@ -8,6 +9,7 @@ mod state_row;
pub use self::content::Content;
use self::divider_row::DividerRow;
use self::invite::Invite;
use self::item_row::ItemRow;
use self::markdown_popover::MarkdownPopover;
use self::message_row::MessageRow;

21
src/session/mod.rs

@ -466,6 +466,25 @@ impl Session {
);
}
}
// TODO: handle StrippedStateEvents for invited rooms
for (room_id, matrix_room) in response.rooms.invite {
if let Some(room) = rooms_map.get(&room_id) {
room.handle_invite_events(
matrix_room
.invite_state
.events
.into_iter()
.filter_map(|event| {
if let Ok(event) = event.deserialize() {
Some(event)
} else {
error!("Couldn't deserialize event: {:?}", event);
None
}
})
.collect(),
)
}
}
}
}

83
src/session/room/room.rs

@ -4,13 +4,14 @@ use log::{debug, error, warn};
use matrix_sdk::{
events::{
room::{
member::MemberEventContent,
member::{MemberEventContent, MembershipState},
message::{
EmoteMessageEventContent, MessageEventContent, MessageType, TextMessageEventContent,
},
},
tag::TagName,
AnyMessageEvent, AnyRoomEvent, AnyStateEvent, MessageEvent, StateEvent, Unsigned,
AnyMessageEvent, AnyRoomEvent, AnyStateEvent, AnyStrippedStateEvent, MessageEvent,
StateEvent, Unsigned,
},
identifiers::{EventId, RoomId, UserId},
room::Room as MatrixRoom,
@ -25,6 +26,7 @@ use crate::session::{
User,
};
use crate::utils::do_async;
use crate::RUNTIME;
mod imp {
use super::*;
@ -43,6 +45,8 @@ mod imp {
pub room_members: RefCell<HashMap<UserId, User>>,
/// The user of this room
pub user_id: OnceCell<UserId>,
/// The user who send the invite to this room. This is only set when this room is an invitiation.
pub inviter: RefCell<Option<User>>,
}
#[glib::object_subclass]
@ -77,6 +81,13 @@ mod imp {
User::static_type(),
glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY,
),
glib::ParamSpec::new_object(
"inviter",
"Inviter",
"The user who send the invite to this room, this is only set when this room rapresents an invite",
User::static_type(),
glib::ParamFlags::READABLE,
),
glib::ParamSpec::new_object(
"avatar",
"Avatar",
@ -154,6 +165,7 @@ mod imp {
let matrix_room = matrix_room.as_ref().unwrap();
match pspec.name() {
"user" => obj.user().to_value(),
"inviter" => obj.inviter().to_value(),
"display-name" => obj.display_name().to_value(),
"avatar" => self.avatar.borrow().to_value(),
"timeline" => self.timeline.get().unwrap().to_value(),
@ -348,6 +360,11 @@ impl Room {
.filter(|topic| !topic.is_empty() && topic.find(|c: char| !c.is_whitespace()).is_some())
}
pub fn inviter(&self) -> Option<User> {
let priv_ = imp::Room::from_instance(&self);
priv_.inviter.borrow().clone()
}
/// Returns the room member `User` object
///
/// The returned `User` is specific to this room
@ -361,6 +378,42 @@ impl Room {
.clone()
}
/// Handle stripped state events.
///
/// Events passed to this function arn't added to the timeline.
pub fn handle_invite_events(&self, events: Vec<AnyStrippedStateEvent>) {
let priv_ = imp::Room::from_instance(self);
let invite_event = events
.iter()
.find(|event| {
if let AnyStrippedStateEvent::RoomMember(event) = event {
event.content.membership == MembershipState::Invite
&& event.state_key == self.user().user_id().as_str()
} else {
false
}
})
.unwrap();
let inviter_id = invite_event.sender();
let inviter_event = events.iter().find(|event| {
if let AnyStrippedStateEvent::RoomMember(event) = event {
&event.sender == inviter_id
} else {
false
}
});
let inviter = User::new(inviter_id);
if let Some(AnyStrippedStateEvent::RoomMember(event)) = inviter_event {
inviter.update_from_stripped_member_event(event);
}
priv_.inviter.replace(Some(inviter));
self.notify("inviter");
}
/// Add new events to the timeline
pub fn append_events(&self, batch: Vec<AnyRoomEvent>) {
let priv_ = imp::Room::from_instance(self);
@ -507,4 +560,30 @@ impl Room {
);
}
}
pub async fn accept_invite(&self) -> matrix_sdk::Result<()> {
let matrix_room = self.matrix_room();
if let MatrixRoom::Invited(matrix_room) = matrix_room {
let (sender, receiver) = futures::channel::oneshot::channel();
RUNTIME.spawn(async move { sender.send(matrix_room.accept_invitation().await) });
receiver.await.unwrap()
} else {
error!("Can't accept invite, because this room isn't an invited room");
Ok(())
}
}
pub async fn reject_invite(&self) -> matrix_sdk::Result<()> {
let matrix_room = self.matrix_room();
if let MatrixRoom::Invited(matrix_room) = matrix_room {
let (sender, receiver) = futures::channel::oneshot::channel();
RUNTIME.spawn(async move { sender.send(matrix_room.reject_invitation().await) });
receiver.await.unwrap()
} else {
error!("Can't reject invite, because this room isn't an invited room");
Ok(())
}
}
}

39
src/session/user.rs

@ -1,7 +1,7 @@
use gtk::{gio, glib, prelude::*, subclass::prelude::*};
use matrix_sdk::{
events::{room::member::MemberEventContent, StateEvent},
events::{room::member::MemberEventContent, StateEvent, StrippedStateEvent},
identifiers::UserId,
RoomMember,
};
@ -174,4 +174,41 @@ impl User {
self.notify("display-name");
}
}
/// Update the user based on the the stripped room member state event
//TODO: create the GLoadableIcon and set `avatar`
pub fn update_from_stripped_member_event(
&self,
event: &StrippedStateEvent<MemberEventContent>,
) {
let changed = {
let priv_ = imp::User::from_instance(&self);
let user_id = priv_.user_id.get().unwrap();
if event.sender.as_str() != user_id {
return;
};
let display_name = if let Some(display_name) = &event.content.displayname {
Some(display_name.to_owned())
} else {
event
.content
.third_party_invite
.as_ref()
.map(|i| i.display_name.to_owned())
};
let mut current_display_name = priv_.display_name.borrow_mut();
if *current_display_name != display_name {
*current_display_name = display_name;
true
} else {
false
}
};
if changed {
self.notify("display-name");
}
}
}

Loading…
Cancel
Save