Browse Source

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.
merge-requests/1719/head
Kévin Commaille 2 years ago
parent
commit
9f796716f3
No known key found for this signature in database
GPG Key ID: C971D9DBC9D678D
  1. 10
      data/resources/style.css
  2. 7
      src/components/rows/loading_row.rs
  3. 168
      src/session/view/content/room_details/history_viewer/audio.rs
  4. 114
      src/session/view/content/room_details/history_viewer/audio.ui
  5. 151
      src/session/view/content/room_details/history_viewer/audio_row.rs
  6. 2
      src/session/view/content/room_details/history_viewer/audio_row.ui
  7. 168
      src/session/view/content/room_details/history_viewer/file.rs
  8. 114
      src/session/view/content/room_details/history_viewer/file.ui
  9. 73
      src/session/view/content/room_details/history_viewer/file_row.rs
  10. 2
      src/session/view/content/room_details/history_viewer/file_row.ui
  11. 177
      src/session/view/content/room_details/history_viewer/media.rs
  12. 112
      src/session/view/content/room_details/history_viewer/media.ui
  13. 251
      src/session/view/content/room_details/history_viewer/media_item.rs
  14. 119
      src/session/view/content/room_details/history_viewer/timeline.rs
  15. 2
      src/session/view/content/room_history/mod.ui

10
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;
}

7
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<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
self.connect_closure(
"retry",
@ -123,3 +124,9 @@ impl LoadingRow {
)
}
}
impl Default for LoadingRow {
fn default() -> Self {
Self::new()
}
}

168
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<HistoryViewerTimeline>,
pub timeline: BoundConstructOnlyObject<HistoryViewerTimeline>,
#[template_child]
pub stack: TemplateChild<gtk::Stack>,
#[template_child]
pub list_view: TemplateChild<gtk::ListView>,
}
@ -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::<gtk::ListItem>() 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::<gtk::ListItem>() 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::<LoadingRow>()
.filter(|_| !list_item.child().is_some_and(|c| c.is::<LoadingRow>()))
{
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::<HistoryViewerEvent>() {
let audio_row =
if let Some(audio_row) = list_item.child().and_downcast::<AudioRow>() {
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::<HistoryViewerEvent>()
.is_some_and(|e| e.event_type() == HistoryViewerEventType::Audio)
|| obj.is::<LoadingRow>()
});
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;
}
}

114
src/session/view/content/room_details/history_viewer/audio.ui

@ -9,43 +9,91 @@
<object class="AdwHeaderBar"/>
</child>
<property name="content">
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="vexpand">True</property>
<object class="GtkStack" id="stack">
<property name="transition-type">crossfade</property>
<child>
<object class="AdwClampScrollable">
<property name="maximum-size">400</property>
<property name="tightening-threshold">400</property>
<child>
<object class="GtkListView" id="list_view">
<property name="show-separators">True</property>
<property name="factory">
<object class="GtkBuilderListItemFactory">
<property name="bytes"><![CDATA[
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="GtkListItem">
<property name="activatable">False</property>
<property name="selectable">False</property>
<property name="child">
<object class="ContentAudioHistoryViewerRow">
<property name="margin-top">6</property>
<property name="margin-bottom">6</property>
<binding name="event">
<lookup name="item">GtkListItem</lookup>
</binding>
</object>
</property>
</template>
</interface>
]]></property>
</object>
</property>
<object class="GtkStackPage">
<property name="name">loading</property>
<property name="title" translatable="yes">Loading</property>
<property name="child">
<object class="Spinner" id="loading">
<property name="valign">center</property>
<property name="halign">center</property>
<property name="vexpand">True</property>
<style>
<class name="navigation-sidebar"/>
<class name="large"/>
</style>
</object>
</child>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">empty</property>
<property name="title" translatable="yes">No Audio</property>
<property name="child">
<object class="AdwStatusPage">
<property name="visible">True</property>
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="icon-name">audio-symbolic</property>
<property name="title" translatable="yes">No Audio</property>
<property name="description" translatable="yes">This room does not contain any audio</property>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">error</property>
<property name="title" translatable="yes">Could Not Load Audio</property>
<property name="child">
<object class="AdwStatusPage">
<property name="visible">True</property>
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="icon-name">error-symbolic</property>
<property name="title" translatable="yes">Could Not Load Audio</property>
<property name="description" translatable="yes">Check your network connection</property>
<property name="child">
<object class="GtkButton">
<property name="can-shrink">true</property>
<property name="label" translatable="yes">Try Again</property>
<property name="halign">center</property>
<signal name="clicked" handler="load_more" swapped="true"/>
<style>
<class name="pill"/>
</style>
</object>
</property>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">content</property>
<property name="title" translatable="yes">Audio History</property>
<property name="child">
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="vexpand">True</property>
<child>
<object class="AdwClampScrollable">
<property name="maximum-size">400</property>
<property name="tightening-threshold">400</property>
<child>
<object class="GtkListView" id="list_view">
<property name="show-separators">True</property>
<style>
<class name="navigation-sidebar"/>
</style>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</child>
</object>

