[fractal/fractal-next] content: Add MessageFile widget



commit b3e3a7c5f7edff2e403caf7e05b97abef77c8198
Author: Kévin Commaille <zecakeh tedomum fr>
Date:   Tue Nov 16 16:19:15 2021 +0100

    content: Add MessageFile widget
    
    Display m.file messages so the user can open or save them.

 data/resources/resources.gresource.xml    |   1 +
 data/resources/ui/content-message-file.ui |  41 ++++++++
 po/POTFILES.in                            |   2 +
 src/matrix_error.rs                       |   1 +
 src/meson.build                           |   1 +
 src/session/content/item_row.rs           | 151 +++++++++++++++++++++++++++++-
 src/session/content/message_row/file.rs   | 111 ++++++++++++++++++++++
 src/session/content/message_row/mod.rs    |   9 +-
 src/session/content/mod.rs                |   1 -
 src/session/room/event.rs                 |   7 +-
 src/utils.rs                              |  19 +++-
 11 files changed, 335 insertions(+), 9 deletions(-)
---
diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml
index a2c82cc5..cea9d767 100644
--- a/data/resources/resources.gresource.xml
+++ b/data/resources/resources.gresource.xml
@@ -11,6 +11,7 @@
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-public-room-row.ui">ui/content-public-room-row.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" alias="content-item.ui">ui/content-item.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-item-row-menu.ui">ui/content-item-row-menu.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks" 
alias="content-message-file.ui">ui/content-message-file.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-message-row.ui">ui/content-message-row.ui</file>
     <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-room-details.ui">ui/content-room-details.ui</file>
diff --git a/data/resources/ui/content-message-file.ui b/data/resources/ui/content-message-file.ui
new file mode 100644
index 00000000..5dc22bfe
--- /dev/null
+++ b/data/resources/ui/content-message-file.ui
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <template class="ContentMessageFile" parent="AdwBin">
+    <property name="focusable">True</property>
+    <property name="valign">center</property>
+    <child>
+      <object class="GtkBox">
+        <property name="spacing">6</property>
+        <child>
+          <object class="GtkLabel">
+            <property name="ellipsize">end</property>
+            <binding name="label">
+              <lookup name="filename">ContentMessageFile</lookup>
+            </binding>
+          </object>
+        </child>
+        <child>
+          <object class="GtkBox">
+              <child>
+                <object class="GtkButton" id="open">
+                  <property name="icon-name">document-open-symbolic</property>
+                  <property name="tooltip-text" translatable="yes">Open</property>
+                  <property name="action-name">item-row.file-open</property>
+                </object>
+              </child>
+              <child>
+                <object class="GtkButton" id="save">
+                  <property name="icon-name">document-save-symbolic</property>
+                  <property name="tooltip-text" translatable="yes">Save</property>
+                  <property name="action-name">item-row.file-save</property>
+                </object>
+              </child>
+              <style>
+                <class name="linked"/>
+              </style>
+          </object>
+        </child>
+      </object>
+    </child>
+  </template>
+</interface>
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 09cca574..1e9de487 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -18,6 +18,7 @@ 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-file.ui
 data/resources/ui/content-message-row.ui
 data/resources/ui/content-room-details.ui
 data/resources/ui/content-room-history.ui
@@ -74,6 +75,7 @@ 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/file.rs
 src/session/content/message_row/mod.rs
 src/session/content/message_row/text.rs
 src/session/content/mod.rs
