[fractal/fractal-next] Implement attachments



commit 57b26400356d319c0e924bdbb4777cbf79d5bbce
Author: Maximiliano Sandoval R <msandova gnome org>
Date:   Fri Dec 10 18:11:13 2021 +0100

    Implement attachments
    
    With drag and drop.
    
    Fixes: https://gitlab.gnome.org/GNOME/fractal/-/issues/121, 
https://gitlab.gnome.org/GNOME/fractal/-/issues/764.

 data/resources/resources.gresource.xml             |   1 +
 data/resources/style.css                           |   7 +-
 data/resources/ui/attachment-dialog.ui             |  63 ++++++
 data/resources/ui/content-room-history.ui          |  16 ++
 po/POTFILES.in                                     |   2 +
 .../content/room_history/attachment_dialog.rs      |  74 +++++++
 src/session/content/room_history/mod.rs            | 244 ++++++++++++++++++++-
 src/session/room/mod.rs                            |  22 +-
 8 files changed, 424 insertions(+), 5 deletions(-)
---
diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml
index 766d01c7b..8d1d2cd8d 100644
--- a/data/resources/resources.gresource.xml
+++ b/data/resources/resources.gresource.xml
@@ -18,6 +18,7 @@
     <file compressed="true" preprocess="xml-stripblanks" 
alias="account-settings-user-page.ui">ui/account-settings-user-page.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="account-settings.ui">ui/account-settings.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="add-account-row.ui">ui/add-account-row.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks" 
alias="attachment-dialog.ui">ui/attachment-dialog.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="avatar-with-selection.ui">ui/avatar-with-selection.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="components-action-button.ui">ui/components-action-button.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="components-audio-player.ui">ui/components-audio-player.ui</file>
diff --git a/data/resources/style.css b/data/resources/style.css
index f6ce190a9..526933700 100644
--- a/data/resources/style.css
+++ b/data/resources/style.css
@@ -507,4 +507,9 @@ message-reactions .reaction-count {
   background-color: @window_bg_color;
   border-radius: 9999px;
   padding: 2px;
-}
\ No newline at end of file
+}
+
+dragoverlay statuspage {
+  background-color: alpha(@accent_bg_color, 0.5);
+  color: @accent_fg_color;
+}
diff --git a/data/resources/ui/attachment-dialog.ui b/data/resources/ui/attachment-dialog.ui
new file mode 100644
index 000000000..674aea5d5
--- /dev/null
+++ b/data/resources/ui/attachment-dialog.ui
@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <template class="AttachmentDialog" parent="GtkWindow">
+    <property name="modal">True</property>
+    <property name="title"></property>
+    <property name="default-width">400</property>
+    <property name="default-height">400</property>
+    <property name="destroy-with-parent">True</property>
+    <property name="titlebar">
+      <object class="GtkHeaderBar">
+        <property name="show-title-buttons">False</property>
+        <child>
+          <object class="GtkButton">
+            <property name="label" translatable="yes">_Cancel</property>
+            <property name="use-underline">True</property>
+            <property name="action-name">window.close</property>
+          </object>
+        </child>
+        <child type="end">
+          <object class="GtkButton">
+            <property name="label" translatable="yes">_Send</property>
+            <property name="use-underline">True</property>
+            <property name="action-name">attachment-dialog.send</property>
+            <style>
+              <class name="suggested-action"/>
+            </style>
+          </object>
+        </child>
+      </object>
+    </property>
+    <property name="child">
+      <object class="GtkStack" id="stack">
+        <child>
+          <object class="AdwStatusPage">
+            <property name="icon-name">face-sick-symbolic</property>
+            <property name="title" translatable="yes">No Preview Available</property>
+            <style>
+              <class name="compact"/>
+            </style>
+          </object>
+        </child>
+        <child>
+          <object class="GtkStackPage">
+            <property name="name">preview</property>
+            <property name="child">
+              <object class="GtkImage" id="preview"/>
+            </property>
+          </object>
+        </child>
+      </object>
+    </property>
+    <child>
+      <object class="GtkShortcutController">
+        <child>
+          <object class="GtkShortcut">
+            <property name="trigger">Escape</property>
+            <property name="action">action(window.close)</property>
+          </object>
+        </child>
+      </object>
+    </child>
+  </template>
+</interface>
diff --git a/data/resources/ui/content-room-history.ui b/data/resources/ui/content-room-history.ui
index abefde088..0a729ba89 100644
--- a/data/resources/ui/content-room-history.ui
+++ b/data/resources/ui/content-room-history.ui
@@ -129,6 +129,22 @@
             </child>
             <child>
               <object class="GtkOverlay" id="content">