151
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<Option<HistoryViewerEvent>>,
pub media_file: RefCell<Option<gtk::MediaFile>>,
#[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<Self>) {
@ -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::<String>).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::<String>).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();

2
src/session/view/content/room_details/history_viewer/audio_row.ui

@ -6,7 +6,7 @@
<property name="spacing">12</property>
<child>
<object class="GtkButton" id="play_button">
<property name="action-name">audio-row.toggle-play</property>
<signal name="clicked" handler="toggle_play" swapped="true"/>
<property name="icon-name">media-playback-start-symbolic</property>
<property name="valign">center</property>
<!-- Translators: As in "Play audio file". -->

168
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<HistoryViewerTimeline>,
pub timeline: BoundConstructOnlyObject<HistoryViewerTimeline>,
#[template_child]
pub stack: TemplateChild<gtk::Stack>,
#[template_child]
pub list_view: TemplateChild<gtk::ListView>,
}
@ -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::<gtk::ListItem>() 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::<gtk::ListItem>() 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::<LoadingRow>()
.filter(|_| !list_item.child().is_some_and(|c| c.is::<LoadingRow>()))
{
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::<HistoryViewerEvent>() {
let file_row =
if let Some(file_row) = list_item.child().and_downcast::<FileRow>() {
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::<HistoryViewerEvent>()
.is_some_and(|e| e.event_type() == HistoryViewerEventType::File)
|| obj.is::<LoadingRow>()
});
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;
}
}

114
src/session/view/content/room_details/history_viewer/file.ui

@ -8,43 +8,91 @@
<object class="AdwHeaderBar"/>
</child>
<property name="content">
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="vexpand">True</property>
<object class="GtkStack" id="stack">
<property name="transition-type">crossfade</property>
<child>
<object class="AdwClampScrollable">
<property name="maximum-size">400</property>
<property name="tightening-threshold">400</property>
<child>
<object class="GtkListView" id="list_view">
<property name="show-separators">True</property>
<property name="factory">
<object class="GtkBuilderListItemFactory">
<property name="bytes"><![CDATA[
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="GtkListItem">
<property name="activatable">False</property>
<property name="selectable">False</property>
<property name="child">
<object class="ContentFileHistoryViewerRow">
<property name="margin-top">6</property>
<property name="margin-bottom">6</property>
<binding name="event">
<lookup name="item">GtkListItem</lookup>
</binding>
</object>
</property>
</template>
</interface>
]]></property>
</object>
</property>
<object class="GtkStackPage">
<property name="name">loading</property>
<property name="title" translatable="yes">Loading</property>
<property name="child">
<object class="Spinner" id="loading">
<property name="valign">center</property>
<property name="halign">center</property>
<property name="vexpand">True</property>
<style>
<class name="navigation-sidebar"/>
<class name="large"/>
</style>
</object>
</child>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">empty</property>
<property name="title" translatable="yes">No Files</property>
<property name="child">
<object class="AdwStatusPage">
<property name="visible">True</property>
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="icon-name">document-symbolic</property>
<property name="title" translatable="yes">No Files</property>
<property name="description" translatable="yes">This room does not contain any files</property>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">error</property>
<property name="title" translatable="yes">Could Not Load Files</property>
<property name="child">
<object class="AdwStatusPage">
<property name="visible">True</property>
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="icon-name">error-symbolic</property>
<property name="title" translatable="yes">Could Not Load Files</property>
<property name="description" translatable="yes">Check your network connection</property>
<property name="child">
<object class="GtkButton">
<property name="can-shrink">true</property>
<property name="label" translatable="yes">Try Again</property>
<property name="halign">center</property>
<signal name="clicked" handler="load_more" swapped="true"/>
<style>
<class name="pill"/>
</style>
</object>
</property>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">content</property>
<property name="title" translatable="yes">File History</property>
<property name="child">
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="vexpand">True</property>
<child>
<object class="AdwClampScrollable">
<property name="maximum-size">400</property>
<property name="tightening-threshold">400</property>
<child>
<object class="GtkListView" id="list_view">
<property name="show-separators">True</property>
<style>
<class name="navigation-sidebar"/>
</style>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</child>
</object>

73
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<Self>) {
@ -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()
}
}
}

2
src/session/view/content/room_details/history_viewer/file_row.ui

@ -6,7 +6,7 @@
<property name="spacing">12</property>
<child>
<object class="GtkButton" id="button">
<property name="action-name">file-row.save-file</property>
<signal name="clicked" handler="button_clicked" swapped="true"/>
<property name="icon-name">save-symbolic</property>
<property name="valign">center</property>
<property name="tooltip-text" translatable="yes">Save File</property>

