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>, pub bindings: RefCell>, - pub event: RefCell>, + pub event: BoundObject, } #[glib::object_subclass] @@ -118,6 +118,14 @@ mod imp { ), ); } + + fn dispose(&self) { + self.event.disconnect_signals(); + + while let Some(binding) = self.bindings.borrow_mut().pop() { + binding.unbind(); + } + } } impl WidgetImpl for MessageRow {} @@ -164,20 +172,16 @@ impl MessageRow { } pub fn event(&self) -> Option { - self.imp().event.borrow().clone() + self.imp().event.obj() } pub fn set_event(&self, event: Event) { let imp = self.imp(); - // Remove signals and bindings from the previous event - if let Some(event) = imp.event.take() { - if let Some(source_changed_handler) = imp.source_changed_handler.take() { - event.disconnect(source_changed_handler); - } - while let Some(binding) = imp.bindings.borrow_mut().pop() { - binding.unbind(); - } + // Remove signals and bindings from the previous event. + imp.event.disconnect_signals(); + while let Some(binding) = imp.bindings.borrow_mut().pop() { + binding.unbind(); } imp.avatar @@ -199,25 +203,30 @@ impl MessageRow { .sync_create() .build(); + let state_binding = event + .bind_property("state", &*imp.message_state, "state") + .sync_create() + .build(); + imp.bindings.borrow_mut().append(&mut vec![ display_name_binding, show_header_binding, timestamp_binding, + state_binding, ]); - imp.source_changed_handler - .replace(Some(event.connect_notify_local( - Some("source"), - clone!(@weak self as obj => move |event, _| { - obj.update_content(event); - }), - ))); + let source_handler = event.connect_notify_local( + Some("source"), + clone!(@weak self as obj => move |event, _| { + obj.update_content(event); + }), + ); self.update_content(&event); imp.reactions .set_reaction_list(&event.room().get_or_create_members(), event.reactions()); imp.read_receipts.set_source(event.read_receipts()); - imp.event.replace(Some(event)); + imp.event.set(event, vec![source_handler]); self.notify("event"); } @@ -232,12 +241,10 @@ impl MessageRow { /// Open the media viewer with the media content of this row. fn show_media(&self) { - let imp = self.imp(); let Some(window) = self.root().and_downcast::() else { return; }; - let borrowed_event = imp.event.borrow(); - let Some(event) = borrowed_event.as_ref() else { + let Some(event) = self.event() else { return; }; let Some(message) = event.message() else { @@ -245,13 +252,17 @@ impl MessageRow { }; if matches!(message, MessageType::Image(_) | MessageType::Video(_)) { - let Some(media_widget) = imp.content.content_widget().and_downcast::() + let Some(media_widget) = self + .imp() + .content + .content_widget() + .and_downcast::() else { warn!("Trying to show media of a non-media message"); return; }; - window.session_view().show_media(event, &media_widget); + window.session_view().show_media(&event, &media_widget); } } } diff --git a/src/session/view/content/room_history/message_row/mod.ui b/src/session/view/content/room_history/message_row/mod.ui index 66062709..2a2773b5 100644 --- a/src/session/view/content/room_history/message_row/mod.ui +++ b/src/session/view/content/room_history/message_row/mod.ui @@ -62,22 +62,8 @@ - - - edit-symbolic - - Edited - - - Edited - - - - ContentMessageRow - - + + false 2 1 diff --git a/src/ui-resources.gresource.xml b/src/ui-resources.gresource.xml index df839c74..854171d5 100644 --- a/src/ui-resources.gresource.xml +++ b/src/ui-resources.gresource.xml @@ -67,6 +67,7 @@ session/view/content/room_history/message_row/file.ui session/view/content/room_history/message_row/location.ui session/view/content/room_history/message_row/media.ui + session/view/content/room_history/message_row/message_state_stack.ui session/view/content/room_history/message_row/mod.ui session/view/content/room_history/message_row/reaction/mod.ui session/view/content/room_history/message_row/reaction/reaction_popover.ui