diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index 2aeb0ab5..a1de5846 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -85,6 +85,8 @@ ui/content-invite.ui ui/content-invitee-item.ui ui/content-invitee-row.ui + ui/content-media-history-viewer-item.ui + ui/content-media-history-viewer.ui ui/content-member-item.ui ui/content-member-page-list-view.ui ui/content-member-page-membership-subpage-row.ui diff --git a/data/resources/style.css b/data/resources/style.css index 3b2e050b..33c1c902 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -620,6 +620,45 @@ typing-bar avatar { border-radius: 6px; } +/* Media History Viewer */ + +mediahistoryviewer { + background: black; + color: white; +} + +mediahistoryviewer headerbar { + background: none; + box-shadow: none; +} + +mediahistoryviewer gridview { + background: none; + padding: 2px; +} + +mediahistoryviewer gridview > child { + background: none; + padding: 2px; + /* ease-out-quad */ + transition: 100ms cubic-bezier(0.25, 0.46, 0.45, 0.94); +} + +mediahistoryviewer gridview > child:hover { + transform: scale(1.03); +} + +mediahistoryviewer gridview > child:active { + transform: scale(0.98); +} + +mediahistoryvieweritem > overlay > image { + border-radius: 100%; + padding: 12px; + -gtk-icon-size: 24px; +} + + /* Room Details */ .room-details listview { diff --git a/data/resources/ui/content-media-history-viewer-item.ui b/data/resources/ui/content-media-history-viewer-item.ui new file mode 100644 index 00000000..482f3971 --- /dev/null +++ b/data/resources/ui/content-media-history-viewer-item.ui @@ -0,0 +1,14 @@ + + + + diff --git a/data/resources/ui/content-media-history-viewer.ui b/data/resources/ui/content-media-history-viewer.ui new file mode 100644 index 00000000..1ab66e74 --- /dev/null +++ b/data/resources/ui/content-media-history-viewer.ui @@ -0,0 +1,70 @@ + + + + diff --git a/data/resources/ui/content-room-details-general-page.ui b/data/resources/ui/content-room-details-general-page.ui index 0cafd7bb..625cf0ac 100644 --- a/data/resources/ui/content-room-details-general-page.ui +++ b/data/resources/ui/content-room-details-general-page.ui @@ -139,6 +139,25 @@ + + + + + Media + details.next-page + 'media-history' + True + + + center + center + go-next-symbolic + + + + + + diff --git a/po/POTFILES.in b/po/POTFILES.in index 8bb43cf8..1f1b68db 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -22,6 +22,7 @@ data/resources/ui/content-explore-servers-popover.ui data/resources/ui/content-explore.ui data/resources/ui/content-invite-subpage.ui data/resources/ui/content-invite.ui +data/resources/ui/content-media-history-viewer.ui data/resources/ui/content-member-page.ui data/resources/ui/content-message-file.ui data/resources/ui/content-message-row.ui diff --git a/src/session/content/room_details/history_viewer/media.rs b/src/session/content/room_details/history_viewer/media.rs new file mode 100644 index 00000000..b931d6d6 --- /dev/null +++ b/src/session/content/room_details/history_viewer/media.rs @@ -0,0 +1,108 @@ +use adw::{prelude::*, subclass::prelude::*}; +use gtk::{glib, glib::clone, CompositeTemplate}; + +use crate::{ + session::{ + content::room_details::history_viewer::{MediaItem, Timeline, TimelineFilter}, + Room, + }, + spawn, +}; + +const MIN_N_ITEMS: u32 = 50; + +mod imp { + use glib::subclass::InitializingObject; + use once_cell::{sync::Lazy, unsync::OnceCell}; + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/Fractal/content-media-history-viewer.ui")] + pub struct MediaHistoryViewer { + pub room_timeline: OnceCell, + #[template_child] + pub grid_view: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for MediaHistoryViewer { + const NAME: &'static str = "ContentMediaHistoryViewer"; + type Type = super::MediaHistoryViewer; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + MediaItem::static_type(); + Self::bind_template(klass); + + klass.set_css_name("mediahistoryviewer"); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for MediaHistoryViewer { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![glib::ParamSpecObject::builder::("room") + .construct_only() + .build()] + }); + + PROPERTIES.as_ref() + } + + fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + match pspec.name() { + "room" => self.obj().set_room(value.get().unwrap()), + _ => unimplemented!(), + } + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "room" => self.obj().room().to_value(), + _ => unimplemented!(), + } + } + } + + impl WidgetImpl for MediaHistoryViewer {} + impl BinImpl for MediaHistoryViewer {} +} + +glib::wrapper! { + pub struct MediaHistoryViewer(ObjectSubclass) + @extends gtk::Widget, adw::Bin; +} + +impl MediaHistoryViewer { + pub fn new(room: &Room) -> Self { + glib::Object::builder().property("room", room).build() + } + + fn set_room(&self, room: &Room) { + let imp = self.imp(); + + let timeline = Timeline::new(room, TimelineFilter::Media); + let model = gtk::NoSelection::new(Some(timeline.clone())); + imp.grid_view.set_model(Some(&model)); + + // Load an initial number of items + spawn!(clone!(@weak timeline => async move { + while timeline.n_items() < MIN_N_ITEMS { + if !timeline.load().await { + break; + } + } + })); + + imp.room_timeline.set(timeline).unwrap(); + } + + pub fn room(&self) -> &Room { + self.imp().room_timeline.get().unwrap().room() + } +} diff --git a/src/session/content/room_details/history_viewer/media_item.rs b/src/session/content/room_details/history_viewer/media_item.rs new file mode 100644 index 00000000..3917b5c9 --- /dev/null +++ b/src/session/content/room_details/history_viewer/media_item.rs @@ -0,0 +1,227 @@ +use gtk::{gdk, glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate}; +use log::warn; +use matrix_sdk::{ + media::{MediaEventContent, MediaThumbnailSize}, + ruma::{ + api::client::media::get_content_thumbnail::v3::Method, + events::{ + room::message::{ImageMessageEventContent, MessageType, VideoMessageEventContent}, + AnyMessageLikeEventContent, + }, + uint, + }, +}; + +use crate::{ + session::content::room_details::history_viewer::HistoryViewerEvent, spawn, spawn_tokio, Session, +}; + +mod imp { + use std::cell::RefCell; + + use glib::subclass::InitializingObject; + use once_cell::sync::Lazy; + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/Fractal/content-media-history-viewer-item.ui")] + pub struct MediaItem { + pub event: RefCell>, + pub overlay_icon: RefCell>, + #[template_child] + pub overlay: TemplateChild, + #[template_child] + pub picture: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for MediaItem { + const NAME: &'static str = "ContentMediaHistoryViewerItem"; + type Type = super::MediaItem; + type ParentType = gtk::Widget; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + + klass.set_css_name("mediahistoryvieweritem"); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for MediaItem { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpecObject::builder::("event") + .explicit_notify() + .build(), + ] + }); + + PROPERTIES.as_ref() + } + + fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { + match pspec.name() { + "event" => self.obj().set_event(value.get().unwrap()), + _ => unimplemented!(), + } + } + + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "event" => self.obj().event().to_value(), + _ => unimplemented!(), + } + } + + fn dispose(&self) { + self.overlay.unparent(); + } + } + + impl WidgetImpl for MediaItem { + fn measure(&self, orientation: gtk::Orientation, for_size: i32) -> (i32, i32, i32, i32) { + // Keep the widget squared + let (min, ..) = self.overlay.measure(orientation, for_size); + (min, for_size.max(min), -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); + } + } +} + +glib::wrapper! { + pub struct MediaItem(ObjectSubclass) + @extends gtk::Widget; +} + +impl MediaItem { + pub fn set_event(&self, event: Option) { + if self.event() == event { + return; + } + + if let Some(ref event) = event { + match event.original_content() { + Some(AnyMessageLikeEventContent::RoomMessage(message)) => match message.msgtype { + MessageType::Image(content) => { + self.show_image(content, &event.room().unwrap().session()); + } + MessageType::Video(content) => { + self.show_video(content, &event.room().unwrap().session()); + } + _ => { + panic!("Unexpected message type"); + } + }, + _ => { + panic!("Unexpected message type"); + } + } + } + + self.imp().event.replace(event); + self.notify("event"); + } + + pub fn event(&self) -> Option { + self.imp().event.borrow().clone() + } + + fn show_image(&self, image: ImageMessageEventContent, session: &Session) { + let imp = self.imp(); + + if let Some(icon) = imp.overlay_icon.take() { + imp.overlay.remove_overlay(&icon); + } + + self.load_thumbnail(image, session); + } + + fn show_video(&self, video: VideoMessageEventContent, session: &Session) { + let imp = self.imp(); + + if imp.overlay_icon.borrow().is_none() { + let icon = gtk::Image::builder() + .icon_name("media-playback-start-symbolic") + .css_classes(vec!["osd".to_string()]) + .halign(gtk::Align::Center) + .valign(gtk::Align::Center) + .build(); + + imp.overlay.add_overlay(&icon); + imp.overlay_icon.replace(Some(icon)); + } + + self.load_thumbnail(video, session); + } + + fn load_thumbnail(&self, content: C, session: &Session) + where + C: MediaEventContent + Send + Sync + Clone + 'static, + { + let media = session.client().media(); + let handle = spawn_tokio!(async move { + let thumbnail = if content.thumbnail_source().is_some() { + media + .get_thumbnail( + content.clone(), + MediaThumbnailSize { + method: Method::Scale, + width: uint!(300), + height: uint!(300), + }, + true, + ) + .await + .ok() + .flatten() + } else { + None + }; + + if let Some(data) = thumbnail { + Ok(Some(data)) + } else { + media.get_file(content, true).await + } + }); + + spawn!( + glib::PRIORITY_LOW, + clone!(@weak self as obj => async move { + let imp = obj.imp(); + + match handle.await.unwrap() { + Ok(Some(data)) => { + match gdk::Texture::from_bytes(&glib::Bytes::from(&data)) { + Ok(texture) => { + imp.picture.set_paintable(Some(&texture)); + } + Err(error) => { + warn!("Image file not supported: {}", error); + } + } + } + Ok(None) => { + warn!("Could not retrieve invalid media file"); + } + Err(error) => { + warn!("Could not retrieve media file: {}", error); + } + } + }) + ); + } +} diff --git a/src/session/content/room_details/history_viewer/mod.rs b/src/session/content/room_details/history_viewer/mod.rs index e9e78535..3e5ac2ad 100644 --- a/src/session/content/room_details/history_viewer/mod.rs +++ b/src/session/content/room_details/history_viewer/mod.rs @@ -1,4 +1,11 @@ mod event; +mod media; +mod media_item; mod timeline; -use self::event::HistoryViewerEvent; +pub use self::media::MediaHistoryViewer; +use self::{ + event::HistoryViewerEvent, + media_item::MediaItem, + timeline::{Timeline, TimelineFilter}, +}; diff --git a/src/session/content/room_details/mod.rs b/src/session/content/room_details/mod.rs index c428e9f8..002d6da3 100644 --- a/src/session/content/room_details/mod.rs +++ b/src/session/content/room_details/mod.rs @@ -11,7 +11,11 @@ use gtk::{glib, CompositeTemplate}; use log::warn; pub use self::{general_page::GeneralPage, invite_subpage::InviteSubpage, member_page::MemberPage}; -use crate::{components::ToastableWindow, prelude::*, session::Room}; +use crate::{ + components::ToastableWindow, + prelude::*, + session::{content::room_details::history_viewer::MediaHistoryViewer, Room}, +}; #[derive(Debug, Default, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)] #[repr(u32)] @@ -22,6 +26,7 @@ pub enum PageName { General, Members, Invite, + MediaHistory, } impl glib::variant::StaticVariantType for PageName { @@ -36,6 +41,7 @@ impl glib::variant::FromVariant for PageName { "general" => Some(PageName::General), "members" => Some(PageName::Members), "invite" => Some(PageName::Invite), + "media-history" => Some(PageName::MediaHistory), "" => Some(PageName::None), _ => None, } @@ -49,6 +55,7 @@ impl glib::variant::ToVariant for PageName { PageName::General => "general", PageName::Members => "members", PageName::Invite => "invite", + PageName::MediaHistory => "media-history", } .to_variant() } @@ -234,6 +241,22 @@ impl RoomDetails { self.set_title(Some(&gettext("Invite new Members"))); imp.main_stack.set_visible_child(&invite_page); } + PageName::MediaHistory => { + let media_page = if let Some(media_page) = list_stack_children + .get(&PageName::MediaHistory) + .and_then(glib::object::WeakRef::upgrade) + { + media_page + } else { + let media_page = MediaHistoryViewer::new(self.room()).upcast::(); + list_stack_children.insert(PageName::MediaHistory, media_page.downgrade()); + imp.main_stack.add_child(&media_page); + media_page + }; + + self.set_title(Some(&gettext("Media"))); + imp.main_stack.set_visible_child(&media_page); + } PageName::None => { warn!("Can't switch to PageName::None"); }