diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index 182b4cfc..60e0b34e 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -39,6 +39,7 @@ ui/account-settings-device-row.ui ui/account-settings-devices-page.ui ui/components-loading-listbox-row.ui + ui/room-creation.ui style.css icons/scalable/actions/send-symbolic.svg icons/scalable/status/welcome.svg diff --git a/data/resources/ui/room-creation.ui b/data/resources/ui/room-creation.ui new file mode 100644 index 00000000..6b6bb6cf --- /dev/null +++ b/data/resources/ui/room-creation.ui @@ -0,0 +1,182 @@ + + + + + diff --git a/data/resources/ui/sidebar.ui b/data/resources/ui/sidebar.ui index 90eb8f62..668ef7ba 100644 --- a/data/resources/ui/sidebar.ui +++ b/data/resources/ui/sidebar.ui @@ -1,6 +1,12 @@ +
+ + _New Room + session.room-creation + +
_Preferences diff --git a/po/POTFILES.in b/po/POTFILES.in index 902998b2..04cb0556 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -27,6 +27,7 @@ data/resources/ui/context-menu-bin.ui data/resources/ui/event-source-dialog.ui data/resources/ui/login.ui data/resources/ui/in-app-notification.ui +data/resources/ui/room-creation.ui data/resources/ui/session.ui data/resources/ui/shortcuts.ui data/resources/ui/sidebar-category-row.ui @@ -54,6 +55,7 @@ src/components/pill.rs src/error.rs src/login.rs src/main.rs +src/matrix_error.rs src/secret.rs src/session/account_settings/devices_page/device.rs src/session/account_settings/devices_page/device_item.rs @@ -74,6 +76,7 @@ src/session/content/room_details/mod.rs src/session/content/room_history.rs src/session/content/state_row.rs src/session/mod.rs +src/session/room_creation/mod.rs src/session/room_list.rs src/session/room/event.rs src/session/room/highlight_flags.rs diff --git a/src/main.rs b/src/main.rs index c90124c0..60984048 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod prelude; mod components; mod error; mod login; +mod matrix_error; mod secret; mod session; mod utils; @@ -18,6 +19,7 @@ mod window; use self::application::Application; use self::error::Error; use self::login::Login; +use self::matrix_error::UserFacingMatrixError; use self::session::Session; use self::window::Window; diff --git a/src/matrix_error.rs b/src/matrix_error.rs new file mode 100644 index 00000000..53f1faed --- /dev/null +++ b/src/matrix_error.rs @@ -0,0 +1,26 @@ +use matrix_sdk::{ + ruma::api::error::{FromHttpResponseError, ServerError}, + HttpError, +}; + +use gettextrs::gettext; + +pub trait UserFacingMatrixError { + fn to_user_facing(self) -> String; +} + +impl UserFacingMatrixError for HttpError { + fn to_user_facing(self) -> String { + match self { + HttpError::Reqwest(_) => { + // TODO: Add more information based on the error + gettext("Couldn't connect to the server.") + } + HttpError::ClientApi(FromHttpResponseError::Http(ServerError::Known(error))) => { + // TODO: The server may not give us pretty enough error message. We should add our own error message. + error.message + } + _ => gettext("An Unknown error occurred."), + } + } +} diff --git a/src/meson.build b/src/meson.build index 84177de3..2e74b35f 100644 --- a/src/meson.build +++ b/src/meson.build @@ -34,6 +34,7 @@ sources = files( 'config.rs', 'error.rs', 'main.rs', + 'matrix_error.rs', 'window.rs', 'login.rs', 'secret.rs', @@ -69,6 +70,7 @@ sources = files( 'session/room/room_type.rs', 'session/room_list.rs', 'session/room/timeline.rs', + 'session/room_creation/mod.rs', 'session/sidebar/item_list.rs', 'session/sidebar/category.rs', 'session/sidebar/category_row.rs', diff --git a/src/session/mod.rs b/src/session/mod.rs index f2a6b480..409be32b 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -3,6 +3,7 @@ mod avatar; mod content; mod event_source_dialog; mod room; +mod room_creation; mod room_list; mod sidebar; mod user; @@ -11,6 +12,7 @@ use self::account_settings::AccountSettings; pub use self::avatar::Avatar; use self::content::Content; pub use self::room::Room; +pub use self::room_creation::RoomCreation; use self::room_list::RoomList; use self::sidebar::Sidebar; pub use self::user::{User, UserExt}; @@ -82,6 +84,10 @@ mod imp { session.set_selected_room(None); }); + klass.install_action("session.room-creation", None, move |session, _, _| { + session.show_room_creation_dialog(); + }); + klass.add_binding_action( gdk::keys::constants::Escape, gdk::ModifierType::empty(), @@ -493,6 +499,11 @@ impl Session { window.show(); } } + + fn show_room_creation_dialog(&self) { + let window = RoomCreation::new(self.parent_window().as_ref(), &self); + window.show(); + } } impl Default for Session { diff --git a/src/session/room_creation/mod.rs b/src/session/room_creation/mod.rs new file mode 100644 index 00000000..d1115d78 --- /dev/null +++ b/src/session/room_creation/mod.rs @@ -0,0 +1,361 @@ +use adw::subclass::prelude::*; +use gettextrs::gettext; +use gtk::{gdk, glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate}; +use log::error; +use std::convert::{TryFrom, TryInto}; + +use crate::components::SpinnerButton; +use crate::session::user::UserExt; +use crate::session::Session; +use crate::utils::do_async; +use matrix_sdk::{ + ruma::{ + api::{ + client::{ + error::ErrorKind as RumaClientErrorKind, + r0::room::{create_room, Visibility}, + }, + error::{FromHttpResponseError, ServerError}, + }, + assign, + identifiers::{Error, RoomName}, + }, + HttpError, +}; + +use crate::UserFacingMatrixError; + +// MAX length of room addresses +const MAX_BYTES: usize = 255; + +mod imp { + use super::*; + use glib::subclass::InitializingObject; + use std::cell::RefCell; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/FractalNext/room-creation.ui")] + pub struct RoomCreation { + pub session: RefCell>, + #[template_child] + pub content: TemplateChild, + #[template_child] + pub create_button: TemplateChild, + #[template_child] + pub cancel_button: TemplateChild, + #[template_child] + pub room_name: TemplateChild, + #[template_child] + pub private_button: TemplateChild, + #[template_child] + pub room_address: TemplateChild, + #[template_child] + pub room_name_error_revealer: TemplateChild, + #[template_child] + pub room_name_error: TemplateChild, + #[template_child] + pub room_address_error_revealer: TemplateChild, + #[template_child] + pub room_address_error: TemplateChild, + #[template_child] + pub server_name: TemplateChild, + #[template_child] + pub error_label: TemplateChild, + #[template_child] + pub error_label_revealer: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for RoomCreation { + const NAME: &'static str = "RoomCreation"; + type Type = super::RoomCreation; + type ParentType = adw::Window; + + fn class_init(klass: &mut Self::Class) { + SpinnerButton::static_type(); + Self::bind_template(klass); + + klass.add_binding( + gdk::keys::constants::Escape, + gdk::ModifierType::empty(), + |obj, _| { + obj.cancel(); + true + }, + None, + ); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for RoomCreation { + fn properties() -> &'static [glib::ParamSpec] { + use once_cell::sync::Lazy; + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![glib::ParamSpec::new_object( + "session", + "Session", + "The session", + Session::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() { + "session" => obj.set_session(value.get().unwrap()), + _ => unimplemented!(), + } + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "session" => obj.session().to_value(), + _ => unimplemented!(), + } + } + + fn constructed(&self, obj: &Self::Type) { + self.parent_constructed(obj); + + self.cancel_button + .connect_clicked(clone!(@weak obj => move |_| { + obj.cancel(); + })); + + self.create_button + .connect_clicked(clone!(@weak obj => move |_| { + obj.create_room(); + })); + + self.room_name + .connect_text_notify(clone!(@weak obj = > move |_| { + obj.validate_input(); + })); + + self.room_address + .connect_text_notify(clone!(@weak obj = > move |_| { + obj.validate_input(); + })); + } + } + + impl WidgetImpl for RoomCreation {} + impl WindowImpl for RoomCreation {} + impl AdwWindowImpl for RoomCreation {} +} + +glib::wrapper! { + /// Preference Window to display and update room details. + pub struct RoomCreation(ObjectSubclass) + @extends gtk::Widget, gtk::Window, adw::Window, @implements gtk::Accessible; +} + +impl RoomCreation { + pub fn new(parent_window: Option<&impl IsA>, session: &Session) -> Self { + glib::Object::new(&[("transient-for", &parent_window), ("session", session)]) + .expect("Failed to create RoomCreation") + } + + pub fn session(&self) -> Option { + let priv_ = imp::RoomCreation::from_instance(self); + priv_.session.borrow().clone() + } + + fn set_session(&self, session: Option) { + let priv_ = imp::RoomCreation::from_instance(self); + + if self.session() == session { + return; + } + + if let Some(user) = session.as_ref().and_then(|session| session.user()) { + priv_ + .server_name + .set_label(&[":", user.user_id().server_name().as_str()].concat()); + } + + priv_.session.replace(session); + self.notify("session"); + } + + fn create_room(&self) -> Option<()> { + let priv_ = imp::RoomCreation::from_instance(self); + + priv_.create_button.set_loading(true); + priv_.content.set_sensitive(false); + priv_.cancel_button.set_sensitive(false); + priv_.error_label_revealer.set_reveal_child(false); + + let client = self.session()?.client().clone(); + + let room_name = priv_.room_name.text().to_string(); + + let visibility = if priv_.private_button.is_active() { + Visibility::Private + } else { + Visibility::Public + }; + + let room_address = if !priv_.private_button.is_active() { + Some(format!("#{}", priv_.room_address.text().as_str())) + } else { + None + }; + + do_async( + glib::PRIORITY_DEFAULT_IDLE, + async move { + // We don't allow invalid room names to be entered by the user + let name = room_name.as_str().try_into().unwrap(); + + let request = assign!(create_room::Request::new(), + { + name: Some(name), + visibility, + room_alias_name: room_address.as_deref() + }); + client.create_room(request).await + }, + clone!(@weak self as obj => move |result| async move { + match result { + Ok(response) => { + if let Some(session) = obj.session() { + let room = session.room_list().get_wait(response.room_id).await; + session.set_selected_room(room); + } + obj.close(); + }, + Err(error) => { + error!("Couldn’t create a new room: {}", error); + obj.handle_error(error); + }, + }; + }), + ); + + None + } + + /// Display the error that occured during creation + fn handle_error(&self, error: HttpError) { + let priv_ = imp::RoomCreation::from_instance(self); + + priv_.create_button.set_loading(false); + priv_.content.set_sensitive(true); + priv_.cancel_button.set_sensitive(true); + + // Treat the room address already taken error special + if let HttpError::ClientApi(FromHttpResponseError::Http(ServerError::Known( + ref client_error, + ))) = error + { + if client_error.kind == RumaClientErrorKind::RoomInUse { + priv_.room_address.add_css_class("error"); + priv_ + .room_address_error + .set_text(&gettext("The address is already taken.")); + priv_.room_address_error_revealer.set_reveal_child(true); + + return; + } + } + + priv_.error_label.set_label(&error.to_user_facing()); + + priv_.error_label_revealer.set_reveal_child(true); + } + + fn validate_input(&self) { + let priv_ = imp::RoomCreation::from_instance(self); + + // Validate room name + let (is_name_valid, has_error) = + match <&RoomName>::try_from(priv_.room_name.text().as_str()) { + Ok(_) => (true, false), + Err(Error::EmptyRoomName) => (false, false), + Err(Error::MaximumLengthExceeded) => { + priv_ + .room_name_error + .set_text(&gettext("Too long. Use a shorter name.")); + (false, true) + } + Err(_) => unimplemented!(), + }; + + if has_error { + priv_.room_name.add_css_class("error"); + } else { + priv_.room_name.remove_css_class("error"); + } + + priv_.room_name_error_revealer.set_reveal_child(has_error); + + // Validate room address + + // Only public rooms have a address + if priv_.private_button.is_active() { + priv_.create_button.set_sensitive(is_name_valid); + return; + } + + let room_address = priv_.room_address.text(); + + // We don't allow #, : in the room address + let (is_address_valid, has_error) = if room_address.find(':').is_some() { + priv_ + .room_address_error + .set_text(&gettext("Can't contain `:`")); + (false, true) + } else if room_address.find('#').is_some() { + priv_ + .room_address_error + .set_text(&gettext("Can't contain `#`")); + (false, true) + } else if room_address.len() > MAX_BYTES { + priv_ + .room_address_error + .set_text(&gettext("Too long. Use a shorter address.")); + (false, true) + } else if room_address.is_empty() { + (false, false) + } else { + (true, false) + }; + + // TODO: should we immediately check if the address is available, like element is doing? + + if has_error { + priv_.room_address.add_css_class("error"); + } else { + priv_.room_address.remove_css_class("error"); + } + + priv_ + .room_address_error_revealer + .set_reveal_child(has_error); + priv_ + .create_button + .set_sensitive(is_name_valid && is_address_valid); + } + + fn cancel(&self) { + let priv_ = imp::RoomCreation::from_instance(self); + + if priv_.cancel_button.is_sensitive() { + self.close(); + } + } +}