diff --git a/data/resources/stylesheet/_room_history.scss b/data/resources/stylesheet/_room_history.scss index f2c74a16..8fa02b72 100644 --- a/data/resources/stylesheet/_room_history.scss +++ b/data/resources/stylesheet/_room_history.scss @@ -106,6 +106,11 @@ room-title { } } + &.jump-highlight { + animation: jump-highlight 1200ms ease-out; + box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent-bg-color) 45%, transparent); + } + button.sender-avatar { padding: 5px; @@ -131,6 +136,15 @@ room-title { } } +@keyframes jump-highlight { + 0% { + box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent-bg-color) 55%, transparent); + } + 100% { + box-shadow: inset 0 0 0 0px transparent; + } +} + message-sender { @include vendor.focus-ring($offset: -1px, $focus-state: ':focus-within'); diff --git a/src/session_view/room_history/message_row/content.rs b/src/session_view/room_history/message_row/content.rs index 2d61bcfc..862fd3ec 100644 --- a/src/session_view/room_history/message_row/content.rs +++ b/src/session_view/room_history/message_row/content.rs @@ -198,6 +198,7 @@ impl MessageContent { replied_to_event.content.can_show_header(), ); reply.set_related_content_sender(replied_to_sender.upcast_ref()); + reply.set_related_event_id(event.reply_to_id()); reply.related_content().build_content( replied_to_event.content, ContentFormat::Compact, diff --git a/src/session_view/room_history/message_row/reply.blp b/src/session_view/room_history/message_row/reply.blp index 0e68b266..ac9531b0 100644 --- a/src/session_view/room_history/message_row/reply.blp +++ b/src/session_view/room_history/message_row/reply.blp @@ -12,6 +12,10 @@ template $ContentMessageReply: Gtk.Grid { "quote", ] + Gtk.GestureClick { + released => $handle_related_content_click() swapped; + } + accessibility { label: _("In Reply To"); } diff --git a/src/session_view/room_history/message_row/reply.rs b/src/session_view/room_history/message_row/reply.rs index e2981a94..5607fc9c 100644 --- a/src/session_view/room_history/message_row/reply.rs +++ b/src/session_view/room_history/message_row/reply.rs @@ -1,7 +1,11 @@ use adw::{prelude::*, subclass::prelude::*}; use gtk::glib; +use matrix_sdk_ui::timeline::TimelineEventItemId; +use ruma::OwnedEventId; +use tracing::error; use crate::session::User; +use crate::utils::matrix::ext_traits::TimelineEventItemIdExt; mod imp { use std::cell::{Cell, RefCell}; @@ -20,6 +24,7 @@ mod imp { related_content: TemplateChild, #[template_child] content: TemplateChild, + related_event_id: RefCell>, /// Whether to show the header of the related content. #[property(get, set = Self::set_show_related_content_header, explicit_notify)] show_related_content_header: Cell, @@ -34,6 +39,7 @@ mod imp { fn class_init(klass: &mut Self::Class) { Self::bind_template(klass); + Self::bind_template_callbacks(klass); } fn instance_init(obj: &InitializingObject) { @@ -53,7 +59,13 @@ mod imp { impl WidgetImpl for MessageReply {} impl GridImpl for MessageReply {} + #[gtk::template_callbacks] impl MessageReply { + /// Set the ID of the related event. + pub(super) fn set_related_event_id(&self, event_id: Option) { + self.related_event_id.replace(event_id); + } + /// Set whether to show the header of the related content. fn set_show_related_content_header(&self, show: bool) { if self.show_related_content_header.get() == show { @@ -87,6 +99,24 @@ mod imp { pub(super) fn content(&self) -> &adw::Bin { self.content.as_ref() } + + /// Handle a click on the replied-to content. + #[template_callback] + fn handle_related_content_click(&self) { + let Some(event_id) = self.related_event_id.borrow().clone() else { + return; + }; + if self + .obj() + .activate_action( + "room-history.scroll-to-event", + Some(&TimelineEventItemId::EventId(event_id).to_variant()), + ) + .is_err() + { + error!("Could not activate `room-history.scroll-to-event` action"); + } + } } } @@ -107,6 +137,11 @@ impl MessageReply { self.imp().set_related_content_sender(user); } + /// Set the ID of the related event. + pub(crate) fn set_related_event_id(&self, event_id: Option) { + self.imp().set_related_event_id(event_id); + } + /// The widget containing the replied-to content. pub(crate) fn related_content(&self) -> &adw::Bin { self.imp().related_content() diff --git a/src/session_view/room_history/mod.rs b/src/session_view/room_history/mod.rs index 81f5a6e1..e3fb8bdb 100644 --- a/src/session_view/room_history/mod.rs +++ b/src/session_view/room_history/mod.rs @@ -54,6 +54,10 @@ use crate::{ const SCROLL_TIMEOUT: Duration = Duration::from_millis(500); /// The time to wait before considering that messages on a screen where read. const READ_TIMEOUT: Duration = Duration::from_secs(5); +/// The time to keep the jump highlight on a message. +const JUMP_HIGHLIGHT_DURATION: Duration = Duration::from_millis(1200); +/// The CSS class used for jump highlighting. +const JUMP_HIGHLIGHT_CLASS: &str = "jump-highlight"; mod imp { use std::{ @@ -120,6 +124,8 @@ mod imp { grouping_model: OnceCell, scroll_timeout: RefCell>, read_timeout: RefCell>, + jump_highlight_timeout: RefCell>, + jump_highlight_key: RefCell>, room_handler: RefCell>, permissions_handlers: RefCell>, membership_handler: RefCell>, @@ -611,6 +617,16 @@ mod imp { if let Some(event) = item.downcast_ref::() { let child = list_item.child_or_else::(|| EventRow::new(&self.obj())); child.set_event(Some(event.clone())); + if self + .jump_highlight_key + .borrow() + .as_ref() + .is_some_and(|key| event.matches_identifier(key)) + { + child.add_css_class(JUMP_HIGHLIGHT_CLASS); + } else { + child.remove_css_class(JUMP_HIGHLIGHT_CLASS); + } } else if let Some(virtual_item) = item.downcast_ref::() { set_virtual_item_child(list_item, virtual_item); } else if let Some(group) = item.downcast_ref::() { @@ -836,11 +852,78 @@ mod imp { if let Some(pos) = timeline.find_event_position(key) { let pos = pos as u32; + self.set_is_auto_scrolling(false); + self.set_sticky(false); self.listview .scroll_to(pos, gtk::ListScrollFlags::FOCUS, None); + self.schedule_jump_highlight(key.clone()); } } + fn schedule_jump_highlight(&self, key: TimelineEventItemId) { + glib::idle_add_local_once(clone!( + #[weak(rename_to = imp)] + self, + move || { + imp.highlight_event_row(&key); + } + )); + } + + fn highlight_event_row(&self, key: &TimelineEventItemId) { + self.clear_jump_highlight(); + + let Some(row) = self.find_event_row(key) else { + return; + }; + + row.add_css_class(JUMP_HIGHLIGHT_CLASS); + self.jump_highlight_key.replace(Some(key.clone())); + + let timeout_id = glib::timeout_add_local_once(JUMP_HIGHLIGHT_DURATION, clone!( + #[weak(rename_to = imp)] + self, + move || { + imp.clear_jump_highlight(); + } + )); + self.jump_highlight_timeout.replace(Some(timeout_id)); + } + + fn clear_jump_highlight(&self) { + if let Some(timeout_id) = self.jump_highlight_timeout.take() { + timeout_id.remove(); + } + + let Some(key) = self.jump_highlight_key.take() else { + return; + }; + if let Some(row) = self.find_event_row(&key) { + row.remove_css_class(JUMP_HIGHLIGHT_CLASS); + } + } + + fn find_event_row(&self, key: &TimelineEventItemId) -> Option { + let listview = &*self.listview; + let mut child = listview.first_child(); + + while let Some(item) = child { + if let Some(event_row) = item + .first_child() + .and_downcast::() + && event_row + .event() + .is_some_and(|event| event.matches_identifier(key)) + { + return Some(event_row); + } + + child = item.next_sibling(); + } + + None + } + /// The ancestor window of the room history. fn parent_window(&self) -> Option { self.obj().root().and_downcast()