+                <child type="overlay">
+                  <object class="GtkRevealer" id="drag_revealer">
+                    <property name="can-target">False</property>
+                    <property name="transition_type">crossfade</property>
+                    <property name="reveal_child">False</property>
+                    <child>
+                      <object class="AdwStatusPage">
+                        <property name="icon-name">document-send-symbolic</property>
+                        <property name="title" translatable="yes">Drop Here to Send</property>
+                        <style>
+                          <class name="drag-n-drop-overlay"/>
+                        </style>
+                      </object>
+                    </child>
+                  </object>
+                </child>
                 <child type="overlay">
                   <object class="GtkRevealer" id="scroll_btn_revealer">
                     <property name="transition_type">crossfade</property>
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 84f604743..5b43e68d1 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -11,6 +11,7 @@ data/resources/ui/account-settings-device-row.ui
 data/resources/ui/account-settings-devices-page.ui
 data/resources/ui/account-settings-user-page.ui
 data/resources/ui/account-settings.ui
+data/resources/ui/attachment-dialog.ui
 data/resources/ui/components-auth-dialog.ui
 data/resources/ui/components-loading-listbox-row.ui
 data/resources/ui/content-explore.ui
@@ -57,6 +58,7 @@ src/session/content/room_history/item_row.rs
 src/session/content/room_history/message_row/audio.rs
 src/session/content/room_history/message_row/media.rs
 src/session/content/room_history/message_row/mod.rs
+src/session/content/room_history/mod.rs
 src/session/content/room_history/state_row/creation.rs
 src/session/content/room_history/state_row/mod.rs
 src/session/content/room_history/verification_info_bar.rs
diff --git a/src/session/content/room_history/attachment_dialog.rs 
b/src/session/content/room_history/attachment_dialog.rs
new file mode 100644
index 000000000..8525c104e
--- /dev/null
+++ b/src/session/content/room_history/attachment_dialog.rs
@@ -0,0 +1,74 @@
+use gtk::{gdk, gio, glib, prelude::*, subclass::prelude::*, CompositeTemplate};
+use once_cell::sync::Lazy;
+
+mod imp {
+    use std::cell::RefCell;
+
+    use super::*;
+
+    #[derive(Debug, Default, CompositeTemplate)]
+    #[template(resource = "/org/gnome/FractalNext/attachment-dialog.ui")]
+    pub struct AttachmentDialog {
+        pub file: RefCell<Option<gio::File>>,
+        pub texture: RefCell<Option<gdk::Texture>>,
+        #[template_child]
+        pub preview: TemplateChild<gtk::Image>,
+        #[template_child]
+        pub stack: TemplateChild<gtk::Stack>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for AttachmentDialog {
+        const NAME: &'static str = "AttachmentDialog";
+        type Type = super::AttachmentDialog;
+        type ParentType = gtk::Window;
+
+        fn class_init(klass: &mut Self::Class) {
+            Self::bind_template(klass);
+
+            klass.install_action("attachment-dialog.send", None, move |window, _, _| {
+                window.emit_by_name::<()>("send", &[]);
+                window.close();
+            });
+        }
+
+        fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
+            obj.init_template();
+        }
+    }
+
+    impl ObjectImpl for AttachmentDialog {
+        fn signals() -> &'static [glib::subclass::Signal] {
+            static SIGNALS: Lazy<Vec<glib::subclass::Signal>> = Lazy::new(|| {
+                vec![
+                    glib::subclass::Signal::builder("send", &[], glib::Type::UNIT.into())
+                        .flags(glib::SignalFlags::RUN_FIRST)
+                        .build(),
+                ]
+            });
+            SIGNALS.as_ref()
+        }
+    }
+    impl WidgetImpl for AttachmentDialog {}
+    impl WindowImpl for AttachmentDialog {}
+}
+
+glib::wrapper! {
+    pub struct AttachmentDialog(ObjectSubclass<imp::AttachmentDialog>)
+        @extends gtk::Widget, gtk::Window;
+}
+
+impl AttachmentDialog {
+    pub fn new(window: &gtk::Window) -> Self {
+        glib::Object::new(&[("transient-for", window)]).unwrap()
+    }
+
+    pub fn set_texture(&self, texture: &gdk::Texture) {
+        let priv_ = self.imp();
+        priv_.stack.set_visible_child_name("preview");
+
+        priv_
+            .preview
+            .set_paintable(Some(texture.upcast_ref::<gdk::Paintable>()));
+    }
+}
diff --git a/src/session/content/room_history/mod.rs b/src/session/content/room_history/mod.rs
index f738475ad..9410994f5 100644
--- a/src/session/content/room_history/mod.rs
+++ b/src/session/content/room_history/mod.rs
@@ -1,3 +1,4 @@
+mod attachment_dialog;
 mod divider_row;
 mod item_row;
 mod message_row;
