diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index 0d47f3e8..7e7b6a2b 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -9,6 +9,7 @@ ui/content-divider-row.ui ui/content-state-row.ui ui/content-markdown-popover.ui + ui/content-invite.ui ui/login.ui ui/session.ui ui/sidebar.ui diff --git a/data/resources/style.css b/data/resources/style.css index 3f8b404e..b4e3ba64 100644 --- a/data/resources/style.css +++ b/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; +} diff --git a/data/resources/ui/content-invite.ui b/data/resources/ui/content-invite.ui new file mode 100644 index 00000000..4902d65b --- /dev/null +++ b/data/resources/ui/content-invite.ui @@ -0,0 +1,138 @@ + + + + + diff --git a/data/resources/ui/content.ui b/data/resources/ui/content.ui index 332a6054..f30599b8 100644 --- a/data/resources/ui/content.ui +++ b/data/resources/ui/content.ui @@ -4,9 +4,15 @@ True True - + - + + + + + + + diff --git a/po/POTFILES.in b/po/POTFILES.in index 1e51db6c..9ad215dd 100644 --- a/po/POTFILES.in +++ b/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 diff --git a/src/meson.build b/src/meson.build index 5a53a121..38b184fa 100644 --- a/src/meson.build +++ b/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', diff --git a/src/session/content/content.rs b/src/session/content/content.rs index 901dce20..af97a437 100644 --- a/src/session/content/content.rs +++ b/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, pub room: RefCell>, + pub category_handler: RefCell>, + #[template_child] + pub stack: TemplateChild, + #[template_child] + pub room_history: TemplateChild, + #[template_child] + pub invite: TemplateChild, } #[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); + } + } } diff --git a/src/session/content/invite.rs b/src/session/content/invite.rs new file mode 100644 index 00000000..3b3fa2c8 --- /dev/null +++ b/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, + pub room: RefCell>, + pub accept_requests: RefCell>, + pub reject_requests: RefCell>, + pub category_handler: RefCell>, + #[template_child] + pub headerbar: TemplateChild, + #[template_child] + pub inviter: TemplateChild, + #[template_child] + pub room_topic: TemplateChild, + #[template_child] + pub accept_button: TemplateChild, + #[template_child] + pub reject_button: TemplateChild, + } + + #[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) { + obj.init_template(); + } + } + + impl ObjectImpl for Invite { + fn properties() -> &'static [glib::ParamSpec] { + use once_cell::sync::Lazy; + static PROPERTIES: Lazy> = 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) + @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) { + 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 { + 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(()) + } +} diff --git a/src/session/content/mod.rs b/src/session/content/mod.rs index 2aa007f0..3109a2ed 100644 --- a/src/session/content/mod.rs +++ b/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; diff --git a/src/session/mod.rs b/src/session/mod.rs index fd4038e9..6cef602e 100644 --- a/src/session/mod.rs +++ b/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(), + ) + } + } } } diff --git a/src/session/room/room.rs b/src/session/room/room.rs index 0748cb5a..63e7f045 100644 --- a/src/session/room/room.rs +++ b/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>, /// The user of this room pub user_id: OnceCell, + /// The user who send the invite to this room. This is only set when this room is an invitiation. + pub inviter: RefCell>, } #[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 { + 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) { + 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) { 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(()) + } + } } diff --git a/src/session/user.rs b/src/session/user.rs index 41fa12db..d31b8592 100644 --- a/src/session/user.rs +++ b/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, + ) { + 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"); + } + } }