diff --git a/src/matrix_error.rs b/src/matrix_error.rs
index ab6324ab..3a66fd3b 100644
--- a/src/matrix_error.rs
+++ b/src/matrix_error.rs
@@ -47,6 +47,7 @@ impl UserFacingError for HttpError {
 impl UserFacingError for Error {
     fn to_user_facing(self) -> String {
         match self {
+            Error::DecryptorError(_) => gettext("Could not decrypt the event"),
             Error::Http(http_error) => http_error.to_user_facing(),
             _ => gettext("An unknown error occurred."),
         }
diff --git a/src/meson.build b/src/meson.build
index 5769b779..6a9f04cb 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -57,6 +57,7 @@ sources = files(
   'session/content/item_row.rs',
   'session/content/invite.rs',
   'session/content/markdown_popover.rs',
+  'session/content/message_row/file.rs',
   'session/content/message_row/mod.rs',
   'session/content/message_row/text.rs',
   'session/content/mod.rs',
diff --git a/src/session/content/item_row.rs b/src/session/content/item_row.rs
index 50b340b9..1d6a7c43 100644
--- a/src/session/content/item_row.rs
+++ b/src/session/content/item_row.rs
@@ -1,12 +1,18 @@
 use adw::{prelude::*, subclass::prelude::*};
 use gettextrs::gettext;
-use gtk::{gio, glib, glib::clone, subclass::prelude::*};
+use gtk::{gio, glib, glib::clone, subclass::prelude::*, FileChooserAction, ResponseType};
+use log::error;
+use matrix_sdk::ruma::events::{
+    room::message::MessageType, AnyMessageEventContent, AnySyncRoomEvent,
+};
 
 use crate::components::{ContextMenuBin, ContextMenuBinExt, ContextMenuBinImpl};
-use crate::session::content::{DividerRow, MessageRow, StateRow};
+use crate::matrix_error::UserFacingError;
+use crate::session::content::{message_row::MessageRow, DividerRow, StateRow};
 use crate::session::event_source_dialog::EventSourceDialog;
 use crate::session::room::{Event, Item, ItemType};
-use matrix_sdk::ruma::events::AnySyncRoomEvent;
+use crate::utils::cache_dir;
+use crate::{spawn, spawn_tokio, Error, Window};
 
 mod imp {
     use super::*;
@@ -34,6 +40,26 @@ mod imp {
                     EventSourceDialog::new(&window, widget.item().unwrap().event().unwrap());
                 dialog.show();
             });
+
+            // Save message's file
+            klass.install_action("item-row.file-save", None, move |widget, _, _| {
+                spawn!(
+                    glib::PRIORITY_LOW,
+                    clone!(@weak widget as obj => async move {
+                        obj.save_file().await;
+                    })
+                );
+            });
+
+            // Open message's file
+            klass.install_action("item-row.file-open", None, move |widget, _, _| {
+                spawn!(
+                    glib::PRIORITY_LOW,
+                    clone!(@weak widget as obj => async move {
+                        obj.open_file().await;
+                    })
+                );
+            });
         }
     }
 
@@ -240,6 +266,125 @@ impl ItemRow {
             }
         }
     }
