diff --git a/Cargo.lock b/Cargo.lock index 80c329d6..535c7716 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -998,6 +998,7 @@ dependencies = [ "log", "matrix-sdk", "mime", + "mime_guess", "once_cell", "qrcode", "rand 0.8.4", @@ -2340,6 +2341,16 @@ version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" +[[package]] +name = "mime_guess" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2684d4c2e97d99848d30b324b00c8fcc7e5c897b7cbb5819b09e7c90e8baf212" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.3.7" diff --git a/Cargo.toml b/Cargo.toml index 83f44389..cd10e056 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ gst_base = {version = "0.17", package = "gstreamer-base"} gst_video = {version = "0.17", package = "gstreamer-video"} image = {version = "0.23", default-features = false, features=["png"]} regex = "1.5.4" +mime_guess = "2.0.3" [dependencies.sourceview] package = "sourceview5" diff --git a/src/session/content/room_history/message_row/media.rs b/src/session/content/room_history/message_row/media.rs index a7cbad5a..b8cf64df 100644 --- a/src/session/content/room_history/message_row/media.rs +++ b/src/session/content/room_history/message_row/media.rs @@ -25,7 +25,7 @@ use crate::{ components::VideoPlayer, session::Session, spawn, spawn_tokio, - utils::{cache_dir, uint_to_i32}, + utils::{cache_dir, media_type_uid, uint_to_i32}, }; const MAX_THUMBNAIL_WIDTH: i32 = 600; @@ -378,9 +378,11 @@ impl MessageMedia { }; if let Some(data) = thumbnail { - Ok(Some(data)) + let id = media_type_uid(content.thumbnail()); + Ok((Some(data), id)) } else { - client.get_file(content, true).await + let id = media_type_uid(content.file()); + client.get_file(content, true).await.map(|data| (data, id)) } }); @@ -390,7 +392,7 @@ impl MessageMedia { let priv_ = imp::MessageMedia::from_instance(&obj); match handle.await.unwrap() { - Ok(Some(data)) => { + Ok((Some(data), id)) => { match media_type { MediaType::Image | MediaType::Sticker => { let stream = gio::MemoryInputStream::from_bytes(&glib::Bytes::from(&data)); @@ -421,7 +423,7 @@ impl MessageMedia { // we need to store the file. // See: https://gitlab.gnome.org/GNOME/gtk/-/issues/4062 let mut path = cache_dir(); - path.push(body.unwrap()); + path.push(format!("{}_{}", id, body.unwrap_or_default())); let file = gio::File::for_path(path); file.replace_contents( &data, @@ -450,7 +452,7 @@ impl MessageMedia { obj.set_state(MediaState::Ready); } - Ok(None) => { + Ok((None, _)) => { warn!("Could not retrieve invalid media file"); priv_.overlay_error.set_tooltip_text(Some(&gettext("Could not retrieve media"))); obj.set_state(MediaState::Error); diff --git a/src/session/content/room_history/message_row/mod.rs b/src/session/content/room_history/message_row/mod.rs index c6359641..f5a234a4 100644 --- a/src/session/content/room_history/message_row/mod.rs +++ b/src/session/content/room_history/message_row/mod.rs @@ -2,7 +2,7 @@ mod file; mod media; mod text; -use crate::components::Avatar; +use crate::{components::Avatar, utils::filename_for_mime}; use adw::{prelude::*, subclass::prelude::*}; use gettextrs::gettext; use gtk::{ @@ -217,7 +217,18 @@ impl MessageRow { child.emote(message.formatted, message.body, event.sender()); } MessageType::File(message) => { - let filename = message.filename.unwrap_or(message.body); + let info = message.info.as_ref(); + let filename = message + .filename + .filter(|name| !name.is_empty()) + .or(Some(message.body)) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| { + filename_for_mime( + info.and_then(|info| info.mimetype.as_deref()), + None, + ) + }); let child = if let Some(Ok(child)) = priv_.content.child().map(|w| w.downcast::()) diff --git a/src/session/media_viewer.rs b/src/session/media_viewer.rs index 9dd65536..f63a3561 100644 --- a/src/session/media_viewer.rs +++ b/src/session/media_viewer.rs @@ -270,7 +270,7 @@ impl MediaViewer { let priv_ = imp::MediaViewer::from_instance(&obj); match event.get_media_content().await { - Ok((_, data)) => { + Ok((_, _, data)) => { let stream = gio::MemoryInputStream::from_bytes(&glib::Bytes::from(&data)); let texture = Pixbuf::from_stream(&stream, gio::NONE_CANCELLABLE) .ok() @@ -297,12 +297,12 @@ impl MediaViewer { let priv_ = imp::MediaViewer::from_instance(&obj); match event.get_media_content().await { - Ok((_, data)) => { + Ok((uid, filename, data)) => { // The GStreamer backend of GtkVideo doesn't work with input streams so // we need to store the file. // See: https://gitlab.gnome.org/GNOME/gtk/-/issues/4062 let mut path = cache_dir(); - path.push(video.body); + path.push(format!("{}_{}", uid, filename)); let file = gio::File::for_path(path); file.replace_contents( &data, diff --git a/src/session/room/event.rs b/src/session/room/event.rs index 8052a64c..cb717506 100644 --- a/src/session/room/event.rs +++ b/src/session/room/event.rs @@ -2,6 +2,7 @@ use gtk::{glib, glib::clone, glib::DateTime, prelude::*, subclass::prelude::*}; use log::warn; use matrix_sdk::{ deserialized_responses::SyncRoomEvent, + media::MediaEventContent, ruma::{ events::{ room::message::Relation, @@ -17,6 +18,7 @@ use matrix_sdk::{ use crate::{ session::{room::Member, Room}, spawn_tokio, + utils::{filename_for_mime, media_type_uid}, }; #[derive(Clone, Debug, glib::GBoxed)] @@ -622,29 +624,68 @@ impl Event { /// - Image message (`MessageType::Image`). /// - Video message (`MessageType::Video`). /// - /// Returns `Ok((filename, binary_content))` on success, `Err` if an error occured while - /// fetching the content. Panics on an incompatible event. - pub async fn get_media_content(&self) -> Result<(String, Vec), matrix_sdk::Error> { + /// Returns `Ok((uid, filename, binary_content))` on success, `Err` if an error occured while + /// fetching the content. Panics on an incompatible event. `uid` is a unique identifier for this + /// media. + pub async fn get_media_content(&self) -> Result<(String, String, Vec), matrix_sdk::Error> { if let AnyMessageEventContent::RoomMessage(content) = self.message_content().unwrap() { let client = self.room().session().client(); match content.msgtype { MessageType::File(content) => { - let filename = content.filename.clone().unwrap_or(content.body.clone()); + let uid = media_type_uid(content.file()); + let filename = content + .filename + .as_ref() + .filter(|name| !name.is_empty()) + .or(Some(&content.body)) + .filter(|name| !name.is_empty()) + .map(|name| name.clone()) + .unwrap_or_else(|| { + filename_for_mime( + content + .info + .as_ref() + .and_then(|info| info.mimetype.as_deref()), + None, + ) + }); let handle = spawn_tokio!(async move { client.get_file(content, true).await }); let data = handle.await.unwrap()?.unwrap(); - return Ok((filename, data)); + return Ok((uid, filename, data)); } MessageType::Image(content) => { - let filename = content.body.clone(); + let uid = media_type_uid(content.file()); + let filename = if content.body.is_empty() { + filename_for_mime( + content + .info + .as_ref() + .and_then(|info| info.mimetype.as_deref()), + Some(mime::IMAGE), + ) + } else { + content.body.clone() + }; let handle = spawn_tokio!(async move { client.get_file(content, true).await }); let data = handle.await.unwrap()?.unwrap(); - return Ok((filename, data)); + return Ok((uid, filename, data)); } MessageType::Video(content) => { - let filename = content.body.clone(); + let uid = media_type_uid(content.file()); + let filename = if content.body.is_empty() { + filename_for_mime( + content + .info + .as_ref() + .and_then(|info| info.mimetype.as_deref()), + Some(mime::VIDEO), + ) + } else { + content.body.clone() + }; let handle = spawn_tokio!(async move { client.get_file(content, true).await }); let data = handle.await.unwrap()?.unwrap(); - return Ok((filename, data)); + return Ok((uid, filename, data)); } _ => {} }; diff --git a/src/session/room/event_actions.rs b/src/session/room/event_actions.rs index d5bc145d..37d9d225 100644 --- a/src/session/room/event_actions.rs +++ b/src/session/room/event_actions.rs @@ -91,7 +91,7 @@ where spawn!( glib::PRIORITY_LOW, clone!(@weak window => async move { - let (filename, data) = match event.get_media_content().await { + let (_, filename, data) = match event.get_media_content().await { Ok(res) => res, Err(err) => { error!("Could not get file: {}", err); @@ -148,7 +148,7 @@ where spawn!( glib::PRIORITY_LOW, clone!(@weak window => async move { - let (filename, data) = match event.get_media_content().await { + let (uid, filename, data) = match event.get_media_content().await { Ok(res) => res, Err(err) => { error!("Could not get file: {}", err); @@ -168,6 +168,13 @@ where }; let mut path = cache_dir(); + path.push(uid); + if !path.exists() { + let dir = gio::File::for_path(path.clone()); + dir.make_directory_with_parents(gio::NONE_CANCELLABLE) + .unwrap(); + } + path.push(filename); let file = gio::File::for_path(path); diff --git a/src/utils.rs b/src/utils.rs index dad489e6..c392bdf7 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -62,10 +62,14 @@ macro_rules! spawn_tokio { use std::convert::TryInto; use std::path::PathBuf; +use std::str::FromStr; +use gettextrs::gettext; use gtk::gio::{self, prelude::*}; use gtk::glib::{self, Object}; +use matrix_sdk::media::MediaType; use matrix_sdk::ruma::UInt; +use mime::Mime; /// Returns an expression looking up the given property on `object`. pub fn prop_expr>(object: &T, prop: &str) -> gtk::Expression { @@ -161,3 +165,55 @@ pub fn style_scheme() -> Option { sourceview::StyleSchemeManager::default().and_then(|scm| scm.scheme(scheme_name)) } + +/// Get the unique id of the given `MediaType`. +/// +/// It is built from the underlying `MxcUri` and can be safely used in a filename. +/// +/// The id is not guaranteed to be unique for malformed `MxcUri`s. +pub fn media_type_uid(media_type: Option) -> String { + if let Some(mxc) = media_type + .map(|media_type| match media_type { + MediaType::Uri(uri) => uri, + MediaType::Encrypted(file) => file.url, + }) + .filter(|mxc| mxc.is_valid()) + { + format!("{}_{}", mxc.server_name().unwrap(), mxc.media_id().unwrap()) + } else { + "media_uid".to_owned() + } +} + +/// Get a default filename for a mime type. +/// +/// Tries to guess the file extension, but it might not find it. +/// +/// If the mime type is unknown, it uses the name for `fallback`. The fallback +/// mime types that are recognized are `mime::IMAGE`, `mime::VIDEO` +/// and `mime::AUDIO`, other values will behave the same as `None`. +pub fn filename_for_mime(mime_type: Option<&str>, fallback: Option) -> String { + let (type_, extension) = if let Some(mime) = mime_type.and_then(|m| Mime::from_str(m).ok()) { + let extension = + mime_guess::get_mime_extensions(&mime).map(|extensions| extensions[0].to_owned()); + + (Some(mime.type_().as_str().to_owned()), extension) + } else { + (fallback.map(|type_| type_.as_str().to_owned()), None) + }; + + let name = match type_.as_deref() { + // Translators: Default name for image files. + Some("image") => gettext("image"), + // Translators: Default name for video files. + Some("video") => gettext("video"), + // Translators: Default name for audio files. + Some("audio") => gettext("audio"), + // Translators: Default name for files. + _ => gettext("file"), + }; + + extension + .map(|extension| format!("{}.{}", name, extension)) + .unwrap_or(name) +}