From 9f796716f3fac1612a7dffc84117795b7e1a8daf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Tue, 9 Jul 2024 16:47:05 +0200 Subject: [PATCH] room-details: Support more states for the media history viewers Show when the list is loading more items, when there is an error, and show a different state when the list is empty. --- data/resources/style.css | 10 + src/components/rows/loading_row.rs | 7 + .../room_details/history_viewer/audio.rs | 168 ++++++++++-- .../room_details/history_viewer/audio.ui | 114 +++++--- .../room_details/history_viewer/audio_row.rs | 151 ++++++----- .../room_details/history_viewer/audio_row.ui | 2 +- .../room_details/history_viewer/file.rs | 168 ++++++++++-- .../room_details/history_viewer/file.ui | 114 +++++--- .../room_details/history_viewer/file_row.rs | 73 +++-- .../room_details/history_viewer/file_row.ui | 2 +- .../room_details/history_viewer/media.rs | 177 +++++++++--- .../room_details/history_viewer/media.ui | 112 +++++--- .../room_details/history_viewer/media_item.rs | 251 +++++++++--------- .../room_details/history_viewer/timeline.rs | 119 ++++++--- src/session/view/content/room_history/mod.ui | 2 +- 15 files changed, 1028 insertions(+), 442 deletions(-) diff --git a/data/resources/style.css b/data/resources/style.css index bc5754eb..20b5fca4 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -942,6 +942,11 @@ media-history-viewer-item > overlay > image { file-history-viewer listview > row { border-radius: 0; + padding: 6px; +} + +file-history-viewer listview > row:last-child { + border-bottom-width: 0; } @@ -949,6 +954,11 @@ file-history-viewer listview > row { audio-history-viewer listview > row { border-radius: 0; + padding: 6px; +} + +audio-history-viewer listview > row:last-child { + border-bottom-width: 0; } diff --git a/src/components/rows/loading_row.rs b/src/components/rows/loading_row.rs index fe4a408e..3c72da1b 100644 --- a/src/components/rows/loading_row.rs +++ b/src/components/rows/loading_row.rs @@ -113,6 +113,7 @@ impl LoadingRow { glib::Object::new() } + /// Connect to the signal emitted when the retry button is clicked. pub fn connect_retry(&self, f: F) -> glib::SignalHandlerId { self.connect_closure( "retry", @@ -123,3 +124,9 @@ impl LoadingRow { ) } } + +impl Default for LoadingRow { + fn default() -> Self { + Self::new() + } +} diff --git a/src/session/view/content/room_details/history_viewer/audio.rs b/src/session/view/content/room_details/history_viewer/audio.rs index e5f02d05..0d1a203a 100644 --- a/src/session/view/content/room_details/history_viewer/audio.rs +++ b/src/session/view/content/room_details/history_viewer/audio.rs @@ -1,14 +1,16 @@ use adw::{prelude::*, subclass::prelude::*}; use gtk::{glib, glib::clone, CompositeTemplate}; +use tracing::error; use super::{AudioRow, HistoryViewerEvent, HistoryViewerEventType, HistoryViewerTimeline}; -use crate::spawn; +use crate::{ + components::LoadingRow, session::model::TimelineState, spawn, utils::BoundConstructOnlyObject, +}; +/// The minimum number of items that should be loaded. const MIN_N_ITEMS: u32 = 20; mod imp { - use std::cell::OnceCell; - use glib::subclass::InitializingObject; use super::*; @@ -21,7 +23,9 @@ mod imp { pub struct AudioHistoryViewer { /// The timeline containing the audio events. #[property(get, set = Self::set_timeline, construct_only)] - pub timeline: OnceCell, + pub timeline: BoundConstructOnlyObject, + #[template_child] + pub stack: TemplateChild, #[template_child] pub list_view: TemplateChild, } @@ -33,9 +37,8 @@ mod imp { type ParentType = adw::NavigationPage; fn class_init(klass: &mut Self::Class) { - AudioRow::ensure_type(); - Self::bind_template(klass); + Self::Type::bind_template_callbacks(klass); klass.set_css_name("audio-history-viewer"); } @@ -46,7 +49,56 @@ mod imp { } #[glib::derived_properties] - impl ObjectImpl for AudioHistoryViewer {} + impl ObjectImpl for AudioHistoryViewer { + fn constructed(&self) { + self.parent_constructed(); + + let factory = gtk::SignalListItemFactory::new(); + + factory.connect_bind(move |_, list_item| { + let Some(list_item) = list_item.downcast_ref::() else { + error!("List item factory did not receive a list item: {list_item:?}"); + return; + }; + + list_item.set_activatable(false); + list_item.set_selectable(false); + }); + factory.connect_bind(move |_, list_item| { + let Some(list_item) = list_item.downcast_ref::() else { + error!("List item factory did not receive a list item: {list_item:?}"); + return; + }; + + let item = list_item.item(); + + if let Some(loading_row) = item + .and_downcast_ref::() + .filter(|_| !list_item.child().is_some_and(|c| c.is::())) + { + loading_row.unparent(); + loading_row.set_width_request(-1); + loading_row.set_height_request(-1); + + list_item.set_child(Some(loading_row)); + } else if let Some(event) = item.and_downcast::() { + let audio_row = + if let Some(audio_row) = list_item.child().and_downcast::() { + audio_row + } else { + let audio_row = AudioRow::new(); + list_item.set_child(Some(&audio_row)); + + audio_row + }; + + audio_row.set_event(Some(event)); + } + }); + + self.list_view.set_factory(Some(&factory)); + } + } impl WidgetImpl for AudioHistoryViewer {} impl NavigationPageImpl for AudioHistoryViewer {} @@ -57,41 +109,91 @@ mod imp { let filter = gtk::CustomFilter::new(|obj| { obj.downcast_ref::() .is_some_and(|e| e.event_type() == HistoryViewerEventType::Audio) + || obj.is::() }); - let filter_model = gtk::FilterListModel::new(Some(timeline.clone()), Some(filter)); + let filter_model = + gtk::FilterListModel::new(Some(timeline.with_loading_item().clone()), Some(filter)); let model = gtk::NoSelection::new(Some(filter_model)); + model.connect_items_changed(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, _, _| { + imp.update_state(); + } + )); self.list_view.set_model(Some(&model)); - // Load an initial number of items + let timeline_state_handler = timeline.connect_state_notify(clone!( + #[weak(rename_to = imp)] + self, + move |_| { + imp.update_state(); + } + )); + + self.timeline.set(timeline, vec![timeline_state_handler]); + self.update_state(); + spawn!(clone!( #[weak(rename_to = imp)] self, - #[weak] - timeline, async move { - while model.n_items() < MIN_N_ITEMS { - if !timeline.load().await { - break; - } - } + imp.init_timeline().await; + } + )); + } - let adj = imp.list_view.vadjustment().unwrap(); - adj.connect_value_notify(clone!( - #[weak] - timeline, - move |adj| { - if adj.value() + adj.page_size() * 2.0 >= adj.upper() { - spawn!(async move { - timeline.load().await; - }); - } - } - )); + /// Initialize the timeline. + async fn init_timeline(&self) { + let Some(model) = self.list_view.model() else { + return; + }; + let timeline = self.timeline.obj(); + let obj = self.obj(); + + // Load an initial number of items. + while model.n_items() < MIN_N_ITEMS { + if !timeline.load().await { + break; + } + } + + let adj = self.list_view.vadjustment().unwrap(); + adj.connect_value_notify(clone!( + #[weak] + obj, + move |adj| { + if adj.value() + adj.page_size() * 2.0 >= adj.upper() { + spawn!(async move { + obj.load_more().await; + }); + } } )); + } + + /// Update this viewer for the current state. + fn update_state(&self) { + let Some(model) = self.list_view.model() else { + return; + }; + let timeline = self.timeline.obj(); - self.timeline.set(timeline).unwrap(); + match timeline.state() { + TimelineState::Initial | TimelineState::Loading if model.n_items() == 0 => { + self.stack.set_visible_child_name("loading"); + } + TimelineState::Error => { + self.stack.set_visible_child_name("error"); + } + TimelineState::Complete if model.n_items() == 0 => { + self.stack.set_visible_child_name("empty"); + } + _ => { + self.stack.set_visible_child_name("content"); + } + } } } } @@ -102,10 +204,18 @@ glib::wrapper! { @extends gtk::Widget, adw::NavigationPage; } +#[gtk::template_callbacks] impl AudioHistoryViewer { pub fn new(timeline: &HistoryViewerTimeline) -> Self { glib::Object::builder() .property("timeline", timeline) .build() } + + /// Load more history. + #[template_callback] + async fn load_more(&self) { + let timeline = self.imp().timeline.obj(); + timeline.load().await; + } } diff --git a/src/session/view/content/room_details/history_viewer/audio.ui b/src/session/view/content/room_details/history_viewer/audio.ui index fc3bade0..e728113c 100644 --- a/src/session/view/content/room_details/history_viewer/audio.ui +++ b/src/session/view/content/room_details/history_viewer/audio.ui @@ -9,43 +9,91 @@ - - never - True + + crossfade - - 400 - 400 - - - True - - - - - - - ]]> - - + + loading + Loading + + + center + center + True - + + + + + + empty + No Audio + + + True + True + True + audio-symbolic + No Audio + This room does not contain any audio + + + + + + + error + Could Not Load Audio + + + True + True + True + error-symbolic + Could Not Load Audio + Check your network connection + + + true + Try Again + center + + + + + + + + + + + content + Audio History + + + never + True + + + 400 + 400 + + + True + + + + + + + diff --git a/src/session/view/content/room_details/history_viewer/audio_row.rs b/src/session/view/content/room_details/history_viewer/audio_row.rs index 38ba83a8..fb330114 100644 --- a/src/session/view/content/room_details/history_viewer/audio_row.rs +++ b/src/session/view/content/room_details/history_viewer/audio_row.rs @@ -2,11 +2,11 @@ use adw::{prelude::*, subclass::prelude::*}; use gettextrs::gettext; use glib::clone; use gtk::{gio, glib, CompositeTemplate}; -use matrix_sdk::ruma::events::room::message::{AudioMessageEventContent, MessageType}; +use ruma::events::room::message::MessageType; use tracing::warn; use super::HistoryViewerEvent; -use crate::{gettext_f, matrix_filename, session::model::Session, spawn, spawn_tokio}; +use crate::{gettext_f, matrix_filename, spawn, spawn_tokio}; mod imp { use std::cell::RefCell; @@ -22,7 +22,7 @@ mod imp { #[properties(wrapper_type = super::AudioRow)] pub struct AudioRow { /// The audio event. - #[property(get, set = Self::set_event, explicit_notify)] + #[property(get, set = Self::set_event, explicit_notify, nullable)] pub event: RefCell>, pub media_file: RefCell>, #[template_child] @@ -41,10 +41,7 @@ mod imp { fn class_init(klass: &mut Self::Class) { Self::bind_template(klass); - - klass.install_action("audio-row.toggle-play", None, |obj, _, _| { - obj.toggle_play(); - }); + Self::Type::bind_template_callbacks(klass); } fn instance_init(obj: &InitializingObject) { @@ -64,7 +61,6 @@ mod imp { if *self.event.borrow() == event { return; } - let obj = self.obj(); if let Some(event) = &event { if let MessageType::Audio(audio) = event.message_content() { @@ -96,21 +92,82 @@ mod imp { } else { self.duration_label.set_label(&gettext("Unknown duration")); } - - if let Some(session) = event.room().and_then(|r| r.session()) { - spawn!(clone!( - #[weak] - obj, - async move { - obj.download_audio(audio, &session).await; - } - )); - } } } self.event.replace(event); - obj.notify_event(); + + spawn!(clone!( + #[weak(rename_to = imp)] + self, + async move { + imp.download_audio().await; + } + )); + + self.obj().notify_event(); + } + + /// Download the given audio. + async fn download_audio(&self) { + let Some(event) = self.event.borrow().clone() else { + return; + }; + let MessageType::Audio(audio) = event.message_content() else { + return; + }; + let Some(session) = event.room().and_then(|r| r.session()) else { + return; + }; + let client = session.client(); + let handle = spawn_tokio!(async move { client.media().get_file(&audio, true).await }); + + match handle.await.unwrap() { + Ok(Some(data)) => { + // The GStreamer backend doesn't work with input streams so + // we need to store the file. + // See: https://gitlab.gnome.org/GNOME/gtk/-/issues/4062 + let (file, _) = gio::File::new_tmp(None::).unwrap(); + file.replace_contents( + &data, + None, + false, + gio::FileCreateFlags::REPLACE_DESTINATION, + gio::Cancellable::NONE, + ) + .unwrap(); + self.set_media_file(file); + } + Ok(None) => { + warn!("Could not retrieve invalid audio file"); + } + Err(error) => { + warn!("Could not retrieve audio file: {error}"); + } + } + } + + /// Set the media file to play. + fn set_media_file(&self, file: gio::File) { + let media_file = gtk::MediaFile::for_file(&file); + + media_file.connect_error_notify(|media_file| { + if let Some(error) = media_file.error() { + warn!("Error reading audio file: {}", error); + } + }); + media_file.connect_ended_notify(clone!( + #[weak(rename_to = imp)] + self, + move |media_file| { + if media_file.is_ended() { + imp.play_button + .set_icon_name("media-playback-start-symbolic"); + } + } + )); + + self.media_file.replace(Some(media_file)); } } } @@ -121,59 +178,15 @@ glib::wrapper! { @extends gtk::Widget, adw::Bin; } +#[gtk::template_callbacks] impl AudioRow { - async fn download_audio(&self, audio: AudioMessageEventContent, session: &Session) { - let client = session.client(); - let handle = spawn_tokio!(async move { client.media().get_file(&audio, true).await }); - - match handle.await.unwrap() { - Ok(Some(data)) => { - // The GStreamer backend doesn't work with input streams so - // we need to store the file. - // See: https://gitlab.gnome.org/GNOME/gtk/-/issues/4062 - let (file, _) = gio::File::new_tmp(None::).unwrap(); - file.replace_contents( - &data, - None, - false, - gio::FileCreateFlags::REPLACE_DESTINATION, - gio::Cancellable::NONE, - ) - .unwrap(); - self.prepare_audio(file); - } - Ok(None) => { - warn!("Could not retrieve invalid audio file"); - } - Err(error) => { - warn!("Could not retrieve audio file: {error}"); - } - } - } - - fn prepare_audio(&self, file: gio::File) { - let media_file = gtk::MediaFile::for_file(&file); - - media_file.connect_error_notify(|media_file| { - if let Some(error) = media_file.error() { - warn!("Error reading audio file: {}", error); - } - }); - media_file.connect_ended_notify(clone!( - #[weak(rename_to = obj)] - self, - move |media_file| { - if media_file.is_ended() { - obj.imp() - .play_button - .set_icon_name("media-playback-start-symbolic"); - } - } - )); - - self.imp().media_file.replace(Some(media_file)); + /// Construct an empty `AudioRow`. + pub fn new() -> Self { + glib::Object::new() } + /// Toggle the audio player playing state. + #[template_callback] fn toggle_play(&self) { let imp = self.imp(); diff --git a/src/session/view/content/room_details/history_viewer/audio_row.ui b/src/session/view/content/room_details/history_viewer/audio_row.ui index 8a8c966f..803ef1a7 100644 --- a/src/session/view/content/room_details/history_viewer/audio_row.ui +++ b/src/session/view/content/room_details/history_viewer/audio_row.ui @@ -6,7 +6,7 @@ 12 - audio-row.toggle-play + media-playback-start-symbolic center diff --git a/src/session/view/content/room_details/history_viewer/file.rs b/src/session/view/content/room_details/history_viewer/file.rs index 11d1d7aa..b10abe4d 100644 --- a/src/session/view/content/room_details/history_viewer/file.rs +++ b/src/session/view/content/room_details/history_viewer/file.rs @@ -1,14 +1,16 @@ use adw::{prelude::*, subclass::prelude::*}; use gtk::{glib, glib::clone, CompositeTemplate}; +use tracing::error; use super::{FileRow, HistoryViewerEvent, HistoryViewerEventType, HistoryViewerTimeline}; -use crate::spawn; +use crate::{ + components::LoadingRow, session::model::TimelineState, spawn, utils::BoundConstructOnlyObject, +}; +/// The minimum number of items that should be loaded. const MIN_N_ITEMS: u32 = 20; mod imp { - use std::cell::OnceCell; - use glib::subclass::InitializingObject; use super::*; @@ -21,7 +23,9 @@ mod imp { pub struct FileHistoryViewer { /// The timeline containing the file events. #[property(get, set = Self::set_timeline, construct_only)] - pub timeline: OnceCell, + pub timeline: BoundConstructOnlyObject, + #[template_child] + pub stack: TemplateChild, #[template_child] pub list_view: TemplateChild, } @@ -33,9 +37,8 @@ mod imp { type ParentType = adw::NavigationPage; fn class_init(klass: &mut Self::Class) { - FileRow::ensure_type(); - Self::bind_template(klass); + Self::Type::bind_template_callbacks(klass); klass.set_css_name("file-history-viewer"); } @@ -46,7 +49,56 @@ mod imp { } #[glib::derived_properties] - impl ObjectImpl for FileHistoryViewer {} + impl ObjectImpl for FileHistoryViewer { + fn constructed(&self) { + self.parent_constructed(); + + let factory = gtk::SignalListItemFactory::new(); + + factory.connect_bind(move |_, list_item| { + let Some(list_item) = list_item.downcast_ref::() else { + error!("List item factory did not receive a list item: {list_item:?}"); + return; + }; + + list_item.set_activatable(false); + list_item.set_selectable(false); + }); + factory.connect_bind(move |_, list_item| { + let Some(list_item) = list_item.downcast_ref::() else { + error!("List item factory did not receive a list item: {list_item:?}"); + return; + }; + + let item = list_item.item(); + + if let Some(loading_row) = item + .and_downcast_ref::() + .filter(|_| !list_item.child().is_some_and(|c| c.is::())) + { + loading_row.unparent(); + loading_row.set_width_request(-1); + loading_row.set_height_request(-1); + + list_item.set_child(Some(loading_row)); + } else if let Some(event) = item.and_downcast::() { + let file_row = + if let Some(file_row) = list_item.child().and_downcast::() { + file_row + } else { + let file_row = FileRow::new(); + list_item.set_child(Some(&file_row)); + + file_row + }; + + file_row.set_event(Some(event)); + } + }); + + self.list_view.set_factory(Some(&factory)); + } + } impl WidgetImpl for FileHistoryViewer {} impl NavigationPageImpl for FileHistoryViewer {} @@ -57,41 +109,91 @@ mod imp { let filter = gtk::CustomFilter::new(|obj| { obj.downcast_ref::() .is_some_and(|e| e.event_type() == HistoryViewerEventType::File) + || obj.is::() }); - let filter_model = gtk::FilterListModel::new(Some(timeline.clone()), Some(filter)); + let filter_model = + gtk::FilterListModel::new(Some(timeline.with_loading_item().clone()), Some(filter)); let model = gtk::NoSelection::new(Some(filter_model)); + model.connect_items_changed(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, _, _| { + imp.update_state(); + } + )); self.list_view.set_model(Some(&model)); - // Load an initial number of items + let timeline_state_handler = timeline.connect_state_notify(clone!( + #[weak(rename_to = imp)] + self, + move |_| { + imp.update_state(); + } + )); + + self.timeline.set(timeline, vec![timeline_state_handler]); + self.update_state(); + spawn!(clone!( #[weak(rename_to = imp)] self, - #[weak] - timeline, async move { - while model.n_items() < MIN_N_ITEMS { - if !timeline.load().await { - break; - } - } + imp.init_timeline().await; + } + )); + } - let adj = imp.list_view.vadjustment().unwrap(); - adj.connect_value_notify(clone!( - #[weak] - timeline, - move |adj| { - if adj.value() + adj.page_size() * 2.0 >= adj.upper() { - spawn!(async move { - timeline.load().await; - }); - } - } - )); + /// Initialize the timeline. + async fn init_timeline(&self) { + let Some(model) = self.list_view.model() else { + return; + }; + let timeline = self.timeline.obj(); + let obj = self.obj(); + + // Load an initial number of items. + while model.n_items() < MIN_N_ITEMS { + if !timeline.load().await { + break; + } + } + + let adj = self.list_view.vadjustment().unwrap(); + adj.connect_value_notify(clone!( + #[weak] + obj, + move |adj| { + if adj.value() + adj.page_size() * 2.0 >= adj.upper() { + spawn!(async move { + obj.load_more().await; + }); + } } )); + } + + /// Update this viewer for the current state. + fn update_state(&self) { + let Some(model) = self.list_view.model() else { + return; + }; + let timeline = self.timeline.obj(); - self.timeline.set(timeline).unwrap(); + match timeline.state() { + TimelineState::Initial | TimelineState::Loading if model.n_items() == 0 => { + self.stack.set_visible_child_name("loading"); + } + TimelineState::Error => { + self.stack.set_visible_child_name("error"); + } + TimelineState::Complete if model.n_items() == 0 => { + self.stack.set_visible_child_name("empty"); + } + _ => { + self.stack.set_visible_child_name("content"); + } + } } } } @@ -102,10 +204,18 @@ glib::wrapper! { @extends gtk::Widget, adw::NavigationPage; } +#[gtk::template_callbacks] impl FileHistoryViewer { pub fn new(timeline: &HistoryViewerTimeline) -> Self { glib::Object::builder() .property("timeline", timeline) .build() } + + /// Load more history. + #[template_callback] + async fn load_more(&self) { + let timeline = self.imp().timeline.obj(); + timeline.load().await; + } } diff --git a/src/session/view/content/room_details/history_viewer/file.ui b/src/session/view/content/room_details/history_viewer/file.ui index fade3b8a..0e2cb5a0 100644 --- a/src/session/view/content/room_details/history_viewer/file.ui +++ b/src/session/view/content/room_details/history_viewer/file.ui @@ -8,43 +8,91 @@ - - never - True + + crossfade - - 400 - 400 - - - True - - - - - - - ]]> - - + + loading + Loading + + + center + center + True - + + + + + + empty + No Files + + + True + True + True + document-symbolic + No Files + This room does not contain any files + + + + + + + error + Could Not Load Files + + + True + True + True + error-symbolic + Could Not Load Files + Check your network connection + + + true + Try Again + center + + + + + + + + + + + content + File History + + + never + True + + + 400 + 400 + + + True + + + + + + + diff --git a/src/session/view/content/room_details/history_viewer/file_row.rs b/src/session/view/content/room_details/history_viewer/file_row.rs index d389e56e..670c4632 100644 --- a/src/session/view/content/room_details/history_viewer/file_row.rs +++ b/src/session/view/content/room_details/history_viewer/file_row.rs @@ -1,7 +1,7 @@ use adw::{prelude::*, subclass::prelude::*}; use gettextrs::gettext; use gtk::{gio, glib, CompositeTemplate}; -use matrix_sdk::ruma::events::room::message::MessageType; +use ruma::events::room::message::MessageType; use tracing::error; use super::HistoryViewerEvent; @@ -40,13 +40,7 @@ mod imp { fn class_init(klass: &mut Self::Class) { Self::bind_template(klass); - - klass.install_action_async("file-row.save-file", None, |obj, _, _| async move { - obj.save_file().await; - }); - klass.install_action("file-row.open-file", None, |obj, _, _| { - obj.open_file(); - }); + Self::Type::bind_template_callbacks(klass); } fn instance_init(obj: &InitializingObject) { @@ -90,8 +84,22 @@ mod imp { } self.event.replace(event); + self.file.take(); + self.update_button(); + self.obj().notify_event(); } + + /// Update the button for the current state. + pub(super) fn update_button(&self) { + if self.file.borrow().is_some() { + self.button.set_icon_name("document-symbolic"); + self.button.set_tooltip_text(Some(&gettext("Open File"))); + } else { + self.button.set_icon_name("save-symbolic"); + self.button.set_tooltip_text(Some(&gettext("Save File"))); + } + } } } @@ -101,13 +109,41 @@ glib::wrapper! { @extends gtk::Widget, adw::Bin; } +#[gtk::template_callbacks] impl FileRow { + /// Construct an empty `FileRow`. + pub fn new() -> Self { + glib::Object::new() + } + + /// Handle when the row's button was clicked. + #[template_callback] + async fn button_clicked(&self) { + let file = self.imp().file.borrow().clone(); + + // If there is a file, open it. + if let Some(file) = file { + if let Err(error) = + gio::AppInfo::launch_default_for_uri(&file.uri(), gio::AppLaunchContext::NONE) + { + error!("Could not open file: {error}"); + } + } else { + // Otherwise save the file. + self.save_file().await + } + } + + /// Save the file of this row. async fn save_file(&self) { - let (filename, data) = match self.event().unwrap().get_file_content().await { + let Some(event) = self.event() else { + return; + }; + let (filename, data) = match event.get_file_content().await { Ok(res) => res, - Err(err) => { - error!("Could not get file: {}", err); - toast!(self, err.to_user_facing()); + Err(error) => { + error!("Could not get file: {error}"); + toast!(self, error.to_user_facing()); return; } @@ -133,18 +169,7 @@ impl FileRow { let imp = self.imp(); imp.file.replace(Some(file)); - imp.button.set_icon_name("document-symbolic"); - imp.button.set_action_name(Some("file-row.open-file")); - } - } - - fn open_file(&self) { - if let Some(file) = self.imp().file.borrow().as_ref() { - if let Err(e) = - gio::AppInfo::launch_default_for_uri(&file.uri(), gio::AppLaunchContext::NONE) - { - error!("Error: {e}"); - } + imp.update_button() } } } diff --git a/src/session/view/content/room_details/history_viewer/file_row.ui b/src/session/view/content/room_details/history_viewer/file_row.ui index 2ca24a13..dd1960f6 100644 --- a/src/session/view/content/room_details/history_viewer/file_row.ui +++ b/src/session/view/content/room_details/history_viewer/file_row.ui @@ -6,7 +6,7 @@ 12 - file-row.save-file + save-symbolic center Save File diff --git a/src/session/view/content/room_details/history_viewer/media.rs b/src/session/view/content/room_details/history_viewer/media.rs index e90116c8..2ee551de 100644 --- a/src/session/view/content/room_details/history_viewer/media.rs +++ b/src/session/view/content/room_details/history_viewer/media.rs @@ -1,14 +1,21 @@ use adw::{prelude::*, subclass::prelude::*}; use gtk::{glib, glib::clone, CompositeTemplate}; +use tracing::error; use super::{HistoryViewerEvent, HistoryViewerEventType, HistoryViewerTimeline, MediaItem}; -use crate::{session::view::MediaViewer, spawn}; - +use crate::{ + components::LoadingRow, + session::{model::TimelineState, view::MediaViewer}, + spawn, + utils::BoundConstructOnlyObject, +}; + +/// The minimum number of items that should be loaded. const MIN_N_ITEMS: u32 = 50; +/// The minimum size requested by an item. +const SIZE_REQUEST: i32 = 150; mod imp { - use std::cell::OnceCell; - use glib::subclass::InitializingObject; use super::*; @@ -21,10 +28,12 @@ mod imp { pub struct MediaHistoryViewer { /// The timeline containing the media events. #[property(get, set = Self::set_timeline, construct_only)] - pub timeline: OnceCell, + pub timeline: BoundConstructOnlyObject, #[template_child] pub media_viewer: TemplateChild, #[template_child] + pub stack: TemplateChild, + #[template_child] pub grid_view: TemplateChild, } @@ -35,9 +44,8 @@ mod imp { type ParentType = adw::NavigationPage; fn class_init(klass: &mut Self::Class) { - MediaItem::ensure_type(); - Self::bind_template(klass); + Self::Type::bind_template_callbacks(klass); klass.set_css_name("media-history-viewer"); } @@ -48,7 +56,59 @@ mod imp { } #[glib::derived_properties] - impl ObjectImpl for MediaHistoryViewer {} + impl ObjectImpl for MediaHistoryViewer { + fn constructed(&self) { + self.parent_constructed(); + + let factory = gtk::SignalListItemFactory::new(); + + factory.connect_bind(move |_, list_item| { + let Some(list_item) = list_item.downcast_ref::() else { + error!("List item factory did not receive a list item: {list_item:?}"); + return; + }; + + list_item.set_activatable(false); + list_item.set_selectable(false); + }); + factory.connect_bind(move |_, list_item| { + let Some(list_item) = list_item.downcast_ref::() else { + error!("List item factory did not receive a list item: {list_item:?}"); + return; + }; + + let item = list_item.item(); + + if let Some(loading_row) = item + .and_downcast_ref::() + .filter(|_| !list_item.child().is_some_and(|c| c.is::())) + { + loading_row.unparent(); + loading_row.set_width_request(SIZE_REQUEST); + loading_row.set_height_request(SIZE_REQUEST); + + list_item.set_child(Some(loading_row)); + } else if let Some(event) = item.and_downcast::() { + let media_item = + if let Some(media_item) = list_item.child().and_downcast::() { + media_item + } else { + let media_item = MediaItem::new(); + media_item.set_width_request(SIZE_REQUEST); + media_item.set_height_request(SIZE_REQUEST); + + list_item.set_child(Some(&media_item)); + + media_item + }; + + media_item.set_event(Some(event)); + } + }); + + self.grid_view.set_factory(Some(&factory)); + } + } impl WidgetImpl for MediaHistoryViewer {} impl NavigationPageImpl for MediaHistoryViewer {} @@ -59,41 +119,90 @@ mod imp { let filter = gtk::CustomFilter::new(|obj| { obj.downcast_ref::() .is_some_and(|e| e.event_type() == HistoryViewerEventType::Media) + || obj.is::() }); - let filter_model = gtk::FilterListModel::new(Some(timeline.clone()), Some(filter)); + let filter_model = + gtk::FilterListModel::new(Some(timeline.with_loading_item().clone()), Some(filter)); let model = gtk::NoSelection::new(Some(filter_model)); + model.connect_items_changed(clone!( + #[weak(rename_to = imp)] + self, + move |_, _, _, _| { + imp.update_state(); + } + )); self.grid_view.set_model(Some(&model)); - // Load an initial number of items. + let timeline_state_handler = timeline.connect_state_notify(clone!( + #[weak(rename_to = imp)] + self, + move |_| { + imp.update_state(); + } + )); + self.timeline.set(timeline, vec![timeline_state_handler]); + self.update_state(); + spawn!(clone!( #[weak(rename_to = imp)] self, - #[weak] - timeline, async move { - while model.n_items() < MIN_N_ITEMS { - if !timeline.load().await { - break; - } - } + imp.init_timeline().await; + } + )); + } + + /// Initialize the timeline + async fn init_timeline(&self) { + let Some(model) = self.grid_view.model() else { + return; + }; + let timeline = self.timeline.obj(); + let obj = self.obj(); - let adj = imp.grid_view.vadjustment().unwrap(); - adj.connect_value_notify(clone!( - #[weak] - timeline, - move |adj| { - if adj.value() + adj.page_size() * 2.0 >= adj.upper() { - spawn!(async move { - timeline.load().await; - }); - } - } - )); + // Load an initial number of items. + while model.n_items() < MIN_N_ITEMS { + if !timeline.load().await { + break; + } + } + + let adj = self.grid_view.vadjustment().unwrap(); + adj.connect_value_notify(clone!( + #[weak] + obj, + move |adj| { + if adj.value() + adj.page_size() * 2.0 >= adj.upper() { + spawn!(async move { + obj.load_more().await; + }); + } } )); + } - self.timeline.set(timeline).unwrap(); + /// Update this viewer for the current state. + fn update_state(&self) { + let Some(model) = self.grid_view.model() else { + return; + }; + let timeline = self.timeline.obj(); + + match timeline.state() { + TimelineState::Initial | TimelineState::Loading if model.n_items() == 0 => { + self.stack.set_visible_child_name("loading"); + } + TimelineState::Error => { + self.stack.set_visible_child_name("error"); + } + TimelineState::Complete if model.n_items() == 0 => { + self.stack.set_visible_child_name("empty"); + } + _ => { + self.stack.set_visible_child_name("content"); + } + } } } } @@ -104,6 +213,7 @@ glib::wrapper! { @extends gtk::Widget, adw::NavigationPage; } +#[gtk::template_callbacks] impl MediaHistoryViewer { pub fn new(timeline: &HistoryViewerTimeline) -> Self { glib::Object::builder() @@ -125,4 +235,11 @@ impl MediaHistoryViewer { .set_message(&room, event.event_id(), event.message_content()); imp.media_viewer.reveal(item); } + + /// Load more history. + #[template_callback] + async fn load_more(&self) { + let timeline = self.imp().timeline.obj(); + timeline.load().await; + } } diff --git a/src/session/view/content/room_details/history_viewer/media.ui b/src/session/view/content/room_details/history_viewer/media.ui index c6c4d583..d597a3ab 100644 --- a/src/session/view/content/room_details/history_viewer/media.ui +++ b/src/session/view/content/room_details/history_viewer/media.ui @@ -15,42 +15,90 @@ - - never - True + + crossfade - - 1000 - 800 - natural - - - 2 - 5 - - - - - - - ]]> + + loading + Loading + + + center + center + True + + + + + + + + empty + No Media + + + True + True + True + image-symbolic + No Media + This room does not contain any media + + + + + + + error + Could Not Load Media + + + True + True + True + error-symbolic + Could Not Load Media + Check your network connection + + + true + Try Again + center + + - + + + + + + content + Media History + + + never + True + + + 1000 + 800 + natural + + + 2 + 5 + + + + + + diff --git a/src/session/view/content/room_details/history_viewer/media_item.rs b/src/session/view/content/room_details/history_viewer/media_item.rs index a4fefd9e..78fbe0a6 100644 --- a/src/session/view/content/room_details/history_viewer/media_item.rs +++ b/src/session/view/content/room_details/history_viewer/media_item.rs @@ -1,19 +1,16 @@ use gtk::{gdk, glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate}; -use matrix_sdk::{ - media::{MediaEventContent, MediaThumbnailSize}, - ruma::{ - api::client::media::get_content_thumbnail::v3::Method, - events::room::message::{ImageMessageEventContent, MessageType, VideoMessageEventContent}, - uint, - }, +use matrix_sdk::media::{MediaEventContent, MediaThumbnailSize}; +use ruma::{ + api::client::media::get_content_thumbnail::v3::Method, + events::room::message::{ImageMessageEventContent, MessageType, VideoMessageEventContent}, }; use tracing::warn; use super::{HistoryViewerEvent, MediaHistoryViewer}; -use crate::{ - matrix_filename, session::model::Session, spawn, spawn_tokio, - utils::add_activate_binding_action, -}; +use crate::{matrix_filename, spawn, spawn_tokio, utils::add_activate_binding_action}; + +/// The default size requested by a thumbnail. +const THUMBNAIL_SIZE: u32 = 300; mod imp { use std::cell::RefCell; @@ -91,137 +88,151 @@ mod imp { if *self.event.borrow() == event { return; } - let obj = self.obj(); - - if let Some(event) = &event { - let Some(room) = event.room() else { - return; - }; - let Some(session) = room.session() else { - return; - }; - match event.message_content() { - MessageType::Image(content) => { - obj.show_image(content, &session); - } - MessageType::Video(content) => { - obj.show_video(content, &session); - } - _ => {} - } - } self.event.replace(event); - obj.notify_event(); - } - } -} + self.update(); -glib::wrapper! { - /// A row presenting a media (image or video) event. - pub struct MediaItem(ObjectSubclass) - @extends gtk::Widget, @implements gtk::Accessible; -} + self.obj().notify_event(); + } -#[gtk::template_callbacks] -impl MediaItem { - fn show_image(&self, image: ImageMessageEventContent, session: &Session) { - let imp = self.imp(); + /// Update this item for the current state. + fn update(&self) { + let Some(message_content) = self.event.borrow().as_ref().map(|e| e.message_content()) + else { + return; + }; - if let Some(icon) = imp.overlay_icon.take() { - imp.overlay.remove_overlay(&icon); + match message_content { + MessageType::Image(content) => { + self.show_image(content); + } + MessageType::Video(content) => { + self.show_video(content); + } + _ => {} + } } - let filename = matrix_filename!(image, Some(mime::IMAGE)); - self.set_tooltip_text(Some(&filename)); + /// Show the given image with this item. + fn show_image(&self, image: ImageMessageEventContent) { + if let Some(icon) = self.overlay_icon.take() { + self.overlay.remove_overlay(&icon); + } - self.load_thumbnail(image, session); - } + let filename = matrix_filename!(image, Some(mime::IMAGE)); + self.obj().set_tooltip_text(Some(&filename)); + + self.load_thumbnail(image); + } - fn show_video(&self, video: VideoMessageEventContent, session: &Session) { - let imp = self.imp(); + /// Show the given video with this item. + fn show_video(&self, video: VideoMessageEventContent) { + if self.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) + .accessible_role(gtk::AccessibleRole::Presentation) + .build(); + + self.overlay.add_overlay(&icon); + self.overlay_icon.replace(Some(icon)); + } - 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) - .accessible_role(gtk::AccessibleRole::Presentation) - .build(); + let filename = matrix_filename!(video, Some(mime::VIDEO)); + self.obj().set_tooltip_text(Some(&filename)); - imp.overlay.add_overlay(&icon); - imp.overlay_icon.replace(Some(icon)); + self.load_thumbnail(video); } - let filename = matrix_filename!(video, Some(mime::VIDEO)); - self.set_tooltip_text(Some(&filename)); + /// Load the thumbnail for the given media event content. + fn load_thumbnail(&self, content: C) + where + C: MediaEventContent + Send + Sync + Clone + 'static, + { + let Some(session) = self + .event + .borrow() + .as_ref() + .and_then(|e| e.room()) + .and_then(|r| r.session()) + else { + return; + }; - self.load_thumbnail(video, session); - } + let media = session.client().media(); + let handle = spawn_tokio!(async move { + let thumbnail = if content.thumbnail_source().is_some() { + media + .get_thumbnail( + &content, + MediaThumbnailSize { + method: Method::Scale, + width: THUMBNAIL_SIZE.into(), + height: THUMBNAIL_SIZE.into(), + }, + true, + ) + .await + .ok() + .flatten() + } else { + None + }; - 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, - 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 + } + }); - if let Some(data) = thumbnail { - Ok(Some(data)) - } else { - media.get_file(&content, true).await - } - }); - - spawn!( - glib::Priority::LOW, - clone!( - #[weak(rename_to = obj)] - self, - 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); + spawn!( + glib::Priority::LOW, + clone!( + #[weak(rename_to = imp)] + self, + async move { + 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); + Ok(None) => { + warn!("Could not retrieve invalid media file"); + } + Err(error) => { + warn!("Could not retrieve media file: {}", error); + } } } - } - ) - ); + ) + ); + } + } +} + +glib::wrapper! { + /// A row presenting a media (image or video) event. + pub struct MediaItem(ObjectSubclass) + @extends gtk::Widget, @implements gtk::Accessible; +} + +#[gtk::template_callbacks] +impl MediaItem { + /// Construct a new empty `MediaItem`. + pub fn new() -> Self { + glib::Object::new() } + /// The item was activated. #[template_callback] fn activate(&self) { let media_history_viewer = self diff --git a/src/session/view/content/room_details/history_viewer/timeline.rs b/src/session/view/content/room_details/history_viewer/timeline.rs index 7030ff0d..c5efc2ec 100644 --- a/src/session/view/content/room_details/history_viewer/timeline.rs +++ b/src/session/view/content/room_details/history_viewer/timeline.rs @@ -12,6 +12,7 @@ use tracing::error; use super::HistoryViewerEvent; use crate::{ + components::LoadingRow, session::model::{Room, TimelineState}, spawn_tokio, }; @@ -37,6 +38,15 @@ mod imp { pub state: Cell, pub list: RefCell>, pub last_token: Arc>, + /// A wrapper model with an extra loading item at the end when + /// applicable. + /// + /// The loading item is a [`LoadingRow`], all other items are + /// [`HistoryViewerEvent`]s. + model_with_loading_item: OnceCell, + /// A model containing a [`LoadingRow`] when the timeline is loading. + loading_item_model: OnceCell, + loading_row: LoadingRow, } #[glib::object_subclass] @@ -64,6 +74,63 @@ mod imp { .map(|o| o.clone().upcast::()) } } + + impl HistoryViewerTimeline { + /// Set the state of the timeline. + pub(super) fn set_state(&self, state: TimelineState) { + if state == self.state.get() { + return; + } + + self.state.set(state); + + let loading_item_model = self.loading_item_model(); + if state == TimelineState::Loading { + if loading_item_model.n_items() == 0 { + loading_item_model.append(&self.loading_row); + } + } else if loading_item_model.n_items() != 0 { + loading_item_model.remove_all(); + } + + self.obj().notify_state(); + } + + /// Append the given batch to the timeline. + pub(super) fn append(&self, batch: Vec) { + if batch.is_empty() { + return; + } + + let index = self.n_items(); + let added = batch.len(); + + self.list.borrow_mut().extend(batch); + + self.obj().items_changed(index, 0, added as u32); + } + + /// A model containing a [`LoadingRow`] when the timeline is loading. + pub(super) fn loading_item_model(&self) -> &gio::ListStore { + self.loading_item_model + .get_or_init(gio::ListStore::new::) + } + + /// A wrapper model with an extra loading item at the end when + /// applicable. + /// + /// The loading item is a [`LoadingRow`], all other items are + /// [`HistoryViewerEvent`]s. + pub(super) fn model_with_loading_item(&self) -> >k::FlattenListModel { + self.model_with_loading_item.get_or_init(|| { + let wrapper_model = gio::ListStore::new::(); + wrapper_model.append(&*self.obj()); + wrapper_model.append(self.loading_item_model()); + + gtk::FlattenListModel::new(Some(wrapper_model)) + }) + } + } } glib::wrapper! { @@ -90,13 +157,13 @@ impl HistoryViewerTimeline { return false; } - self.set_state(TimelineState::Loading); + imp.set_state(TimelineState::Loading); let room = self.room(); let matrix_room = room.matrix_room().clone(); let last_token = imp.last_token.clone(); let is_encrypted = room.is_encrypted(); - let handle: tokio::task::JoinHandle> = spawn_tokio!(async move { + let handle = spawn_tokio!(async move { let last_token = last_token.lock().await; // If the room is encrypted, the messages content cannot be filtered with URLs @@ -134,56 +201,28 @@ impl HistoryViewerTimeline { .filter_map(|event| HistoryViewerEvent::try_new(&room, event)) .collect(); - self.append(events); - - self.set_state(TimelineState::Ready); + imp.append(events); + imp.set_state(TimelineState::Ready); true } None => { - self.set_state(TimelineState::Complete); + imp.set_state(TimelineState::Complete); false } }, Err(error) => { error!("Could not load history viewer timeline events: {error}"); - self.set_state(TimelineState::Error); + imp.set_state(TimelineState::Error); false } } } - fn append(&self, batch: Vec) { - let imp = self.imp(); - - if batch.is_empty() { - return; - } - - let added = batch.len(); - let index = { - let mut list = imp.list.borrow_mut(); - let index = list.len(); - - // Extend the size of the list so that rust doesn't need to reallocate memory - // multiple times - list.reserve(batch.len()); - - for event in batch { - list.push(event.upcast()); - } - - index - }; - - self.items_changed(index as u32, 0, added as u32); - } - - fn set_state(&self, state: TimelineState) { - if state == self.state() { - return; - } - - self.imp().state.set(state); - self.notify_state(); + /// This model with an extra loading item at the end when applicable. + /// + /// The loading item is a [`LoadingRow`], all other items are + /// [`HistoryViewerEvent`]s. + pub fn with_loading_item(&self) -> &gio::ListModel { + self.imp().model_with_loading_item().upcast_ref() } } diff --git a/src/session/view/content/room_history/mod.ui b/src/session/view/content/room_history/mod.ui index 4c5baf4a..1e050030 100644 --- a/src/session/view/content/room_history/mod.ui +++ b/src/session/view/content/room_history/mod.ui @@ -209,7 +209,7 @@ True error-symbolic Could Not Load Room - Check your network connection. + Check your network connection true