fractal/session/view/content/room_history/message_row/
visual_media.rsuse adw::{prelude::*, subclass::prelude::*};
use gettextrs::gettext;
use gtk::{
gdk,
glib::{self, clone},
CompositeTemplate,
};
use matrix_sdk::Client;
use ruma::api::client::media::get_content_thumbnail::v3::Method;
use tracing::{error, warn};
use super::{content::MessageCacheKey, ContentFormat};
use crate::{
components::{AnimatedImagePaintable, VideoPlayer},
gettext_f,
session::model::Session,
spawn,
utils::{
matrix::VisualMediaMessage,
media::{
image::{ImageRequestPriority, ThumbnailSettings, THUMBNAIL_MAX_DIMENSIONS},
FrameDimensions,
},
CountedRef, File, LoadingState,
},
};
const FALLBACK_DIMENSIONS: FrameDimensions = FrameDimensions {
width: 480,
height: 360,
};
const MAX_COMPACT_DIMENSIONS: FrameDimensions = FrameDimensions {
width: 75,
height: 50,
};
const MEDIA_PAGE: &str = "media";
mod imp {
use std::cell::{Cell, RefCell};
use glib::subclass::InitializingObject;
use super::*;
#[derive(Debug, Default, CompositeTemplate, glib::Properties)]
#[template(
resource = "/org/gnome/Fractal/ui/session/view/content/room_history/message_row/visual_media.ui"
)]
#[properties(wrapper_type = super::MessageVisualMedia)]
pub struct MessageVisualMedia {
#[template_child]
overlay: TemplateChild<gtk::Overlay>,
#[template_child]
stack: TemplateChild<gtk::Stack>,
#[template_child]
spinner: TemplateChild<adw::Spinner>,
#[template_child]
error: TemplateChild<gtk::Image>,
dimensions: Cell<Option<FrameDimensions>>,
#[property(get, builder(LoadingState::default()))]
state: Cell<LoadingState>,
#[property(get)]
compact: Cell<bool>,
#[property(get)]
activatable: Cell<bool>,
gesture_click: glib::WeakRef<gtk::GestureClick>,
cache_key: RefCell<MessageCacheKey>,
file: RefCell<Option<File>>,
paintable_animation_ref: RefCell<Option<CountedRef>>,
}
#[glib::object_subclass]
impl ObjectSubclass for MessageVisualMedia {
const NAME: &'static str = "ContentMessageVisualMedia";
type Type = super::MessageVisualMedia;
type ParentType = gtk::Widget;
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
klass.set_css_name("message-visual-media");
klass.set_accessible_role(gtk::AccessibleRole::Group);
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for MessageVisualMedia {
fn dispose(&self) {
self.overlay.unparent();
}
}
impl WidgetImpl for MessageVisualMedia {
fn measure(&self, orientation: gtk::Orientation, for_size: i32) -> (i32, i32, i32, i32) {
let max_size = if self.compact.get() {
MAX_COMPACT_DIMENSIONS
} else {
THUMBNAIL_MAX_DIMENSIONS
};
let max = max_size.dimension_for_orientation(orientation);
let max_for_size = max_size
.dimension_for_other_orientation(orientation)
.try_into()
.unwrap_or(i32::MAX);
let for_size = if for_size == -1 {
max_for_size
} else {
for_size.min(max_for_size)
};
if self.stack.visible_child_name().as_deref() == Some(MEDIA_PAGE) {
if let Some(child) = self.media_child::<gtk::Widget>() {
let other_orientation = if orientation == gtk::Orientation::Vertical {
gtk::Orientation::Horizontal
} else {
gtk::Orientation::Vertical
};
let (_, intrinsic_for_size, ..) = child.measure(other_orientation, -1);
let (min, nat, ..) =
child.measure(orientation, for_size.min(intrinsic_for_size));
if nat != 0 {
let max = max.try_into().unwrap_or(i32::MAX);
return (min.min(max), nat.min(max), -1, -1);
}
}
}
let for_size = u32::try_from(for_size).unwrap_or(0);
let wanted_size = if orientation == gtk::Orientation::Vertical {
FrameDimensions {
width: for_size,
height: max,
}
} else {
FrameDimensions {
width: max,
height: for_size,
}
};
let media_size = self.dimensions.get().unwrap_or(FALLBACK_DIMENSIONS);
let nat = media_size
.scale_to_fit(wanted_size, gtk::ContentFit::ScaleDown)
.dimension_for_orientation(orientation)
.try_into()
.unwrap_or(i32::MAX);
(0, nat, -1, -1)
}
fn request_mode(&self) -> gtk::SizeRequestMode {
gtk::SizeRequestMode::HeightForWidth
}
fn size_allocate(&self, width: i32, height: i32, baseline: i32) {
self.overlay.allocate(width, height, baseline, None);
}
fn map(&self) {
self.parent_map();
self.update_animated_paintable_state();
}
fn unmap(&self) {
self.parent_unmap();
self.update_animated_paintable_state();
}
}
impl MessageVisualMedia {
pub(super) fn media_child<T: IsA<gtk::Widget>>(&self) -> Option<T> {
self.stack.child_by_name(MEDIA_PAGE).and_downcast()
}
fn set_media_child(&self, child: &impl IsA<gtk::Widget>) {
if let Some(prev_child) = self.stack.child_by_name(MEDIA_PAGE) {
self.stack.remove(&prev_child);
}
self.stack.add_named(child, Some(MEDIA_PAGE));
}
fn set_state(&self, state: LoadingState) {
if self.state.get() == state {
return;
}
match state {
LoadingState::Loading | LoadingState::Initial => {
self.stack.set_visible_child_name("placeholder");
self.spinner.set_visible(true);
self.error.set_visible(false);
}
LoadingState::Ready => {
self.stack.set_visible_child_name(MEDIA_PAGE);
self.spinner.set_visible(false);
self.error.set_visible(false);
}
LoadingState::Error => {
self.spinner.set_visible(false);
self.error.set_visible(true);
}
}
self.state.set(state);
self.obj().notify_state();
}
fn update_animated_paintable_state(&self) {
self.paintable_animation_ref.take();
let Some(paintable) = self
.media_child::<gtk::Picture>()
.and_then(|p| p.paintable())
.and_downcast::<AnimatedImagePaintable>()
else {
return;
};
if self.obj().is_mapped() {
self.paintable_animation_ref
.replace(Some(paintable.animation_ref()));
}
}
fn set_compact(&self, compact: bool) {
if self.compact.get() == compact {
return;
}
self.compact.set(compact);
if compact {
self.overlay.add_css_class("compact");
} else {
self.overlay.remove_css_class("compact");
}
self.update_gesture_click();
self.obj().notify_compact();
}
fn set_activatable(&self, activatable: bool) {
if self.activatable.get() == activatable {
return;
}
self.activatable.set(activatable);
self.update_gesture_click();
self.obj().notify_activatable();
}
fn update_gesture_click(&self) {
let needs_controller = self.activatable.get() && !self.compact.get();
let gesture_click = self.gesture_click.upgrade();
if needs_controller && gesture_click.is_none() {
let gesture_click = gtk::GestureClick::new();
gesture_click.connect_released(clone!(
#[weak(rename_to = imp)]
self,
move |_, _, _, _| {
if imp
.obj()
.activate_action("message-row.show-media", None)
.is_err()
{
error!("Could not activate `message-row.show-media` action");
}
}
));
self.gesture_click.set(Some(&gesture_click));
self.overlay.add_controller(gesture_click);
} else if let Some(gesture_click) = gesture_click {
self.gesture_click.set(None);
self.overlay.remove_controller(&gesture_click);
}
}
fn set_cache_key(&self, key: MessageCacheKey) -> bool {
let should_reload = self.cache_key.borrow().should_reload(&key);
self.cache_key.replace(key);
should_reload
}
pub(super) fn build(
&self,
media_message: VisualMediaMessage,
session: &Session,
format: ContentFormat,
cache_key: MessageCacheKey,
) {
if !self.set_cache_key(cache_key) {
return;
}
self.file.take();
self.dimensions.set(media_message.dimensions());
let compact = matches!(format, ContentFormat::Compact | ContentFormat::Ellipsized);
self.set_compact(compact);
let activatable = matches!(
media_message,
VisualMediaMessage::Image(_) | VisualMediaMessage::Video(_)
);
self.set_activatable(activatable);
let filename = media_message.filename();
let accessible_label = if filename.is_empty() {
match &media_message {
VisualMediaMessage::Image(_) => gettext("Image"),
VisualMediaMessage::Sticker(_) => gettext("Sticker"),
VisualMediaMessage::Video(_) => gettext("Video"),
}
} else {
match &media_message {
VisualMediaMessage::Image(_) => {
gettext_f("Image: {filename}", &[("filename", &filename)])
}
VisualMediaMessage::Sticker(_) => {
gettext_f("Sticker: {filename}", &[("filename", &filename)])
}
VisualMediaMessage::Video(_) => {
gettext_f("Video: {filename}", &[("filename", &filename)])
}
}
};
self.obj()
.update_property(&[gtk::accessible::Property::Label(&accessible_label)]);
self.set_state(LoadingState::Loading);
let client = session.client();
spawn!(
glib::Priority::LOW,
clone!(
#[weak(rename_to = imp)]
self,
async move {
match &media_message {
VisualMediaMessage::Image(_) | VisualMediaMessage::Sticker(_) => {
imp.build_image(&media_message, client).await;
}
VisualMediaMessage::Video(_) => {
imp.build_video(media_message, &client).await;
}
}
imp.update_animated_paintable_state();
}
)
);
}
async fn build_image(&self, media_message: &VisualMediaMessage, client: Client) {
if matches!(media_message, VisualMediaMessage::Image(_)) {
self.enable_copy_image_action(false);
}
let scale_factor = self.obj().scale_factor();
let settings = ThumbnailSettings {
dimensions: FrameDimensions::thumbnail_max_dimensions(scale_factor),
method: Method::Scale,
animated: true,
prefer_thumbnail: false,
};
let image = match media_message
.thumbnail(client, settings, ImageRequestPriority::Default)
.await
{
Ok(Some(image)) => image,
Ok(None) => unreachable!("Image messages should always have a fallback"),
Err(error) => {
self.set_error(&error.to_string());
return;
}
};
let child = if let Some(child) = self.media_child::<gtk::Picture>() {
child
} else {
let child = gtk::Picture::builder()
.content_fit(gtk::ContentFit::ScaleDown)
.build();
self.set_media_child(&child);
child
};
child.set_paintable(Some(&gdk::Paintable::from(image)));
child.set_tooltip_text(Some(&media_message.filename()));
if matches!(&media_message, VisualMediaMessage::Sticker(_)) {
self.overlay.remove_css_class("opaque-bg");
} else {
self.overlay.add_css_class("opaque-bg");
}
self.set_state(LoadingState::Ready);
if matches!(media_message, VisualMediaMessage::Image(_)) {
self.enable_copy_image_action(true);
}
}
fn enable_copy_image_action(&self, enable: bool) {
if self.compact.get() {
return;
}
if self
.obj()
.activate_action(
"room-history-row.enable-copy-image",
Some(&enable.to_variant()),
)
.is_err()
{
error!("Could not change state of copy-image action: `room-history-row.enable-copy-image` action not found");
}
}
async fn build_video(&self, media_message: VisualMediaMessage, client: &Client) {
let file = match media_message.into_tmp_file(client).await {
Ok(file) => file,
Err(error) => {
warn!("Could not retrieve video: {error}");
self.set_error(&gettext("Could not retrieve media"));
return;
}
};
let child = if let Some(child) = self.media_child::<VideoPlayer>() {
child
} else {
let child = VideoPlayer::new();
child.connect_state_notify(clone!(
#[weak(rename_to = imp)]
self,
move |player| {
imp.video_state_changed(player);
}
));
self.set_media_child(&child);
child
};
child.set_compact(self.compact.get());
child.play_video_file(file.as_gfile());
self.file.replace(Some(file));
}
fn set_error(&self, message: &str) {
self.error.set_tooltip_text(Some(message));
self.set_state(LoadingState::Error);
}
fn video_state_changed(&self, player: &VideoPlayer) {
match player.state() {
LoadingState::Initial | LoadingState::Loading => {
self.set_state(LoadingState::Loading);
}
LoadingState::Ready => self.set_state(LoadingState::Ready),
LoadingState::Error => {
let error = player.error();
self.set_error(
error
.map(|e| e.to_string())
.as_deref()
.unwrap_or(&gettext("An unexpected error occurred")),
);
}
}
}
}
}
glib::wrapper! {
pub struct MessageVisualMedia(ObjectSubclass<imp::MessageVisualMedia>)
@extends gtk::Widget, @implements gtk::Accessible;
}
impl MessageVisualMedia {
pub(crate) fn new() -> Self {
glib::Object::new()
}
pub(crate) fn set_media_message(
&self,
media_message: VisualMediaMessage,
session: &Session,
format: ContentFormat,
cache_key: MessageCacheKey,
) {
self.imp().build(media_message, session, format, cache_key);
}
pub(crate) fn texture(&self) -> Option<gdk::Texture> {
let paintable = self
.imp()
.media_child::<gtk::Picture>()
.and_then(|p| p.paintable())?;
if let Some(paintable) = paintable.downcast_ref::<AnimatedImagePaintable>() {
paintable.current_texture()
} else {
paintable.downcast().ok()
}
}
}
impl Default for MessageVisualMedia {
fn default() -> Self {
Self::new()
}
}