diff --git a/data/resources/style.css b/data/resources/style.css index 45a58304..8475ff11 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -191,6 +191,10 @@ headerbar.flat { margin-left: 46px; } +.room-history .event-content .thumbnail { + border-radius: 6px; +} + .divider-row { font-size: 0.9em; font-weight: bold; diff --git a/po/POTFILES.in b/po/POTFILES.in index 1278d7ca..e4cc9f4f 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -77,6 +77,7 @@ 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/image.rs src/session/content/message_row/mod.rs src/session/content/message_row/text.rs src/session/content/mod.rs diff --git a/src/meson.build b/src/meson.build index 0df36dcf..8dbaccbc 100644 --- a/src/meson.build +++ b/src/meson.build @@ -62,6 +62,7 @@ sources = files( 'session/content/invite.rs', 'session/content/markdown_popover.rs', 'session/content/message_row/file.rs', + 'session/content/message_row/image.rs', 'session/content/message_row/mod.rs', 'session/content/message_row/text.rs', 'session/content/mod.rs', diff --git a/src/session/content/message_row/image.rs b/src/session/content/message_row/image.rs new file mode 100644 index 00000000..88069ab4 --- /dev/null +++ b/src/session/content/message_row/image.rs @@ -0,0 +1,267 @@ +use std::convert::TryInto; + +use adw::{prelude::BinExt, subclass::prelude::*}; +use gettextrs::gettext; +use gtk::{ + gdk, + gdk_pixbuf::Pixbuf, + gio, + glib::{self, clone}, + prelude::*, + subclass::prelude::*, +}; +use log::warn; +use matrix_sdk::{ + media::{MediaEventContent, MediaThumbnailSize}, + ruma::{ + api::client::r0::media::get_content_thumbnail::Method, + events::room::{message::ImageMessageEventContent, ImageInfo}, + uint, + }, +}; + +use crate::{session::Session, spawn, spawn_tokio}; + +mod imp { + use std::cell::Cell; + + use once_cell::sync::Lazy; + + use super::*; + + #[derive(Debug, Default)] + pub struct MessageImage { + /// The intended display width of the full image. + pub width: Cell, + /// The intended display height of the full image. + pub height: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for MessageImage { + const NAME: &'static str = "ContentMessageImage"; + type Type = super::MessageImage; + type ParentType = adw::Bin; + } + + impl ObjectImpl for MessageImage { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpec::new_int( + "width", + "Width", + "The intended display width of the full image", + -1, + i32::MAX, + -1, + glib::ParamFlags::WRITABLE, + ), + glib::ParamSpec::new_int( + "height", + "Height", + "The intended display height of the full image", + -1, + i32::MAX, + -1, + glib::ParamFlags::WRITABLE, + ), + ] + }); + + PROPERTIES.as_ref() + } + + fn set_property( + &self, + _obj: &Self::Type, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + match pspec.name() { + "width" => { + self.width.set(value.get().unwrap()); + } + "height" => { + self.height.set(value.get().unwrap()); + } + _ => unimplemented!(), + } + } + + fn constructed(&self, obj: &Self::Type) { + self.parent_constructed(obj); + + // We need to control the value returned by `measure`. + obj.set_layout_manager(gtk::NONE_LAYOUT_MANAGER); + } + } + + impl WidgetImpl for MessageImage { + fn measure( + &self, + obj: &Self::Type, + orientation: gtk::Orientation, + for_size: i32, + ) -> (i32, i32, i32, i32) { + match obj.child() { + Some(child) => { + // The GdkPaintable will keep its ratio, so we only need to control the height. + if orientation == gtk::Orientation::Vertical { + let original_width = self.width.get(); + let original_height = self.height.get(); + + // We limit the thumbnail's width to 320 pixels. + let width = for_size.min(320); + + let nat_height = if original_height > 0 && original_width > 0 { + // We don't want the image to be upscaled. + let width = width.min(original_width); + width * original_height / original_width + } else { + // Get the natural height of the image data. + child.measure(orientation, width).1 + }; + + // We limit the thumbnail's height to 240 pixels. + let height = nat_height.min(240); + (0, height, -1, -1) + } else { + child.measure(orientation, for_size) + } + } + None => (0, 0, -1, -1), + } + } + + fn request_mode(&self, _obj: &Self::Type) -> gtk::SizeRequestMode { + gtk::SizeRequestMode::HeightForWidth + } + + fn size_allocate(&self, obj: &Self::Type, _width: i32, height: i32, baseline: i32) { + if let Some(child) = obj.child() { + // We need to allocate just enough width to the child so it doesn't expand. + let original_width = self.width.get(); + let original_height = self.height.get(); + let width = if original_height > 0 && original_width > 0 { + height * original_width / original_height + } else { + // Get the natural width of the image data. + child.measure(gtk::Orientation::Horizontal, height).1 + }; + + child.allocate(width, height, baseline, None); + } + } + } + + impl BinImpl for MessageImage {} +} + +glib::wrapper! { + /// A widget displaying an image message. + pub struct MessageImage(ObjectSubclass) + @extends gtk::Widget, adw::Bin, @implements gtk::Accessible; +} + +impl MessageImage { + pub fn image(image: ImageMessageEventContent, session: &Session) -> Self { + let (width, height) = get_width_height(image.info.as_deref()); + + let self_: Self = glib::Object::new(&[("width", &width), ("height", &height)]) + .expect("Failed to create MessageImage"); + self_.build(image, session); + self_ + } + + fn build(&self, content: C, session: &Session) + where + C: MediaEventContent + Send + Sync + 'static, + { + let client = session.client(); + let handle = match content.thumbnail() { + Some(_) => { + spawn_tokio!(async move { + client + .get_thumbnail( + content, + MediaThumbnailSize { + method: Method::Scale, + width: uint!(320), + height: uint!(240), + }, + true, + ) + .await + }) + } + None => { + spawn_tokio!(async move { client.get_file(content, true,).await }) + } + }; + + spawn!( + glib::PRIORITY_LOW, + clone!(@weak self as obj => async move { + match handle.await.unwrap() { + Ok(Some(data)) => { + let stream = gio::MemoryInputStream::from_bytes(&glib::Bytes::from(&data)); + let texture = Pixbuf::from_stream(&stream, gio::NONE_CANCELLABLE) + .ok() + .map(|pixbuf| gdk::Texture::for_pixbuf(&pixbuf)); + let child = gtk::Picture::for_paintable(texture.as_ref()); + + // To get rounded corners + child.set_overflow(gtk::Overflow::Hidden); + child.add_css_class("thumbnail"); + + obj.set_child(Some(&child)); + obj.queue_resize(); + } + Ok(None) => { + warn!("Could not retrieve invalid image file"); + let child = gtk::Label::new(Some(&gettext("Could not retrieve image"))); + obj.set_child(Some(&child)); + } + Err(error) => { + warn!("Could not retrieve image file: {}", error); + let child = gtk::Label::new(Some(&gettext("Could not retrieve image"))); + obj.set_child(Some(&child)); + } + } + }) + ); + } +} + +/// Gets the width and height of the full image in info. +/// +/// Returns a (width, height) tuple with either value set to -1 if it wasn't found. +fn get_width_height(info: Option<&ImageInfo>) -> (i32, i32) { + let width = info + .and_then(|info| info.width) + .and_then(|ui| { + let u: Option = ui.try_into().ok(); + u + }) + .and_then(|u| { + let i: i32 = u.into(); + Some(i) + }) + .unwrap_or(-1); + + let height = info + .and_then(|info| info.height) + .and_then(|ui| { + let u: Option = ui.try_into().ok(); + u + }) + .and_then(|u| { + let i: i32 = u.into(); + Some(i) + }) + .unwrap_or(-1); + + (width, height) +} diff --git a/src/session/content/message_row/mod.rs b/src/session/content/message_row/mod.rs index 461e01c3..288197a9 100644 --- a/src/session/content/message_row/mod.rs +++ b/src/session/content/message_row/mod.rs @@ -1,4 +1,5 @@ mod file; +mod image; mod text; use crate::components::Avatar; @@ -14,7 +15,7 @@ use matrix_sdk::ruma::events::{ AnyMessageEventContent, AnySyncMessageEvent, AnySyncRoomEvent, }; -use self::{file::MessageFile, text::MessageText}; +use self::{file::MessageFile, image::MessageImage, text::MessageText}; use crate::prelude::*; use crate::session::room::Event; @@ -267,7 +268,10 @@ impl MessageRow { let child = MessageFile::new(Some(filename)); priv_.content.set_child(Some(&child)); } - MessageType::Image(_message) => {} + MessageType::Image(message) => { + let child = MessageImage::image(message, &event.room().session()); + priv_.content.set_child(Some(&child)); + } MessageType::Location(_message) => {} MessageType::Notice(message) => { let child = MessageText::markup(message.formatted, message.body); diff --git a/src/session/content/room_history.rs b/src/session/content/room_history.rs index 1ef7785a..ac1166ac 100644 --- a/src/session/content/room_history.rs +++ b/src/session/content/room_history.rs @@ -177,6 +177,10 @@ mod imp { } fn constructed(&self, obj: &Self::Type) { + // Needed to use the natural height of GtkPictures + self.listview + .set_vscroll_policy(gtk::ScrollablePolicy::Natural); + obj.set_sticky(true); let adj = self.listview.vadjustment().unwrap();