[fractal/fractal-next] content: Add support for displaying rich replies
- From: Julian Sparber <jsparber src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [fractal/fractal-next] content: Add support for displaying rich replies
- Date: Thu, 13 Jan 2022 19:46:49 +0000 (UTC)
commit cf7bc0c90c2880f1fa03653283602c3096cf00fc
Author: Kévin Commaille <zecakeh tedomum fr>
Date: Thu Jan 13 14:24:54 2022 +0100
content: Add support for displaying rich replies
data/resources/resources.gresource.xml | 1 +
data/resources/style.css | 1 +
data/resources/ui/content-message-reply.ui | 27 ++
.../content/room_history/message_row/mod.rs | 390 +++++++++++----------
.../content/room_history/message_row/reply.rs | 73 ++++
.../content/room_history/message_row/text.rs | 23 +-
src/session/room/event.rs | 38 ++
src/session/room/timeline.rs | 42 ++-
8 files changed, 400 insertions(+), 195 deletions(-)
---
diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml
index ffb2867d..be75fc61 100644
--- a/data/resources/resources.gresource.xml
+++ b/data/resources/resources.gresource.xml
@@ -18,6 +18,7 @@
<file compressed="true" preprocess="xml-stripblanks"
alias="content-invitee-item.ui">ui/content-invitee-item.ui</file>
<file compressed="true" preprocess="xml-stripblanks"
alias="content-invitee-row.ui">ui/content-invitee-row.ui</file>
<file compressed="true" preprocess="xml-stripblanks"
alias="content-message-media.ui">ui/content-message-media.ui</file>
+ <file compressed="true" preprocess="xml-stripblanks"
alias="content-message-reply.ui">ui/content-message-reply.ui</file>
<file compressed="true" preprocess="xml-stripblanks"
alias="content-message-row.ui">ui/content-message-row.ui</file>
<file compressed="true" preprocess="xml-stripblanks"
alias="content-divider-row.ui">ui/content-divider-row.ui</file>
<file compressed="true" preprocess="xml-stripblanks"
alias="content-room-details.ui">ui/content-room-details.ui</file>
diff --git a/data/resources/style.css b/data/resources/style.css
index bc460033..20fd0bdd 100644
--- a/data/resources/style.css
+++ b/data/resources/style.css
@@ -232,6 +232,7 @@ headerbar.flat {
.room-history .event-content .quote {
border-left: 2px solid @theme_selected_bg_color;
padding-left: 6px;
+ opacity: 0.7;
}
.divider-row {
diff --git a/data/resources/ui/content-message-reply.ui b/data/resources/ui/content-message-reply.ui
new file mode 100644
index 00000000..ac147b1f
--- /dev/null
+++ b/data/resources/ui/content-message-reply.ui
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+ <template class="ContentMessageReply" parent="GtkBox">
+ <property name="orientation">vertical</property>
+ <property name="spacing">3</property>
+ <child>
+ <object class="GtkBox">
+ <style>
+ <class name="quote"/>
+ </style>
+ <property name="orientation">vertical</property>
+ <property name="spacing">3</property>
+ <child>
+ <object class="Pill" id="pill">
+ <property name="halign">start</property>
+ </object>
+ </child>
+ <child>
+ <object class="AdwBin" id="related_content"/>
+ </child>
+ </object>
+ </child>
+ <child>
+ <object class="AdwBin" id="content"/>
+ </child>
+ </template>
+</interface>
diff --git a/src/session/content/room_history/message_row/mod.rs
b/src/session/content/room_history/message_row/mod.rs
index 336b96f3..f5a9b57d 100644
--- a/src/session/content/room_history/message_row/mod.rs
+++ b/src/session/content/room_history/message_row/mod.rs
@@ -1,8 +1,9 @@
mod file;
mod media;
+mod reply;
mod text;
-use crate::{components::Avatar, utils::filename_for_mime};
+use crate::{components::Avatar, spawn, utils::filename_for_mime};
use adw::{prelude::*, subclass::prelude::*};
use gettextrs::gettext;
use gtk::{
@@ -14,7 +15,7 @@ use matrix_sdk::ruma::events::{
AnyMessageEventContent,
};
-use self::{file::MessageFile, media::MessageMedia, text::MessageText};
+use self::{file::MessageFile, media::MessageMedia, reply::MessageReply, text::MessageText};
use crate::prelude::*;
use crate::session::room::Event;
@@ -189,202 +190,217 @@ impl MessageRow {
fn update_content(&self, event: &Event) {
let priv_ = imp::MessageRow::from_instance(self);
- let content = event.content();
- // TODO: create widgets for all event types
- // TODO: display reaction events from event.relates_to()
- // TODO: we should reuse the already present child widgets when possible
+ if event.is_reply() {
+ spawn!(
+ glib::PRIORITY_HIGH,
+ clone!(@weak self as obj, @weak event => async move {
+ let priv_ = imp::MessageRow::from_instance(&obj);
- match content {
- Some(AnyMessageEventContent::RoomMessage(message)) => {
- let msgtype = if let Some(Relation::Replacement(replacement)) = message.relates_to {
- replacement.new_content.msgtype
- } else {
- message.msgtype
- };
- match msgtype {
- MessageType::Audio(_message) => {}
- MessageType::Emote(message) => {
- let child = if let Some(Ok(child)) =
- priv_.content.child().map(|w| w.downcast::<MessageText>())
- {
- child
- } else {
- let child = MessageText::new();
- priv_.content.set_child(Some(&child));
- child
- };
- child.emote(message.formatted, message.body, event.sender());
+ if let Ok(Some(related_event)) = event.reply_to_event().await {
+ let reply = MessageReply::new();
+ reply.set_related_content_sender(related_event.sender().upcast());
+ build_content(reply.related_content(), &related_event);
+ build_content(reply.content(), &event);
+ priv_.content.set_child(Some(&reply));
+ } else {
+ build_content(&*priv_.content, &event);
}
- MessageType::File(message) => {
- let info = message.info.as_ref();
- let filename = message
- .filename
- .filter(|name| !name.is_empty())
- .or(Some(message.body))
- .filter(|name| !name.is_empty())
- .unwrap_or_else(|| {
- filename_for_mime(
- info.and_then(|info| info.mimetype.as_deref()),
- None,
- )
- });
+ })
+ );
+ } else {
+ build_content(&*priv_.content, event);
+ }
+ }
+}
- let child = if let Some(Ok(child)) =
- priv_.content.child().map(|w| w.downcast::<MessageFile>())
- {
- child
- } else {
- let child = MessageFile::new();
- priv_.content.set_child(Some(&child));
- child
- };
- child.set_filename(Some(filename));
- }
- MessageType::Image(message) => {
- let child = if let Some(Ok(child)) =
- priv_.content.child().map(|w| w.downcast::<MessageMedia>())
- {
- child
- } else {
- let child = MessageMedia::new();
- priv_.content.set_child(Some(&child));
- child
- };
- child.image(message, &event.room().session());
- }
- MessageType::Location(_message) => {}
- MessageType::Notice(message) => {
- let child = if let Some(Ok(child)) =
- priv_.content.child().map(|w| w.downcast::<MessageText>())
- {
- child
- } else {
- let child = MessageText::new();
- priv_.content.set_child(Some(&child));
- child
- };
- child.markup(message.formatted, message.body);
- }
- MessageType::ServerNotice(message) => {
- let child = if let Some(Ok(child)) =
- priv_.content.child().map(|w| w.downcast::<MessageText>())
- {
- child
- } else {
- let child = MessageText::new();
- priv_.content.set_child(Some(&child));
- child
- };
- child.text(message.body);
- }
- MessageType::Text(message) => {
- let child = if let Some(Ok(child)) =
- priv_.content.child().map(|w| w.downcast::<MessageText>())
- {
- child
- } else {
- let child = MessageText::new();
- priv_.content.set_child(Some(&child));
- child
- };
- child.markup(message.formatted, message.body);
- }
- MessageType::Video(message) => {
- let child = if let Some(Ok(child)) =
- priv_.content.child().map(|w| w.downcast::<MessageMedia>())
- {
- child
- } else {
- let child = MessageMedia::new();
- priv_.content.set_child(Some(&child));
- child
- };
- child.video(message, &event.room().session());
- }
- MessageType::VerificationRequest(_) => {
- // TODO: show more information about the verification
- let child = if let Some(Ok(child)) =
- priv_.content.child().map(|w| w.downcast::<MessageText>())
- {
- child
- } else {
- let child = MessageText::new();
- priv_.content.set_child(Some(&child));
- child
- };
- child.text(gettext("Identity verification was started"));
- }
- _ => {
- warn!("Event not supported: {:?}", msgtype);
- let child = if let Some(Ok(child)) =
- priv_.content.child().map(|w| w.downcast::<MessageText>())
- {
- child
- } else {
- let child = MessageText::new();
- priv_.content.set_child(Some(&child));
- child
- };
- child.text(gettext("Unsupported event"));
- }
+impl Default for MessageRow {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+/// Build the content widget of `event` as a child of `parent`.
+fn build_content(parent: &adw::Bin, event: &Event) {
+ // TODO: create widgets for all event types
+ // TODO: display reaction events from event.relates_to()
+ // TODO: we should reuse the already present child widgets when possible
+ match event.content() {
+ Some(AnyMessageEventContent::RoomMessage(message)) => {
+ let msgtype = if let Some(Relation::Replacement(replacement)) = message.relates_to {
+ replacement.new_content.msgtype
+ } else {
+ message.msgtype
+ };
+ match msgtype {
+ MessageType::Audio(_message) => {}
+ MessageType::Emote(message) => {
+ let child = if let Some(Ok(child)) =
+ parent.child().map(|w| w.downcast::<MessageText>())
+ {
+ child
+ } else {
+ let child = MessageText::new();
+ parent.set_child(Some(&child));
+ child
+ };
+ child.emote(message.formatted, message.body, event.sender());
+ }
+ MessageType::File(message) => {
+ let info = message.info.as_ref();
+ let filename = message
+ .filename
+ .filter(|name| !name.is_empty())
+ .or(Some(message.body))
+ .filter(|name| !name.is_empty())
+ .unwrap_or_else(|| {
+ filename_for_mime(info.and_then(|info| info.mimetype.as_deref()), None)
+ });
+
+ let child = if let Some(Ok(child)) =
+ parent.child().map(|w| w.downcast::<MessageFile>())
+ {
+ child
+ } else {
+ let child = MessageFile::new();
+ parent.set_child(Some(&child));
+ child
+ };
+ child.set_filename(Some(filename));
+ }
+ MessageType::Image(message) => {
+ let child = if let Some(Ok(child)) =
+ parent.child().map(|w| w.downcast::<MessageMedia>())
+ {
+ child
+ } else {
+ let child = MessageMedia::new();
+ parent.set_child(Some(&child));
+ child
+ };
+ child.image(message, &event.room().session());
+ }
+ MessageType::Location(_message) => {}
+ MessageType::Notice(message) => {
+ let child = if let Some(Ok(child)) =
+ parent.child().map(|w| w.downcast::<MessageText>())
+ {
+ child
+ } else {
+ let child = MessageText::new();
+ parent.set_child(Some(&child));
+ child
+ };
+ child.markup(message.formatted, message.body);
+ }
+ MessageType::ServerNotice(message) => {
+ let child = if let Some(Ok(child)) =
+ parent.child().map(|w| w.downcast::<MessageText>())
+ {
+ child
+ } else {
+ let child = MessageText::new();
+ parent.set_child(Some(&child));
+ child
+ };
+ child.text(message.body);
+ }
+ MessageType::Text(message) => {
+ let child = if let Some(Ok(child)) =
+ parent.child().map(|w| w.downcast::<MessageText>())
+ {
+ child
+ } else {
+ let child = MessageText::new();
+ parent.set_child(Some(&child));
+ child
+ };
+ child.markup(message.formatted, message.body);
+ }
+ MessageType::Video(message) => {
+ let child = if let Some(Ok(child)) =
+ parent.child().map(|w| w.downcast::<MessageMedia>())
+ {
+ child
+ } else {
+ let child = MessageMedia::new();
+ parent.set_child(Some(&child));
+ child
+ };
+ child.video(message, &event.room().session());
+ }
+ MessageType::VerificationRequest(_) => {
+ // TODO: show more information about the verification
+ let child = if let Some(Ok(child)) =
+ parent.child().map(|w| w.downcast::<MessageText>())
+ {
+ child
+ } else {
+ let child = MessageText::new();
+ parent.set_child(Some(&child));
+ child
+ };
+ child.text(gettext("Identity verification was started"));
+ }
+ _ => {
+ warn!("Event not supported: {:?}", msgtype);
+ let child = if let Some(Ok(child)) =
+ parent.child().map(|w| w.downcast::<MessageText>())
+ {
+ child
+ } else {
+ let child = MessageText::new();
+ parent.set_child(Some(&child));
+ child
+ };
+ child.text(gettext("Unsupported event"));
}
}
- Some(AnyMessageEventContent::Sticker(content)) => {
- let child = if let Some(Ok(child)) =
- priv_.content.child().map(|w| w.downcast::<MessageMedia>())
- {
+ }
+ Some(AnyMessageEventContent::Sticker(content)) => {
+ let child =
+ if let Some(Ok(child)) = parent.child().map(|w| w.downcast::<MessageMedia>()) {
child
} else {
let child = MessageMedia::new();
- priv_.content.set_child(Some(&child));
- child
- };
- child.sticker(content, &event.room().session());
- }
- Some(AnyMessageEventContent::RoomEncrypted(content)) => {
- warn!("Couldn't decrypt event {:?}", content);
- let child = if let Some(Ok(child)) =
- priv_.content.child().map(|w| w.downcast::<MessageText>())
- {
- child
- } else {
- let child = MessageText::new();
- priv_.content.set_child(Some(&child));
+ parent.set_child(Some(&child));
child
};
- child.text(gettext("Fractal couldn't decrypt this message."));
- }
- Some(AnyMessageEventContent::RoomRedaction(_)) => {
- let child = if let Some(Ok(child)) =
- priv_.content.child().map(|w| w.downcast::<MessageText>())
- {
- child
- } else {
- let child = MessageText::new();
- priv_.content.set_child(Some(&child));
- child
- };
- child.text(gettext("This message was removed."));
- }
- _ => {
- let child = if let Some(Ok(child)) =
- priv_.content.child().map(|w| w.downcast::<MessageText>())
- {
- child
- } else {
- let child = MessageText::new();
- priv_.content.set_child(Some(&child));
- child
- };
- child.text(gettext("Unsupported event"));
- }
+ child.sticker(content, &event.room().session());
+ }
+ Some(AnyMessageEventContent::RoomEncrypted(content)) => {
+ warn!("Couldn't decrypt event {:?}", content);
+ let child = if let Some(Ok(child)) = parent.child().map(|w| w.downcast::<MessageText>())
+ {
+ child
+ } else {
+ let child = MessageText::new();
+ parent.set_child(Some(&child));
+ child
+ };
+ child.text(gettext("Fractal couldn't decrypt this message."));
+ }
+ Some(AnyMessageEventContent::RoomRedaction(_)) => {
+ let child = if let Some(Ok(child)) = parent.child().map(|w| w.downcast::<MessageText>())
+ {
+ child
+ } else {
+ let child = MessageText::new();
+ parent.set_child(Some(&child));
+ child
+ };
+ child.text(gettext("This message was removed."));
+ }
+ _ => {
+ let child = if let Some(Ok(child)) = parent.child().map(|w| w.downcast::<MessageText>())
+ {
+ child
+ } else {
+ let child = MessageText::new();
+ parent.set_child(Some(&child));
+ child
+ };
+ child.text(gettext("Unsupported event"));
}
- }
-}
-
-impl Default for MessageRow {
- fn default() -> Self {
- Self::new()
}
}
diff --git a/src/session/content/room_history/message_row/reply.rs
b/src/session/content/room_history/message_row/reply.rs
new file mode 100644
index 00000000..353d2092
--- /dev/null
+++ b/src/session/content/room_history/message_row/reply.rs
@@ -0,0 +1,73 @@
+use adw::{prelude::*, subclass::prelude::*};
+use gtk::{glib, subclass::prelude::*, CompositeTemplate};
+
+use crate::{components::Pill, session::User};
+
+mod imp {
+ use super::*;
+ use glib::subclass::InitializingObject;
+
+ #[derive(Debug, Default, CompositeTemplate)]
+ #[template(resource = "/org/gnome/FractalNext/content-message-reply.ui")]
+ pub struct MessageReply {
+ #[template_child]
+ pub pill: TemplateChild<Pill>,
+ #[template_child]
+ pub related_content: TemplateChild<adw::Bin>,
+ #[template_child]
+ pub content: TemplateChild<adw::Bin>,
+ }
+
+ #[glib::object_subclass]
+ impl ObjectSubclass for MessageReply {
+ const NAME: &'static str = "ContentMessageReply";
+ type Type = super::MessageReply;
+ type ParentType = gtk::Box;
+
+ fn class_init(klass: &mut Self::Class) {
+ Self::bind_template(klass);
+ }
+
+ fn instance_init(obj: &InitializingObject<Self>) {
+ obj.init_template();
+ }
+ }
+
+ impl ObjectImpl for MessageReply {}
+
+ impl WidgetImpl for MessageReply {}
+
+ impl BoxImpl for MessageReply {}
+}
+
+glib::wrapper! {
+ pub struct MessageReply(ObjectSubclass<imp::MessageReply>)
+ @extends gtk::Widget, gtk::Box, @implements gtk::Accessible;
+}
+
+impl MessageReply {
+ pub fn new() -> Self {
+ glib::Object::new(&[]).expect("Failed to create MessageReply")
+ }
+
+ pub fn set_related_content_sender(&self, user: User) {
+ let priv_ = imp::MessageReply::from_instance(self);
+ priv_.pill.set_user(Some(user));
+ }
+
+ pub fn related_content(&self) -> &adw::Bin {
+ let priv_ = imp::MessageReply::from_instance(self);
+ priv_.related_content.as_ref()
+ }
+
+ pub fn content(&self) -> &adw::Bin {
+ let priv_ = imp::MessageReply::from_instance(self);
+ priv_.content.as_ref()
+ }
+}
+
+impl Default for MessageReply {
+ fn default() -> Self {
+ Self::new()
+ }
+}
diff --git a/src/session/content/room_history/message_row/text.rs
b/src/session/content/room_history/message_row/text.rs
index 31a26b2c..e54a75a7 100644
--- a/src/session/content/room_history/message_row/text.rs
+++ b/src/session/content/room_history/message_row/text.rs
@@ -4,6 +4,7 @@ use html2pango::{
block::{markup_html, HtmlBlock},
html_escape, markup_links,
};
+use log::warn;
use matrix_sdk::ruma::events::room::message::{FormattedBody, MessageFormat};
use once_cell::sync::Lazy;
use regex::Regex;
@@ -131,14 +132,14 @@ impl MessageText {
if let Some((html_blocks, body)) = formatted
.filter(|formatted| is_valid_formatted_body(formatted))
.and_then(|formatted| {
- parse_formatted_body(&formatted.body)
+ parse_formatted_body(strip_reply(&formatted.body))
.and_then(|blocks| Some((blocks, formatted.body)))
})
{
self.build_html(html_blocks);
self.set_body(Some(body));
} else {
- let body = linkify(&body);
+ let body = linkify(strip_reply(&body));
self.build_text(&body, true);
self.set_body(Some(body));
}
@@ -189,7 +190,7 @@ impl MessageText {
{
// TODO: we need to bind the display name to the sender
let formatted = FormattedBody {
- body: format!("<b>{}</b> {}", sender.display_name(), &body),
+ body: format!("<b>{}</b> {}", sender.display_name(), strip_reply(&body)),
format: MessageFormat::Html,
};
@@ -324,7 +325,6 @@ fn create_widget_for_html_block(block: &HtmlBlock) -> gtk::Widget {
HtmlBlock::Quote(blocks) => {
let bx = gtk::Box::new(gtk::Orientation::Vertical, 6);
bx.add_css_class("quote");
- bx.add_css_class("dim-label");
for block in blocks.iter() {
let w = create_widget_for_html_block(block);
bx.append(&w);
@@ -340,6 +340,21 @@ fn create_widget_for_html_block(block: &HtmlBlock) -> gtk::Widget {
}
}
+/// Remove the content between `mx-reply` tags.
+///
+/// Returns the unchanged string if none was found to be able to chain calls.
+fn strip_reply(text: &str) -> &str {
+ if let Some(end) = text.find("</mx-reply>") {
+ if !text.starts_with("<mx-reply>") {
+ warn!("Received a rich reply that doesn't start with '<mx-reply>'");
+ }
+
+ &text[end + 11..]
+ } else {
+ text
+ }
+}
+
impl Default for MessageText {
fn default() -> Self {
Self::new()
diff --git a/src/session/room/event.rs b/src/session/room/event.rs
index cb717506..784a3bc0 100644
--- a/src/session/room/event.rs
+++ b/src/session/room/event.rs
@@ -13,6 +13,7 @@ use matrix_sdk::{
identifiers::{EventId, UserId},
MilliSecondsSinceUnixEpoch,
},
+ Error as MatrixError,
};
use crate::{
@@ -706,4 +707,41 @@ impl Event {
_ => false,
}
}
+
+ /// Get the id of the event this `Event` replies to, if any.
+ pub fn reply_to_id(&self) -> Option<EventId> {
+ match self.original_content()? {
+ AnyMessageEventContent::RoomMessage(message) => {
+ if let Some(Relation::Reply { in_reply_to }) = message.relates_to {
+ Some(in_reply_to.event_id)
+ } else {
+ None
+ }
+ }
+ _ => None,
+ }
+ }
+
+ /// Whether this `Event` is a reply to another event.
+ pub fn is_reply(&self) -> bool {
+ self.reply_to_id().is_some()
+ }
+
+ /// Get the `Event` this `Event` replies to, if any.
+ ///
+ /// Returns `Ok(None)` if this event is not a reply.
+ pub async fn reply_to_event(&self) -> Result<Option<Event>, MatrixError> {
+ let related_event_id = match self.reply_to_id() {
+ Some(related_event_id) => related_event_id,
+ None => {
+ return Ok(None);
+ }
+ };
+ let event = self
+ .room()
+ .timeline()
+ .fetch_event_by_id(&related_event_id)
+ .await?;
+ Ok(Some(event))
+ }
}
diff --git a/src/session/room/timeline.rs b/src/session/room/timeline.rs
index 7da6b202..eb46d04c 100644
--- a/src/session/room/timeline.rs
+++ b/src/session/room/timeline.rs
@@ -4,13 +4,16 @@ use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*};
use log::{error, warn};
use matrix_sdk::{
ruma::{
- api::client::r0::message::get_message_events::Direction,
+ api::client::r0::{
+ message::get_message_events::Direction, room::get_room_event::Request as EventRequest,
+ },
events::{
room::message::MessageType, AnySyncMessageEvent, AnySyncRoomEvent, AnySyncStateEvent,
},
identifiers::EventId,
},
uuid::Uuid,
+ Error as MatrixError,
};
use crate::session::{
@@ -482,14 +485,45 @@ impl Timeline {
}
}
- /// Returns the event with the given id
+ /// Get the event with the given id from the local store.
+ ///
+ /// Use this method if you are sure the event has already been received.
+ /// Otherwise use `fetch_event_by_id`.
pub fn event_by_id(&self, event_id: &EventId) -> Option<Event> {
- // TODO: if the referenced event isn't known to us we will need to request it
- // from the sdk or the matrix homeserver
let priv_ = imp::Timeline::from_instance(self);
priv_.event_map.borrow().get(event_id).cloned()
}
+ /// Fetch the event with the given id.
+ ///
+ /// If the event can't be found locally, a request will be made to the
+ /// homeserver.
+ ///
+ /// Use this method if you are not sure the event has already been received.
+ /// Otherwise use `event_by_id`.
+ pub async fn fetch_event_by_id(&self, event_id: &EventId) -> Result<Event, MatrixError> {
+ if let Some(event) = self.event_by_id(event_id) {
+ Ok(event)
+ } else {
+ let room = self.room();
+ let matrix_room = room.matrix_room();
+ let event_id_clone = event_id.clone();
+ let handle = spawn_tokio!(async move {
+ matrix_room
+ .event(EventRequest::new(matrix_room.room_id(), &event_id_clone))
+ .await
+ });
+ match handle.await.unwrap() {
+ Ok(room_event) => Ok(Event::new(room_event.event.into(), &room)),
+ Err(error) => {
+ // TODO: Retry on connection error?
+ warn!("Could not fetch event {}: {}", event_id, error);
+ Err(error)
+ }
+ }
+ }
+ }
+
/// Prepends a batch of events
// TODO: This should be lazy, see: https://blogs.gnome.org/ebassi/documentation/lazy-loading/
pub fn prepend(&self, batch: Vec<Event>) {
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]