@@ -5,8 +6,9 @@ mod state_row;
 mod verification_info_bar;
 
 use adw::subclass::prelude::*;
+use gettextrs::gettext;
 use gtk::{
-    gdk, glib,
+    gdk, gio, glib,
     glib::{clone, signal::Inhibit},
     prelude::*,
     subclass::prelude::*,
@@ -19,9 +21,10 @@ use matrix_sdk::ruma::events::room::message::{
 use sourceview::prelude::*;
 
 use self::{
-    divider_row::DividerRow, item_row::ItemRow, state_row::StateRow,
-    verification_info_bar::VerificationInfoBar,
+    attachment_dialog::AttachmentDialog, divider_row::DividerRow, item_row::ItemRow,
+    state_row::StateRow, verification_info_bar::VerificationInfoBar,
 };
+use crate::spawn;
 use crate::{
     components::{CustomEntry, Pill, RoomTitle},
     session::{
@@ -32,6 +35,14 @@ use crate::{
     spawn,
 };
 
+const MIME_TYPES: &[&str] = &[
+    "image/png",
+    "image/jpeg",
+    "image/tiff",
+    "image/svg+xml",
+    "image/bmp",
+];
+
 mod imp {
     use std::cell::{Cell, RefCell};
 
@@ -78,6 +89,8 @@ mod imp {
         #[template_child]
         pub stack: TemplateChild<gtk::Stack>,
         pub is_loading: Cell<bool>,
+        #[template_child]
+        pub drag_revealer: TemplateChild<gtk::Revealer>,
     }
 
     #[glib::object_subclass]
@@ -119,6 +132,10 @@ mod imp {
             klass.install_action("room-history.scroll-down", None, move |widget, _, _| {
                 widget.scroll_down();
             });
+
+            klass.install_action("room-history.select-file", None, move |widget, _, _| {
+                widget.select_file();
+            });
         }
 
         fn instance_init(obj: &InitializingObject<Self>) {
@@ -258,6 +275,23 @@ mod imp {
 
             let key_events = gtk::EventControllerKey::new();
             self.message_entry.add_controller(&key_events);
+            self.message_entry
+                .connect_paste_clipboard(clone!(@weak obj => move |entry| {
+                    spawn!(
+                        glib::PRIORITY_DEFAULT_IDLE,
+                        clone!(@weak obj => async move {
+                            obj.read_clipboard().await;
+                    }));
+                    let clip = obj.clipboard();
+
+                    // TODO Check if this is the most general condition on which
+                    // the clipboard contains more than text.
+                    let formats = clip.formats();
+                    let contains_mime = MIME_TYPES.iter().any(|mime| formats.contain_mime_type(mime));
+                    if formats.contains_type(gio::File::static_type()) || contains_mime {
+                        entry.stop_signal_emission_by_name("paste-clipboard");
+                    }
+                }));
 
             key_events
                 .connect_key_pressed(clone!(@weak obj => @default-return Inhibit(false), move |_, key, _, 
modifier| {
@@ -295,6 +329,8 @@ mod imp {
                 .bind("markdown-enabled", obj, "markdown-enabled")
                 .build();
 
+            obj.setup_drop_target();
+
             self.parent_constructed(obj);
         }
     }
@@ -309,6 +345,50 @@ glib::wrapper! {
 }
 
 impl RoomHistory {
+    async fn read_clipboard(&self) {
+        let clipboard = self.clipboard();
+
+        // Check if there is a png/jpg in the clipboard.
+        let res = clipboard
+            .read_future(MIME_TYPES, glib::PRIORITY_DEFAULT)
+            .await;
+        let body = match clipboard.read_text_future().await {
+            Ok(Some(body)) => std::path::Path::new(&body)
+                .file_name()
+                .unwrap()
+                .to_str()
+                .unwrap()
+                .to_string(),
+            _ => gettext("Image"),
+        };
+        if let Ok((stream, mime)) = res {
+            log::debug!("Found a {} in the clipboard", &mime);
+            if let Ok(bytes) = read_stream(&stream).await {
+                self.open_attach_dialog(bytes, &mime, &body);
+
+                return;
+            }
+        }
+
+        // Check if there is a file in the clipboard.
+        let res = clipboard
+            .read_value_future(gio::File::static_type(), glib::PRIORITY_DEFAULT)
+            .await;
+        if let Ok(value) = res {
+            if let Ok(file) = value.get::<gio::File>() {
+                log::debug!("Found a file in the clipboard");
+
+                // Under some circumstances, the file will be
+                // under a path we don't have access to.
+                if !file.query_exists(gio::Cancellable::NONE) {
+                    return;
+                }
+
+                self.read_file(&file).await;
+            }
+        }
+    }
+
     pub fn new() -> Self {
         glib::Object::new(&[]).expect("Failed to create RoomHistory")
     }
@@ -625,6 +705,148 @@ impl RoomHistory {
     fn try_again(&self) {
         self.start_loading();
     }
+
+    fn setup_drop_target(&self) {
+        let priv_ = imp::RoomHistory::from_instance(self);
+
+        let target = gtk::DropTarget::new(
+            gio::File::static_type(),
+            gdk::DragAction::COPY | gdk::DragAction::MOVE,
+        );
+
+        target.connect_drop(
+            glib::clone!(@weak self as obj => @default-return false, move |target, value, _, _| {
+                let drop = target.current_drop().unwrap();
+
+                // We first try to read if we get a serialized image. In general
+                // we get files, but this is useful when reading a drag-n-drop
+                // from another sandboxed app.
+                let formats = drop.formats();
+                for mime in MIME_TYPES {
+                    if formats.contain_mime_type(mime) {
+                        log::debug!("Received drag & drop with mime type: {}", mime);
+                        drop.read_async(&[mime], glib::PRIORITY_DEFAULT, gio::Cancellable::NONE, 
glib::clone!(@weak obj => move |res| {
+                            if let Ok((stream, mime)) = res {
+                                crate::spawn!(glib::clone!(@weak obj => async move {
+                                    if let Ok(bytes) = read_stream(&stream).await {
+                                        // TODO Get the actual name of the file by reading
+                                        // the text/plain mime type.
+                                        let body = gettext("Image");
+                                        obj.open_attach_dialog(bytes, &mime, &body);
+                                    }
+                                }));
+                            }
+                        }));
+
+                        return true;
+                    }
+                }
+
+                if let Ok(file) = value.get::<gio::File>() {
+                    if !file.query_exists(gio::Cancellable::NONE) {
+                        log::debug!("Received drag & drop file, but don't have permissions: {:?}", 
file.path());
+                        return false;
+                    }
+                    log::debug!("Received drag & drop file: {:?}", file.path());
+                    crate::spawn!(glib::clone!(@weak obj, @strong file => async move {
+                        obj.read_file(&file).await;
+                    }));
+
+                    return true;
+                }
+                false
+            }),
+        );
+
+        target.connect_current_drop_notify(glib::clone!(@weak self as obj => move |target| {
+            let priv_ = imp::RoomHistory::from_instance(&obj);
+            priv_.drag_revealer.set_reveal_child(target.current_drop().is_some());
+        }));
+
+        priv_.scrolled_window.add_controller(&target);
+    }
+
+    fn open_attach_dialog(&self, bytes: Vec<u8>, mime: &str, title: &str) {
+        let window = self.root().unwrap().downcast::<gtk::Window>().unwrap();
+        let dialog = AttachmentDialog::new(&window);
+        let gbytes = glib::Bytes::from_owned(bytes.clone());
+        if let Ok(texture) = gdk::Texture::from_bytes(&gbytes) {
+            dialog.set_texture(&texture);
+        }
+
+        let mime = mime.to_string();
+        dialog.set_title(Some(title));
+        let title = title.to_string();
+        dialog
+            .connect_local(
+                "send",
+                false,
+                glib::clone!(@weak self as obj => @default-return None, move |_| {
+                    if let Some(room) = obj.room() {
+                        room.send_attachment(&gbytes, &mime, &title);
+                    }
+
+                    None
+                }),
+            )
+            .unwrap();
+        dialog.present();
+    }
+
+    pub fn select_file(&self) {
+        let window = self.root().unwrap().downcast::<gtk::Window>().unwrap();
+        let dialog = gtk::FileChooserNative::new(
+            None,
+            Some(&window),
+            gtk::FileChooserAction::Open,
+            None,
+            None,
+        );
+        dialog.set_modal(true);
+
+        dialog.connect_response(
+            glib::clone!(@weak self as obj, @strong dialog => move |_, response| {
+                dialog.destroy();
+                if response == gtk::ResponseType::Accept {
+                    let file = dialog.file().unwrap();
+
+                    crate::spawn!(glib::clone!(@weak obj, @strong file => async move {
+                        obj.read_file(&file).await;
+                    }));
+                }
+            }),
+        );
+
+        dialog.show();
+    }
+
+    async fn read_file(&self, file: &gio::File) {
+        let filename = file
+            .basename()
+            .unwrap()
+            .into_os_string()
+            .to_str()
+            .unwrap()
+            .to_string();
+
+        // Read mime type.
+        let mime = if let Ok(file_info) = file.query_info(
+            "standard::content-type",
+            gio::FileQueryInfoFlags::NONE,
+            gio::Cancellable::NONE,
+        ) {
+            file_info
+                .content_type()
+                .map_or("text/plain".to_string(), |x| x.to_string())
+        } else {
+            "text/plain".to_string()
+        };
+
+        match file.load_contents_future().await {
+            Ok((bytes, _tag)) => self.open_attach_dialog(bytes, &mime, &filename),
+            Err(err) => log::debug!("Could not read file: {}", err),
+        }
+    }
 }
 
 impl Default for RoomHistory {
@@ -632,3 +854,19 @@ impl Default for RoomHistory {
         Self::new()
     }
 }
+
+async fn read_stream(stream: &gio::InputStream) -> Result<Vec<u8>, glib::Error> {
+    let mut buffer = Vec::<u8>::with_capacity(4096);
+
+    loop {
+        let bytes = stream
+            .read_bytes_future(4096, glib::PRIORITY_DEFAULT)
+            .await?;
+        if bytes.is_empty() {
+            break;
+        }
+        buffer.extend_from_slice(&bytes);
+    }
+
+    Ok(buffer)
+}
diff --git a/src/session/room/mod.rs b/src/session/room/mod.rs
index 5e65acbff..fb107f9af 100644
--- a/src/session/room/mod.rs
+++ b/src/session/room/mod.rs
@@ -11,12 +11,13 @@ mod reaction_list;
 mod room_type;
 mod timeline;
 
-use std::{cell::RefCell, convert::TryInto, path::PathBuf, sync::Arc};
+use std::{cell::RefCell, convert::TryInto, ops::Deref, path::PathBuf, str::FromStr, sync::Arc};
 
 use gettextrs::gettext;
 use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*};
 use log::{debug, error, info, warn};
 use matrix_sdk::{
+    attachment::AttachmentConfig,
     deserialized_responses::{JoinedRoom, LeftRoom, SyncRoomEvent},
     room::Room as MatrixRoom,
     ruma::{
@@ -1183,6 +1184,25 @@ impl Room {
         Some(())
     }
 
+    pub fn send_attachment(&self, bytes: &glib::Bytes, mime: &str, body: &str) {
+        let matrix_room = self.matrix_room();
+
+        if let MatrixRoom::Joined(matrix_room) = matrix_room {
+            let mime = mime::Mime::from_str(&mime.to_owned()).unwrap();
+            let body = body.to_string();
+            spawn_tokio!(glib::clone!(@strong bytes => async move {
+                let config = AttachmentConfig::default();
+                let mut cursor = std::io::Cursor::new(bytes.deref());
+                matrix_room
+                    // TODO This should be added to pending messages instead of
+                    // sending it directly.
+                    .send_attachment(&body, &mime, &mut cursor, config)
+                    .await
+                    .unwrap();
+            }));
+        }
+    }
+
     pub async fn invite(&self, users: &[User]) {
         let matrix_room = self.matrix_room();
         let user_ids: Vec<Arc<UserId>> = users.iter().map(|user| user.user_id()).collect();


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