177
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<HistoryViewerTimeline>,
pub timeline: BoundConstructOnlyObject<HistoryViewerTimeline>,
#[template_child]
pub media_viewer: TemplateChild<MediaViewer>,
#[template_child]
pub stack: TemplateChild<gtk::Stack>,
#[template_child]
pub grid_view: TemplateChild<gtk::GridView>,
}
@ -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::<gtk::ListItem>() 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::<gtk::ListItem>() 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::<LoadingRow>()
.filter(|_| !list_item.child().is_some_and(|c| c.is::<LoadingRow>()))
{
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::<HistoryViewerEvent>() {
let media_item =
if let Some(media_item) = list_item.child().and_downcast::<MediaItem>() {
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::<HistoryViewerEvent>()
.is_some_and(|e| e.event_type() == HistoryViewerEventType::Media)
|| obj.is::<LoadingRow>()
});
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;
}
}

112
src/session/view/content/room_details/history_viewer/media.ui

@ -15,42 +15,90 @@
<object class="AdwHeaderBar"/>
</child>
<property name="content">
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="vexpand">True</property>
<object class="GtkStack" id="stack">
<property name="transition-type">crossfade</property>
<child>
<object class="AdwClampScrollable">
<property name="maximum-size">1000</property>
<property name="tightening-threshold">800</property>
<property name="vscroll-policy">natural</property>
<child>
<object class="GtkGridView" id="grid_view">
<property name="min-columns">2</property>
<property name="max-columns">5</property>
<property name="factory">
<object class="GtkBuilderListItemFactory">
<property name="bytes"><![CDATA[
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="GtkListItem">
<property name="selectable">False</property>
<property name="activatable">False</property>
<property name="child">
<object class="ContentMediaHistoryViewerItem">
<property name="width-request">150</property>
<property name="height-request">150</property>
<binding name="event">
<lookup name="item">GtkListItem</lookup>
</binding>
</object>
</property>
</template>
</interface>
]]></property>
<object class="GtkStackPage">
<property name="name">loading</property>
<property name="title" translatable="yes">Loading</property>
<property name="child">
<object class="Spinner" id="loading">
<property name="valign">center</property>
<property name="halign">center</property>
<property name="vexpand">True</property>
<style>
<class name="large"/>
</style>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">empty</property>
<property name="title" translatable="yes">No Media</property>
<property name="child">
<object class="AdwStatusPage">
<property name="visible">True</property>
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="icon-name">image-symbolic</property>
<property name="title" translatable="yes">No Media</property>
<property name="description" translatable="yes">This room does not contain any media</property>
</object>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">error</property>
<property name="title" translatable="yes">Could Not Load Media</property>
<property name="child">
<object class="AdwStatusPage">
<property name="visible">True</property>
<property name="hexpand">True</property>
<property name="vexpand">True</property>
<property name="icon-name">error-symbolic</property>
<property name="title" translatable="yes">Could Not Load Media</property>
<property name="description" translatable="yes">Check your network connection</property>
<property name="child">
<object class="GtkButton">
<property name="can-shrink">true</property>
<property name="label" translatable="yes">Try Again</property>
<property name="halign">center</property>
<signal name="clicked" handler="load_more" swapped="true"/>
<style>
<class name="pill"/>
</style>
</object>
</property>
</object>
</child>
</property>
</object>
</child>
<child>
<object class="GtkStackPage">
<property name="name">content</property>
<property name="title" translatable="yes">Media History</property>
<property name="child">
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="vexpand">True</property>
<child>
<object class="AdwClampScrollable">
<property name="maximum-size">1000</property>
<property name="tightening-threshold">800</property>
<property name="vscroll-policy">natural</property>
<child>
<object class="GtkGridView" id="grid_view">
<property name="min-columns">2</property>
<property name="max-columns">5</property>
</object>
</child>
</object>
</child>
</object>
</property>
</object>
</child>
</object>

251
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<imp::MediaItem>)
@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<C>(&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<C>(&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<imp::MediaItem>)
@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

119
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<TimelineState>,
pub list: RefCell<Vec<HistoryViewerEvent>>,
pub last_token: Arc<Mutex<String>>,
/// 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<gtk::FlattenListModel>,
/// A model containing a [`LoadingRow`] when the timeline is loading.
loading_item_model: OnceCell<gio::ListStore>,
loading_row: LoadingRow,
}
#[glib::object_subclass]
@ -64,6 +74,63 @@ mod imp {
.map(|o| o.clone().upcast::<glib::Object>())
}
}
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<HistoryViewerEvent>) {
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::<LoadingRow>)
}
/// 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) -> &gtk::FlattenListModel {
self.model_with_loading_item.get_or_init(|| {
let wrapper_model = gio::ListStore::new::<glib::Object>();
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<matrix_sdk::Result<_>> = 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<HistoryViewerEvent>) {
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()
}
}

2
src/session/view/content/room_history/mod.ui

@ -209,7 +209,7 @@
<property name="vexpand">True</property>
<property name="icon-name">error-symbolic</property>
<property name="title" translatable="yes">Could Not Load Room</property>
<property name="description" translatable="yes">Check your network connection.</property>
<property name="description" translatable="yes">Check your network connection</property>
<property name="child">
<object class="GtkButton">
<property name="can-shrink">true</property>

Loading…
Cancel
Save