diff --git a/data/resources/icons/scalable/status/done-symbolic.svg b/data/resources/icons/scalable/status/done-symbolic.svg
new file mode 100644
index 00000000..df40e217
--- /dev/null
+++ b/data/resources/icons/scalable/status/done-symbolic.svg
@@ -0,0 +1,2 @@
+
+
diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml
index e9f52b70..1cd0b989 100644
--- a/data/resources/resources.gresource.xml
+++ b/data/resources/resources.gresource.xml
@@ -43,6 +43,7 @@
icons/scalable/status/checkmark-symbolic.svg
icons/scalable/status/devices-symbolic.svg
icons/scalable/status/document-symbolic.svg
+ icons/scalable/status/done-symbolic.svg
icons/scalable/status/empty-page-symbolic.svg
icons/scalable/status/error-symbolic.svg
icons/scalable/status/explore-symbolic.svg
diff --git a/po/POTFILES.in b/po/POTFILES.in
index c1311286..62c119d5 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -84,7 +84,8 @@ src/session/view/content/room_history/message_row/content.rs
src/session/view/content/room_history/message_row/file.ui
src/session/view/content/room_history/message_row/location.rs
src/session/view/content/room_history/message_row/media.rs
-src/session/view/content/room_history/message_row/mod.ui
+src/session/view/content/room_history/message_row/message_state_stack.rs
+src/session/view/content/room_history/message_row/message_state_stack.ui
src/session/view/content/room_history/message_toolbar/attachment_dialog.ui
src/session/view/content/room_history/message_toolbar/mod.rs
src/session/view/content/room_history/message_toolbar/mod.ui
diff --git a/src/session/model/mod.rs b/src/session/model/mod.rs
index 8075bf9a..2d74fef4 100644
--- a/src/session/model/mod.rs
+++ b/src/session/model/mod.rs
@@ -12,10 +12,10 @@ pub use self::{
avatar::{AvatarData, AvatarImage, AvatarUriSource},
notifications::Notifications,
room::{
- Event, EventKey, HighlightFlags, Member, MemberList, MemberRole, Membership, PowerLevel,
- ReactionGroup, ReactionList, Room, RoomType, Timeline, TimelineItem, TimelineItemExt,
- TimelineState, TypingList, UserReadReceipt, VirtualItem, VirtualItemKind, POWER_LEVEL_MAX,
- POWER_LEVEL_MIN,
+ Event, EventKey, HighlightFlags, Member, MemberList, MemberRole, Membership, MessageState,
+ PowerLevel, ReactionGroup, ReactionList, Room, RoomType, Timeline, TimelineItem,
+ TimelineItemExt, TimelineState, TypingList, UserReadReceipt, VirtualItem, VirtualItemKind,
+ POWER_LEVEL_MAX, POWER_LEVEL_MIN,
},
room_list::RoomList,
session::{Session, SessionState},
diff --git a/src/session/model/room/event/mod.rs b/src/session/model/room/event/mod.rs
index 0abf6760..bd1eff16 100644
--- a/src/session/model/room/event/mod.rs
+++ b/src/session/model/room/event/mod.rs
@@ -3,14 +3,15 @@ use std::{borrow::Cow, fmt};
use gtk::{gio, glib, prelude::*, subclass::prelude::*};
use indexmap::IndexMap;
use matrix_sdk_ui::timeline::{
- AnyOtherFullStateEventContent, Error as TimelineError, EventTimelineItem, RepliedToEvent,
- TimelineDetails, TimelineItemContent,
+ AnyOtherFullStateEventContent, Error as TimelineError, EventSendState, EventTimelineItem,
+ RepliedToEvent, TimelineDetails, TimelineItemContent,
};
use ruma::{
events::{receipt::Receipt, room::message::MessageType, AnySyncTimelineEvent},
serde::Raw,
EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedTransactionId, OwnedUserId,
};
+use tracing::error;
mod reaction_group;
mod reaction_list;
@@ -67,6 +68,18 @@ impl glib::FromVariant for EventKey {
}
}
+#[derive(Debug, Default, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)]
+#[repr(u32)]
+#[enum_type(name = "MessageState")]
+pub enum MessageState {
+ #[default]
+ None = 0,
+ Sending = 1,
+ Error = 2,
+ Cancelled = 3,
+ Edited = 4,
+}
+
#[derive(Clone, Debug, glib::Boxed)]
#[boxed_type(name = "BoxedEventTimelineItem")]
pub struct BoxedEventTimelineItem(EventTimelineItem);
@@ -79,7 +92,7 @@ pub struct UserReadReceipt {
}
mod imp {
- use std::cell::RefCell;
+ use std::cell::{Cell, RefCell};
use glib::object::WeakRef;
use once_cell::sync::Lazy;
@@ -99,6 +112,9 @@ mod imp {
/// The read receipts on this event.
pub read_receipts: gio::ListStore,
+
+ /// The state of this event.
+ pub state: Cell,
}
impl Default for Event {
@@ -108,6 +124,7 @@ mod imp {
room: Default::default(),
reactions: Default::default(),
read_receipts: gio::ListStore::new::(),
+ state: Default::default(),
}
}
}
@@ -146,6 +163,9 @@ mod imp {
glib::ParamSpecBoolean::builder("has-read-receipts")
.read_only()
.build(),
+ glib::ParamSpecEnum::builder::("state")
+ .read_only()
+ .build(),
]
});
@@ -179,6 +199,7 @@ mod imp {
"is-highlighted" => obj.is_highlighted().to_value(),
"read-receipts" => obj.read_receipts().to_value(),
"has-read-receipts" => obj.has_read_receipts().to_value(),
+ "state" => obj.state().to_value(),
_ => unimplemented!(),
}
}
@@ -291,6 +312,7 @@ impl Event {
if self.is_highlighted() != was_highlighted {
self.notify("is-highlighted");
}
+ self.update_state();
}
/// The raw JSON source for this `Event`, if it has been echoed back
@@ -447,6 +469,51 @@ impl Event {
}
}
+ /// The state of this `Event`.
+ pub fn state(&self) -> MessageState {
+ self.imp().state.get()
+ }
+
+ /// Compute the current state of this `Event`.
+ fn compute_state(&self) -> MessageState {
+ let item_ref = self.imp().item.borrow();
+ let Some(item) = item_ref.as_ref() else {
+ return MessageState::None;
+ };
+
+ if let Some(send_state) = item.send_state() {
+ match send_state {
+ EventSendState::NotSentYet => return MessageState::Sending,
+ EventSendState::SendingFailed { error } => {
+ if self.state() != MessageState::Error {
+ error!("Failed to send message: {error}");
+ }
+
+ return MessageState::Error;
+ }
+ EventSendState::Cancelled => return MessageState::Cancelled,
+ EventSendState::Sent { .. } => {}
+ }
+ }
+
+ match item.content() {
+ TimelineItemContent::Message(msg) if msg.is_edited() => MessageState::Edited,
+ _ => MessageState::None,
+ }
+ }
+
+ /// Update the state of this `Event`.
+ fn update_state(&self) {
+ let state = self.compute_state();
+
+ if self.state() == state {
+ return;
+ }
+
+ self.imp().state.set(state);
+ self.notify("state");
+ }
+
/// Whether this `Event` should be highlighted.
pub fn is_highlighted(&self) -> bool {
let item_ref = self.imp().item.borrow();
diff --git a/src/session/view/content/room_history/message_row/message_state_stack.rs b/src/session/view/content/room_history/message_row/message_state_stack.rs
new file mode 100644
index 00000000..87c9ad0d
--- /dev/null
+++ b/src/session/view/content/room_history/message_row/message_state_stack.rs
@@ -0,0 +1,164 @@
+use adw::subclass::prelude::*;
+use gettextrs::gettext;
+use gtk::{glib, glib::clone, prelude::*, CompositeTemplate};
+
+use crate::session::model::MessageState;
+
+mod imp {
+ use std::cell::Cell;
+
+ use glib::subclass::InitializingObject;
+ use once_cell::sync::Lazy;
+
+ use super::*;
+
+ #[derive(Debug, Default, CompositeTemplate)]
+ #[template(
+ resource = "/org/gnome/Fractal/ui/session/view/content/room_history/message_row/message_state_stack.ui"
+ )]
+ pub struct MessageStateStack {
+ /// The state that is currently displayed.
+ pub state: Cell,
+ #[template_child]
+ pub stack: TemplateChild,
+ #[template_child]
+ pub error_image: TemplateChild,
+ }
+
+ #[glib::object_subclass]
+ impl ObjectSubclass for MessageStateStack {
+ const NAME: &'static str = "MessageStateStack";
+ type Type = super::MessageStateStack;
+ type ParentType = adw::Bin;
+
+ fn class_init(klass: &mut Self::Class) {
+ Self::bind_template(klass);
+ }
+
+ fn instance_init(obj: &InitializingObject) {
+ obj.init_template();
+ }
+ }
+
+ impl ObjectImpl for MessageStateStack {
+ fn properties() -> &'static [glib::ParamSpec] {
+ static PROPERTIES: Lazy> = Lazy::new(|| {
+ vec![glib::ParamSpecEnum::builder::("state")
+ .explicit_notify()
+ .build()]
+ });
+
+ PROPERTIES.as_ref()
+ }
+
+ fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
+ let obj = self.obj();
+
+ match pspec.name() {
+ "state" => {
+ obj.set_state(value.get().unwrap());
+ }
+ _ => unimplemented!(),
+ }
+ }
+
+ fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+ let obj = self.obj();
+
+ match pspec.name() {
+ "state" => obj.state().to_value(),
+ _ => unimplemented!(),
+ }
+ }
+ }
+ impl WidgetImpl for MessageStateStack {}
+ impl BinImpl for MessageStateStack {}
+}
+
+glib::wrapper! {
+ /// A stack to display the different message states.
+ pub struct MessageStateStack(ObjectSubclass)
+ @extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
+}
+
+impl MessageStateStack {
+ /// Create a new `MessageStateStack`.
+ pub fn new() -> Self {
+ glib::Object::new()
+ }
+
+ /// The state that is currently displayed.
+ pub fn state(&self) -> MessageState {
+ self.imp().state.get()
+ }
+
+ /// Set the state to display.
+ pub fn set_state(&self, state: MessageState) {
+ let prev_state = self.state();
+
+ if prev_state == state {
+ return;
+ }
+
+ let imp = self.imp();
+ let stack = &*imp.stack;
+ match state {
+ MessageState::None => {
+ if matches!(
+ prev_state,
+ MessageState::Sending | MessageState::Error | MessageState::Cancelled
+ ) {
+ // Show the sent icon for 2 seconds.
+ stack.set_visible_child_name("sent");
+
+ glib::timeout_add_seconds_local_once(
+ 2,
+ clone!(@weak self as obj => move || {
+ obj.set_visible(false);
+ }),
+ );
+ } else {
+ self.set_visible(false);
+ }
+ }
+ MessageState::Sending => {
+ stack.set_visible_child_name("sending");
+ self.set_visible(true);
+ }
+ MessageState::Error => {
+ imp.error_image
+ .set_tooltip_text(Some(&gettext("Could not send the message")));
+ stack.set_visible_child_name("error");
+ self.set_visible(true);
+ }
+ MessageState::Cancelled => {
+ imp.error_image
+ .set_tooltip_text(Some(&gettext("An error occurred with the sending queue")));
+ stack.set_visible_child_name("error");
+ self.set_visible(true);
+ }
+ MessageState::Edited => {
+ if matches!(
+ prev_state,
+ MessageState::Sending | MessageState::Error | MessageState::Cancelled
+ ) {
+ // Show the sent icon for 2 seconds.
+ stack.set_visible_child_name("sent");
+
+ glib::timeout_add_seconds_local_once(
+ 2,
+ clone!(@weak stack => move || {
+ stack.set_visible_child_name("edited");
+ }),
+ );
+ } else {
+ stack.set_visible_child_name("edited");
+ self.set_visible(true);
+ }
+ }
+ }
+
+ imp.state.set(state);
+ self.notify("state");
+ }
+}
diff --git a/src/session/view/content/room_history/message_row/message_state_stack.ui b/src/session/view/content/room_history/message_row/message_state_stack.ui
new file mode 100644
index 00000000..e1197e0a
--- /dev/null
+++ b/src/session/view/content/room_history/message_row/message_state_stack.ui
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
diff --git a/src/session/view/content/room_history/message_row/mod.rs b/src/session/view/content/room_history/message_row/mod.rs
index 2c97a228..670ec778 100644
--- a/src/session/view/content/room_history/message_row/mod.rs
+++ b/src/session/view/content/room_history/message_row/mod.rs
@@ -3,24 +3,23 @@ mod content;
mod file;
mod location;
mod media;
+mod message_state_stack;
mod reaction;
mod reaction_list;
mod reply;
mod text;
use adw::{prelude::*, subclass::prelude::*};
-use gtk::{
- gdk, glib,
- glib::{clone, signal::SignalHandlerId},
- CompositeTemplate,
-};
+use gtk::{gdk, glib, glib::clone, CompositeTemplate};
use matrix_sdk::ruma::events::room::message::MessageType;
use tracing::warn;
pub use self::content::{ContentFormat, MessageContent};
-use self::{media::MessageMedia, reaction_list::MessageReactionList};
+use self::{
+ media::MessageMedia, message_state_stack::MessageStateStack, reaction_list::MessageReactionList,
+};
use super::ReadReceiptsList;
-use crate::{components::Avatar, prelude::*, session::model::Event, Window};
+use crate::{components::Avatar, prelude::*, session::model::Event, utils::BoundObject, Window};
mod imp {
use std::cell::RefCell;
@@ -46,12 +45,13 @@ mod imp {
#[template_child]
pub content: TemplateChild,
#[template_child]
+ pub message_state: TemplateChild,
+ #[template_child]
pub reactions: TemplateChild,
#[template_child]
pub read_receipts: TemplateChild,
- pub source_changed_handler: RefCell