From f21eccfc15fca049efada7bd4bd3a55d436ab39e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Tue, 30 Nov 2021 19:32:24 +0100 Subject: [PATCH] session: Add Media Viewer --- data/resources/resources.gresource.xml | 1 + data/resources/ui/content-item.ui | 10 +- data/resources/ui/content-room-history.ui | 2 +- data/resources/ui/media-viewer.ui | 93 ++++++++ data/resources/ui/session.ui | 4 +- po/POTFILES.in | 2 + src/meson.build | 2 +- src/session/content/room_history/mod.rs | 17 +- src/session/media_viewer.rs | 272 ++++++++++++++++++++++ src/session/mod.rs | 14 +- src/session/room/event.rs | 32 ++- src/session/room/item.rs | 21 ++ src/window.rs | 11 + 13 files changed, 470 insertions(+), 11 deletions(-) create mode 100644 data/resources/ui/media-viewer.ui create mode 100644 src/session/media_viewer.rs diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index 607d1544..bfb305e4 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -22,6 +22,7 @@ ui/event-menu.ui ui/event-source-dialog.ui ui/login.ui + ui/media-viewer.ui ui/session.ui ui/sidebar.ui ui/sidebar-account-switcher.ui diff --git a/data/resources/ui/content-item.ui b/data/resources/ui/content-item.ui index 3a2c3bce..19ef73bd 100644 --- a/data/resources/ui/content-item.ui +++ b/data/resources/ui/content-item.ui @@ -1,12 +1,16 @@ - diff --git a/data/resources/ui/media-viewer.ui b/data/resources/ui/media-viewer.ui new file mode 100644 index 00000000..c470ae48 --- /dev/null +++ b/data/resources/ui/media-viewer.ui @@ -0,0 +1,93 @@ + + + + \ No newline at end of file diff --git a/data/resources/ui/session.ui b/data/resources/ui/session.ui index f8dee1d9..ca7d10a5 100644 --- a/data/resources/ui/session.ui +++ b/data/resources/ui/session.ui @@ -61,8 +61,10 @@ + + + - diff --git a/po/POTFILES.in b/po/POTFILES.in index c47338ca..bd618fcd 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -28,6 +28,7 @@ data/resources/ui/event-menu.ui data/resources/ui/event-source-dialog.ui data/resources/ui/login.ui data/resources/ui/in-app-notification.ui +data/resources/ui/media-viewer.ui data/resources/ui/room-creation.ui data/resources/ui/session.ui data/resources/ui/session-verification.ui @@ -86,6 +87,7 @@ src/session/content/room_history/message_row/mod.rs src/session/content/room_history/message_row/text.rs src/session/content/room_history/mod.rs src/session/content/room_history/state_row.rs +src/session/media_viewer.rs src/session/mod.rs src/session/room_creation/mod.rs src/session/room_list.rs diff --git a/src/meson.build b/src/meson.build index ccc4245e..229e3a88 100644 --- a/src/meson.build +++ b/src/meson.build @@ -71,7 +71,7 @@ sources = files( 'session/content/mod.rs', 'session/content/room_details/member_page.rs', 'session/content/room_details/mod.rs', - 'session/room/event_actions.rs', + 'session/media_viewer.rs', 'session/room/event.rs', 'session/room/highlight_flags.rs', 'session/room/item.rs', diff --git a/src/session/content/room_history/mod.rs b/src/session/content/room_history/mod.rs index 715b9d08..176346a1 100644 --- a/src/session/content/room_history/mod.rs +++ b/src/session/content/room_history/mod.rs @@ -16,7 +16,7 @@ use sourceview::prelude::*; use crate::components::{CustomEntry, RoomTitle}; use crate::session::content::{MarkdownPopover, RoomDetails}; -use crate::session::room::{Room, RoomType}; +use crate::session::room::{Item, Room, RoomType}; mod imp { use super::*; @@ -193,6 +193,21 @@ mod imp { self.listview .set_vscroll_policy(gtk::ScrollablePolicy::Natural); + self.listview + .connect_activate(clone!(@weak obj => move |listview, pos| { + if let Some(item) = listview + .model() + .and_then(|model| model.item(pos)) + .and_then(|o| o.downcast::().ok()) + { + if let Some(event) = item.event() { + if let Some(room) = obj.room() { + room.session().show_media(event); + } + } + } + })); + obj.set_sticky(true); let adj = self.listview.vadjustment().unwrap(); diff --git a/src/session/media_viewer.rs b/src/session/media_viewer.rs new file mode 100644 index 00000000..aaa84659 --- /dev/null +++ b/src/session/media_viewer.rs @@ -0,0 +1,272 @@ +use adw::{prelude::*, subclass::prelude::*}; +use gettextrs::gettext; +use gtk::{ + gdk, gdk_pixbuf::Pixbuf, gio, glib, glib::clone, subclass::prelude::*, CompositeTemplate, +}; +use log::warn; +use matrix_sdk::ruma::events::{room::message::MessageType, AnyMessageEventContent}; + +use crate::{ + components::{ContextMenuBin, ContextMenuBinImpl}, + session::room::Event, + spawn, Window, +}; + +use super::room::EventActions; + +mod imp { + use crate::components::ContextMenuBinExt; + + use super::*; + use glib::object::WeakRef; + use glib::subclass::InitializingObject; + use once_cell::sync::Lazy; + use std::cell::{Cell, RefCell}; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/FractalNext/media-viewer.ui")] + pub struct MediaViewer { + pub fullscreened: Cell, + pub event: RefCell>>, + pub body: RefCell>, + #[template_child] + pub headerbar_revealer: TemplateChild, + #[template_child] + pub menu_full: TemplateChild, + #[template_child] + pub media: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for MediaViewer { + const NAME: &'static str = "MediaViewer"; + type Type = super::MediaViewer; + type ParentType = ContextMenuBin; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for MediaViewer { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpec::new_boolean( + "fullscreened", + "Fullscreened", + "Whether the viewer is fullscreen", + false, + glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, + ), + glib::ParamSpec::new_object( + "event", + "Event", + "The media event to display", + Event::static_type(), + glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, + ), + glib::ParamSpec::new_string( + "body", + "Body", + "The body of the media event", + None, + glib::ParamFlags::READABLE, + ), + ] + }); + + PROPERTIES.as_ref() + } + + fn set_property( + &self, + obj: &Self::Type, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + match pspec.name() { + "fullscreened" => obj.set_fullscreened(value.get().unwrap()), + "event" => obj.set_event(value.get().unwrap()), + _ => unimplemented!(), + } + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "fullscreened" => obj.fullscreened().to_value(), + "event" => obj.event().to_value(), + "body" => obj.body().to_value(), + _ => unimplemented!(), + } + } + + fn constructed(&self, obj: &Self::Type) { + self.parent_constructed(obj); + + obj.set_context_menu(Some(Self::Type::event_menu_model())); + + // Bind `fullscreened` to the window property of the same name. + obj.connect_notify_local(Some("root"), |obj, _| { + if let Some(window) = obj.root().and_then(|root| root.downcast::().ok()) { + window + .bind_property("fullscreened", obj, "fullscreened") + .flags(glib::BindingFlags::SYNC_CREATE) + .build(); + } + }); + + // Toggle fullscreen on double click. + let click_gesture = gtk::GestureClick::builder().button(1).build(); + click_gesture.connect_pressed(clone!(@weak obj => move |_, n_pressed, _, _| { + if n_pressed == 2 { + obj.activate_action("win.toggle-fullscreen", None); + } + })); + obj.add_controller(&click_gesture); + + // Show headerbar when revealer is hovered. + let revealer: >k::Revealer = &*self.headerbar_revealer; + let menu: >k::MenuButton = &*self.menu_full; + let motion_controller = gtk::EventControllerMotion::new(); + motion_controller.connect_enter(clone!(@weak revealer => move |_, _, _| { + revealer.set_reveal_child(true); + })); + // Hide the headerbar when revealer is not hovered and header menu is closed. + motion_controller.connect_leave(clone!(@weak revealer, @weak menu => move |_| { + if menu.popover().filter(|popover| popover.is_visible()).is_none() { + revealer.set_reveal_child(false); + } + })); + menu.popover().unwrap().connect_closed( + clone!(@weak revealer, @weak motion_controller, => move |_| { + if !motion_controller.contains_pointer() { + revealer.set_reveal_child(false); + } + }), + ); + revealer.add_controller(&motion_controller); + } + } + + impl WidgetImpl for MediaViewer {} + impl BinImpl for MediaViewer {} + impl ContextMenuBinImpl for MediaViewer {} +} + +glib::wrapper! { + pub struct MediaViewer(ObjectSubclass) + @extends gtk::Widget, adw::Bin, ContextMenuBin, @implements gtk::Accessible; +} + +impl MediaViewer { + pub fn new() -> Self { + glib::Object::new(&[]).expect("Failed to create MediaViewer") + } + + pub fn event(&self) -> Option { + let priv_ = imp::MediaViewer::from_instance(self); + priv_ + .event + .borrow() + .as_ref() + .and_then(|event| event.upgrade()) + } + + pub fn set_event(&self, event: Option) { + let priv_ = imp::MediaViewer::from_instance(self); + + if event == self.event() { + return; + } + + priv_.event.replace(event.map(|event| event.downgrade())); + self.build(); + self.notify("event"); + } + + pub fn body(&self) -> Option { + let priv_ = imp::MediaViewer::from_instance(self); + priv_.body.borrow().clone() + } + + pub fn set_body(&self, body: Option) { + let priv_ = imp::MediaViewer::from_instance(self); + + if body == self.body() { + return; + } + + priv_.body.replace(body); + self.notify("body"); + } + + pub fn fullscreened(&self) -> bool { + let priv_ = imp::MediaViewer::from_instance(self); + priv_.fullscreened.get() + } + + pub fn set_fullscreened(&self, fullscreened: bool) { + let priv_ = imp::MediaViewer::from_instance(self); + + if fullscreened == self.fullscreened() { + return; + } + + priv_.fullscreened.set(fullscreened); + + // Upscale the media on fullscreen + if fullscreened { + priv_.media.set_halign(gtk::Align::Fill); + } else { + priv_.media.set_halign(gtk::Align::Center); + } + + self.notify("fullscreened"); + } + + fn build(&self) { + if let Some(event) = self.event() { + self.set_event_actions(Some(&event)); + if let Some(AnyMessageEventContent::RoomMessage(content)) = event.message_content() { + match content.msgtype { + MessageType::Image(image) => { + self.set_body(Some(image.body.clone())); + + spawn!( + glib::PRIORITY_LOW, + clone!(@weak self as obj => async move { + let priv_ = imp::MediaViewer::from_instance(&obj); + + match event.get_media_content().await { + Ok((_, 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()); + + priv_.media.set_child(Some(&child)); + } + Err(error) => { + warn!("Could not retrieve image file: {}", error); + let child = gtk::Label::new(Some(&gettext("Could not retrieve image"))); + priv_.media.set_child(Some(&child)); + } + } + }) + ); + } + _ => {} + } + } + } + } +} + +impl EventActions for MediaViewer {} diff --git a/src/session/mod.rs b/src/session/mod.rs index 0d35cc34..572cdb29 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -2,6 +2,7 @@ mod account_settings; mod avatar; mod content; mod event_source_dialog; +mod media_viewer; pub mod room; mod room_creation; mod room_list; @@ -12,7 +13,8 @@ pub mod verification; use self::account_settings::AccountSettings; pub use self::avatar::Avatar; use self::content::Content; -pub use self::room::Room; +use self::media_viewer::MediaViewer; +pub use self::room::{Event, Item, Room}; pub use self::room_creation::RoomCreation; use self::room_list::RoomList; use self::sidebar::Sidebar; @@ -75,6 +77,8 @@ mod imp { pub content: TemplateChild, #[template_child] pub sidebar: TemplateChild, + #[template_child] + pub media_viewer: TemplateChild, pub client: RefCell>, pub item_list: OnceCell, pub user: OnceCell, @@ -728,6 +732,14 @@ impl Session { self.emit_by_name("ready", &[]).unwrap(); } + + /// Show a media event + pub fn show_media(&self, event: &Event) { + let priv_ = imp::Session::from_instance(self); + priv_.media_viewer.set_event(Some(event.clone())); + + priv_.stack.set_visible_child(&*priv_.media_viewer); + } } impl Default for Session { diff --git a/src/session/room/event.rs b/src/session/room/event.rs index 57440472..9c63fb78 100644 --- a/src/session/room/event.rs +++ b/src/session/room/event.rs @@ -107,6 +107,13 @@ mod imp { None, glib::ParamFlags::READABLE, ), + glib::ParamSpec::new_boolean( + "can-view-media", + "Can View Media", + "Whether this is a media event that can be viewed", + false, + glib::ParamFlags::READABLE, + ), ] }); @@ -146,6 +153,7 @@ mod imp { "show-header" => obj.show_header().to_value(), "can-hide-header" => obj.can_hide_header().to_value(), "time" => obj.time().to_value(), + "can-view-media" => obj.can_view_media().to_value(), _ => unimplemented!(), } } @@ -200,6 +208,7 @@ impl Event { priv_.pure_event.replace(Some(event)); self.notify("event"); + self.notify("can-view-media"); } pub fn matrix_sender(&self) -> UserId { @@ -506,6 +515,7 @@ impl Event { /// Compatible events: /// /// - File message (`MessageType::File`). + /// - Image message (`MessageType::Image`). /// /// Returns `Ok((filename, binary_content))` on success, `Err` if an error occured while /// fetching the content. Panics on an incompatible event. @@ -513,11 +523,17 @@ impl Event { if let AnyMessageEventContent::RoomMessage(content) = self.message_content().unwrap() { let client = self.room().session().client(); match content.msgtype { - MessageType::File(file_content) => { - let content = file_content.clone(); + MessageType::File(content) => { + let filename = content.filename.clone().unwrap_or(content.body.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)); + return Ok((filename, data)); + } + MessageType::Image(content) => { + let filename = 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)); } _ => {} }; @@ -525,4 +541,14 @@ impl Event { panic!("Trying to get the media content of an event of incompatible type"); } + + /// Whether this is a media event that can be viewed. + pub fn can_view_media(&self) -> bool { + match self.message_content() { + Some(AnyMessageEventContent::RoomMessage(message)) => { + matches!(message.msgtype, MessageType::Image(_)) + } + _ => false, + } + } } diff --git a/src/session/room/item.rs b/src/session/room/item.rs index ad1709e1..ccb32b4e 100644 --- a/src/session/room/item.rs +++ b/src/session/room/item.rs @@ -27,12 +27,15 @@ impl From for BoxedItemType { } mod imp { + use std::cell::Cell; + use super::*; use once_cell::{sync::Lazy, unsync::OnceCell}; #[derive(Debug, Default)] pub struct Item { pub type_: OnceCell, + pub activatable: Cell, } #[glib::object_subclass] @@ -74,6 +77,13 @@ mod imp { false, glib::ParamFlags::READABLE, ), + glib::ParamSpec::new_boolean( + "activatable", + "Activatable", + "Whether this item is activatable.", + false, + glib::ParamFlags::READWRITE, + ), ] }); @@ -96,6 +106,7 @@ mod imp { let show_header = value.get().unwrap(); let _ = obj.set_show_header(show_header); } + "activatable" => self.activatable.set(value.get().unwrap()), _ => unimplemented!(), } } @@ -105,9 +116,19 @@ mod imp { "selectable" => obj.selectable().to_value(), "show-header" => obj.show_header().to_value(), "can-hide-header" => obj.can_hide_header().to_value(), + "activatable" => self.activatable.get().to_value(), _ => unimplemented!(), } } + + fn constructed(&self, obj: &Self::Type) { + if let Some(event) = obj.event() { + event + .bind_property("can-view-media", obj, "activatable") + .flags(glib::BindingFlags::SYNC_CREATE) + .build(); + } + } } } diff --git a/src/window.rs b/src/window.rs index 2e3ded05..7cea5193 100644 --- a/src/window.rs +++ b/src/window.rs @@ -74,6 +74,17 @@ mod imp { ); obj.set_default_by_child(); + + // Ask for the toggle fullscreen state + let fullscreen = gio::SimpleAction::new("toggle-fullscreen", None); + fullscreen.connect_activate(clone!(@weak obj as window => move |_, _| { + if window.is_fullscreened() { + window.unfullscreen(); + } else { + window.fullscreen(); + } + })); + obj.add_action(&fullscreen); } }