[fractal/fractal-next] room: Add dialog to create new rooms



commit f01d402dd63543e0e54221cc2c096fb993d79d00
Author: Julian Sparber <julian sparber net>
Date:   Wed Sep 29 12:39:23 2021 +0200

    room: Add dialog to create new rooms

 data/resources/resources.gresource.xml |   1 +
 data/resources/ui/room-creation.ui     | 182 +++++++++++++++++
 data/resources/ui/sidebar.ui           |   6 +
 po/POTFILES.in                         |   3 +
 src/main.rs                            |   2 +
 src/matrix_error.rs                    |  26 +++
 src/meson.build                        |   2 +
 src/session/mod.rs                     |  11 +
 src/session/room_creation/mod.rs       | 361 +++++++++++++++++++++++++++++++++
 9 files changed, 594 insertions(+)
---
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 @@
     <file compressed="true" preprocess="xml-stripblanks" 
alias="account-settings-device-row.ui">ui/account-settings-device-row.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="account-settings-devices-page.ui">ui/account-settings-devices-page.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="components-loading-listbox-row.ui">ui/components-loading-listbox-row.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks" alias="room-creation.ui">ui/room-creation.ui</file>
     <file compressed="true">style.css</file>
     <file preprocess="xml-stripblanks">icons/scalable/actions/send-symbolic.svg</file>
     <file preprocess="xml-stripblanks">icons/scalable/status/welcome.svg</file>
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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <template class="RoomCreation" parent="AdwWindow">
+    <property name="title" translatable="yes">Create new Room</property>
+    <property name="default-widget">create_button</property>
+    <property name="modal">True</property>
+    <property name="default-width">380</property>
+    <property name="content">
+      <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="create_button">
+                <property name="label" translatable="yes">C_reate</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="GtkRevealer" id="error_label_revealer">
+            <property name="child">
+              <object class="GtkLabel" id="error_label">
+                <property name="wrap">True</property>
+                <property name="wrap-mode">word-char</property>
+                <property name="margin-top">24</property>
+                <style>
+                  <class name="error"/>
+                </style>
+              </object>
+            </property>
+          </object>
+        </child>
+        <child>
+          <object class="GtkListBox" id="content">
+            <property name="selection-mode">none</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>
+            <style>
+              <class name="content"/>
+            </style>
+            <child>
+              <object class="AdwActionRow">
+                <property name="title" translatable="yes">Room Name</property>
+                <property name="selectable">False</property>
+                <property name="use_underline">True</property>
+                <child>
+                  <object class="GtkBox">
+                    <property name="orientation">vertical</property>
+                    <property name="margin-top">6</property>
+                    <property name="margin-bottom">6</property>
+                    <child>
+                      <object class="GtkEntry" id="room_name">
+                        <property name="valign">center</property>
+                        <property name="vexpand">True</property>
+                      </object>
+                    </child>
+                    <child>
+                      <object class="GtkRevealer" id="room_name_error_revealer">
+                        <property name="child">
+                          <object class="GtkLabel" id="room_name_error">
+                            <property name="valign">start</property>
+                            <property name="xalign">0.0</property>
+                            <property name="margin-top">6</property>
+                            <style>
+                              <class name="error"/>
+                              <class name="caption"/>
+                            </style>
+                          </object>
+                        </property>
+                      </object>
+                    </child>
+                  </object>
+                </child>
+              </object>
+            </child>
+            <child>
+              <object class="AdwActionRow">
+                <property name="title" translatable="yes">Visibility</property>
+                <property name="selectable">False</property>
+                <child>
+                  <object class="GtkBox">
+                    <property name="valign">center</property>
+                    <style>
+                      <class name="linked"/>
+                    </style>
+                    <child>
+                      <object class="GtkToggleButton" id="private_button">
+                        <property name="label" translatable="yes">_Private</property>
+                        <property name="use_underline">True</property>
+                        <property name="active">True</property>
+                      </object>
+                    </child>
+                    <child>
+                      <object class="GtkToggleButton" id="public_button">
+                        <property name="label" translatable="yes">P_ublic</property>
+                        <property name="use_underline">True</property>
+                        <property name="group">private_button</property>
+                      </object>
+                    </child>
+                  </object>
+                </child>
+              </object>
+            </child>
+            <child>
+              <object class="AdwActionRow">
+                <property name="visible" bind-source="public_button" bind-property="active" 
bind-flags="sync-create"/>
+                <property name="title" translatable="yes">Room Address</property>
+                <property name="selectable">False</property>
+                <child>
+                  <object class="GtkBox">
+                    <property name="orientation">vertical</property>
+                    <property name="margin-top">6</property>
+                    <property name="margin-bottom">6</property>
+                    <child>
+                      <object class="GtkBox">
+                        <property name="valign">center</property>
+                        <property name="spacing">6</property>
+                        <child>
+                          <object class="GtkLabel">
+                            <property name="label">#</property>
+                            <style>
+                              <class name="dim-label"/>
+                            </style>
+                          </object>
+                        </child>
+                        <child>
+                          <object class="GtkEntry" id="room_address">
+                            <property name="valign">center</property>
+                            <property name="max-width-chars">10</property>
+                          </object>
+                        </child>
+                        <child>
+                          <object class="GtkLabel" id="server_name">
+                            <property name="label">:gnome.org</property>
+                            <style>
+                              <class name="dim-label"/>
+                            </style>
+                          </object>
+                        </child>
+                      </object>
+                    </child>
+                    <child>
+                      <object class="GtkRevealer" id="room_address_error_revealer">
+                        <property name="child">
+                          <object class="GtkLabel" id="room_address_error">
+                            <property name="valign">start</property>
+                            <property name="xalign">0.0</property>
+                            <property name="margin-top">6</property>
+                            <style>
+                              <class name="error"/>
+                              <class name="caption"/>
+                            </style>
+                          </object>
+                        </property>
+                      </object>
+                    </child>
+                  </object>
+                </child>
+              </object>
+            </child>
+          </object>
+        </child>
+      </object>
+    </property>
+  </template>
+</interface>
+
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 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <interface>
   <menu id="primary_menu">
