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 @@ ui/content-public-room-row.ui ui/content-item.ui ui/content-item-row-menu.ui + ui/content-message-file.ui ui/content-message-row.ui ui/content-divider-row.ui ui/content-room-details.ui 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 @@ + + + + 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::().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::().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), 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>, + } + + #[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) { + obj.init_template(); + } + } + + impl ObjectImpl for MessageFile { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = 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) + @extends gtk::Widget, adw::Bin, @implements gtk::Accessible; +} + +impl MessageFile { + pub fn new(filename: Option) -> Self { + glib::Object::new(&[("filename", &filename)]).expect("Failed to create MessageFile") + } + + pub fn set_filename(&self, filename: Option) { + 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 { + 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>(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 +}