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 @@
+
+
+
+ True
+ center
+
+
+
+
+
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