+
+    pub async fn save_file(&self) {
+        let (filename, data) = match self.get_media_content().await {
+            Ok(res) => res,
+            Err(err) => {
+                error!("Could not get file: {}", err);
+
+                let error_message = err.to_user_facing();
+                let error = Error::new(move |_| {
+                    let error_label = gtk::LabelBuilder::new()
+                        .label(&error_message)
+                        .wrap(true)
+                        .build();
+                    Some(error_label.upcast())
+                });
+                if let Some(window) = self.root().and_then(|root| root.downcast::<Window>().ok()) {
+                    window.append_error(&error);
+                }
+
+                return;
+            }
+        };
+
+        let window: gtk::Window = self.root().unwrap().downcast().unwrap();
+        let dialog = gtk::FileChooserDialog::new(
+            Some(&gettext("Save File")),
+            Some(&window),
+            FileChooserAction::Save,
+            &[
+                (&gettext("Save"), ResponseType::Accept),
+                (&gettext("Cancel"), ResponseType::Cancel),
+            ],
+        );
+        dialog.set_current_name(&filename);
+
+        let response = dialog.run_future().await;
+        if response == ResponseType::Accept {
+            if let Some(file) = dialog.file() {
+                file.replace_contents(
+                    &data,
+                    None,
+                    false,
+                    gio::FileCreateFlags::REPLACE_DESTINATION,
+                    gio::NONE_CANCELLABLE,
+                )
+                .unwrap();
+            }
+        }
+
+        dialog.close();
+    }
+
+    pub async fn open_file(&self) {
+        let (filename, data) = match self.get_media_content().await {
+            Ok(res) => res,
+            Err(err) => {
+                error!("Could not get file: {}", err);
+
+                let error_message = err.to_user_facing();
+                let error = Error::new(move |_| {
+                    let error_label = gtk::LabelBuilder::new()
+                        .label(&error_message)
+                        .wrap(true)
+                        .build();
+                    Some(error_label.upcast())
+                });
+                if let Some(window) = self.root().and_then(|root| root.downcast::<Window>().ok()) {
+                    window.append_error(&error);
+                }
+
+                return;
+            }
+        };
+
+        let mut path = cache_dir();
+        path.push(filename);
+        let file = gio::File::for_path(path);
+
+        file.replace_contents(
+            &data,
+            None,
+            false,
+            gio::FileCreateFlags::REPLACE_DESTINATION,
+            gio::NONE_CANCELLABLE,
+        )
+        .unwrap();
+
+        if let Err(error) = gio::AppInfo::launch_default_for_uri_async_future(
+            &file.uri(),
+            gio::NONE_APP_LAUNCH_CONTEXT,
+        )
+        .await
+        {
+            error!("Error opening file '{}': {}", file.uri(), error);
+        }
+    }
+
+    async fn get_media_content(&self) -> Result<(String, Vec<u8>), matrix_sdk::Error> {
+        let item = self.item().unwrap();
+        let event = item.event().unwrap();
+
+        if let AnySyncRoomEvent::Message(message_event) = event.matrix_event().unwrap() {
+            if let AnyMessageEventContent::RoomMessage(content) = message_event.content() {
+                let client = event.room().session().client();
+                match content.msgtype {
+                    MessageType::File(file_content) => {
+                        let content = file_content.clone();
+                        let handle =
+                            spawn_tokio!(async move { client.get_file(content, true).await });
+                        let data = handle.await.unwrap()?.unwrap();
+                        return Ok((file_content.filename.unwrap_or(file_content.body), data));
+                    }
+                    _ => {}
+                };
+            }
+        };
+
+        panic!("Trying to get the media content of an event of incompatible type");
+    }
 }
 
 impl Default for ItemRow {
diff --git a/src/session/content/message_row/file.rs b/src/session/content/message_row/file.rs
new file mode 100644
index 00000000..eaa317dc
--- /dev/null
+++ b/src/session/content/message_row/file.rs
@@ -0,0 +1,111 @@
+use adw::subclass::prelude::*;
+use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate};
+
+mod imp {
+    use super::*;
+    use glib::subclass::InitializingObject;
+    use once_cell::sync::Lazy;
+    use std::cell::RefCell;
+
+    #[derive(Debug, Default, CompositeTemplate)]
+    #[template(resource = "/org/gnome/FractalNext/content-message-file.ui")]
+    pub struct MessageFile {
+        /// The filename of the file
+        pub filename: RefCell<Option<String>>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for MessageFile {
+        const NAME: &'static str = "ContentMessageFile";
+        type Type = super::MessageFile;
+        type ParentType = adw::Bin;
+
+        fn class_init(klass: &mut Self::Class) {
+            Self::bind_template(klass);
+        }
+
+        fn instance_init(obj: &InitializingObject<Self>) {
+            obj.init_template();
+        }
+    }
+
+    impl ObjectImpl for MessageFile {
+        fn properties() -> &'static [glib::ParamSpec] {
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![glib::ParamSpec::new_string(
+                    "filename",
+                    "Filename",
+                    "The filename of the file",
+                    None,
+                    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() {
+                "filename" => obj.set_filename(value.get().unwrap()),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "filename" => obj.filename().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn constructed(&self, obj: &Self::Type) {
+            self.parent_constructed(obj);
+        }
+    }
+
+    impl WidgetImpl for MessageFile {}
+
+    impl BinImpl for MessageFile {}
+}
+
+glib::wrapper! {
+    /// A widget displaying an interface to download or open the content of a file message.
+    pub struct MessageFile(ObjectSubclass<imp::MessageFile>)
+        @extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
+}
+
+impl MessageFile {
+    pub fn new(filename: Option<String>) -> Self {
+        glib::Object::new(&[("filename", &filename)]).expect("Failed to create MessageFile")
+    }
+
+    pub fn set_filename(&self, filename: Option<String>) {
+        let priv_ = imp::MessageFile::from_instance(self);
+
+        let name = filename.filter(|name| !name.is_empty());
+
+        if name.as_ref() == priv_.filename.borrow().as_ref() {
+            return;
+        }
+
+        priv_.filename.replace(name);
+        self.notify("filename");
+    }
+
+    pub fn filename(&self) -> Option<String> {
+        let priv_ = imp::MessageFile::from_instance(self);
+        priv_.filename.borrow().to_owned()
+    }
+}
+
+impl Default for MessageFile {
+    fn default() -> Self {
+        Self::new(None)
+    }
+}
diff --git a/src/session/content/message_row/mod.rs b/src/session/content/message_row/mod.rs
index a31d611a..461e01c3 100644
--- a/src/session/content/message_row/mod.rs
+++ b/src/session/content/message_row/mod.rs
@@ -1,3 +1,4 @@
+mod file;
 mod text;
 
 use crate::components::Avatar;
@@ -13,7 +14,7 @@ use matrix_sdk::ruma::events::{
     AnyMessageEventContent, AnySyncMessageEvent, AnySyncRoomEvent,
 };
 
-use self::text::MessageText;
+use self::{file::MessageFile, text::MessageText};
 use crate::prelude::*;
 use crate::session::room::Event;
 
@@ -261,7 +262,11 @@ impl MessageRow {
                             MessageText::emote(message.formatted, message.body, event.sender());
                         priv_.content.set_child(Some(&child));
                     }
-                    MessageType::File(_message) => {}
+                    MessageType::File(message) => {
+                        let filename = message.filename.unwrap_or(message.body);
+                        let child = MessageFile::new(Some(filename));
+                        priv_.content.set_child(Some(&child));
+                    }
                     MessageType::Image(_message) => {}
                     MessageType::Location(_message) => {}
                     MessageType::Notice(message) => {
diff --git a/src/session/content/mod.rs b/src/session/content/mod.rs
index d07515ee..12bf3fb5 100644
--- a/src/session/content/mod.rs
+++ b/src/session/content/mod.rs
@@ -15,7 +15,6 @@ use self::explore::Explore;
 use self::invite::Invite;
 use self::item_row::ItemRow;
 use self::markdown_popover::MarkdownPopover;
-use self::message_row::MessageRow;
 use self::room_details::RoomDetails;
 use self::room_history::RoomHistory;
 use self::state_row::StateRow;
diff --git a/src/session/room/event.rs b/src/session/room/event.rs
index bc6f352e..cfe60c79 100644
--- a/src/session/room/event.rs
+++ b/src/session/room/event.rs
@@ -137,7 +137,7 @@ mod imp {
             match pspec.name() {
                 "source" => obj.source().to_value(),
                 "sender" => obj.sender().to_value(),
-                "room" => self.room.get().unwrap().to_value(),
+                "room" => obj.room().to_value(),
                 "show-header" => obj.show_header().to_value(),
                 "can-hide-header" => obj.can_hide_header().to_value(),
                 "time" => obj.time().to_value(),
@@ -170,6 +170,11 @@ impl Event {
             .member_by_id(&self.matrix_sender())
     }
 
+    pub fn room(&self) -> &Room {
+        let priv_ = imp::Event::from_instance(self);
+        priv_.room.get().unwrap()
+    }
+
     /// Get the matrix event
     ///
     /// If the `SyncRoomEvent` couldn't be deserialized this is `None`
diff --git a/src/utils.rs b/src/utils.rs
index 19a6db3e..e4b336a2 100644
--- a/src/utils.rs
+++ b/src/utils.rs
@@ -58,8 +58,10 @@ macro_rules! spawn_tokio {
     };
 }
 
-use gtk::gio::prelude::*;
-use gtk::glib::Object;
+use std::path::PathBuf;
+
+use gtk::gio::{self, prelude::*};
+use gtk::glib::{self, Object};
 
 /// Returns an expression looking up the given property on `object`.
 pub fn prop_expr<T: IsA<Object>>(object: &T, prop: &str) -> gtk::Expression {
@@ -106,3 +108,16 @@ pub fn not_expr(a_expr: gtk::Expression) -> gtk::Expression {
     )
     .upcast()
 }
+
+pub fn cache_dir() -> PathBuf {
+    let mut path = glib::user_cache_dir();
+    path.push("fractal");
+
+    if !path.exists() {
+        let dir = gio::File::for_path(path.clone());
+        dir.make_directory_with_parents(gio::NONE_CANCELLABLE)
+            .unwrap();
+    }
+
+    path
+}


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