+    <section>
+      <item>
+        <attribute name="label" translatable="yes">_New Room</attribute>
+        <attribute name="action">session.room-creation</attribute>
+      </item>
+    </section>
     <section>
       <item>
         <attribute name="label" translatable="yes">_Preferences</attribute>
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<Option<Session>>,
+        #[template_child]
+        pub content: TemplateChild<gtk::ListBox>,
+        #[template_child]
+        pub create_button: TemplateChild<SpinnerButton>,
+        #[template_child]
+        pub cancel_button: TemplateChild<gtk::Button>,
+        #[template_child]
+        pub room_name: TemplateChild<gtk::Entry>,
+        #[template_child]
+        pub private_button: TemplateChild<gtk::ToggleButton>,
+        #[template_child]
+        pub room_address: TemplateChild<gtk::Entry>,
+        #[template_child]
+        pub room_name_error_revealer: TemplateChild<gtk::Revealer>,
+        #[template_child]
+        pub room_name_error: TemplateChild<gtk::Label>,
+        #[template_child]
+        pub room_address_error_revealer: TemplateChild<gtk::Revealer>,
+        #[template_child]
+        pub room_address_error: TemplateChild<gtk::Label>,
+        #[template_child]
+        pub server_name: TemplateChild<gtk::Label>,
+        #[template_child]
+        pub error_label: TemplateChild<gtk::Label>,
+        #[template_child]
+        pub error_label_revealer: TemplateChild<gtk::Revealer>,
+    }
+
+    #[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<Self>) {
+            obj.init_template();
+        }
+    }
+
+    impl ObjectImpl for RoomCreation {
+        fn properties() -> &'static [glib::ParamSpec] {
+            use once_cell::sync::Lazy;
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = 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<imp::RoomCreation>)
+        @extends gtk::Widget, gtk::Window, adw::Window, @implements gtk::Accessible;
+}
+
+impl RoomCreation {
+    pub fn new(parent_window: Option<&impl IsA<gtk::Window>>, session: &Session) -> Self {
+        glib::Object::new(&[("transient-for", &parent_window), ("session", session)])
+            .expect("Failed to create RoomCreation")
+    }
+
+    pub fn session(&self) -> Option<Session> {
+        let priv_ = imp::RoomCreation::from_instance(self);
+        priv_.session.borrow().clone()
+    }
+
+    fn set_session(&self, session: Option<Session>) {
+        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();
+        }
+    }
+}


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]