diff --git a/crates/matrix-sdk-ui/src/timeline/event_handler.rs b/crates/matrix-sdk-ui/src/timeline/event_handler.rs index 219ef6bc2b2..0550ea94158 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_handler.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_handler.rs @@ -68,6 +68,7 @@ use crate::{ controller::PendingEdit, event_item::{ReactionInfo, ReactionStatus}, reactions::PendingReaction, + RepliedToEvent, }, }; @@ -511,9 +512,33 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> { ) { if let Some((item_pos, item)) = rfind_event_by_id(self.items, &replacement.event_id) { let edit_json = self.ctx.flow.raw_event().cloned(); - if let Some(new_item) = self.apply_msg_edit(&item, replacement, edit_json) { + if let Some(new_item) = self.apply_msg_edit(&item, replacement.new_content, edit_json) { trace!("Applied edit"); - self.items.set(item_pos, TimelineItem::new(new_item, item.internal_id.to_owned())); + + let internal_id = item.internal_id.to_owned(); + + // Update all events that replied to this message with the edited content. + self.items.for_each(|mut entry| { + let Some(event_item) = entry.as_event() else { return }; + let Some(message) = event_item.content.as_message() else { return }; + let Some(in_reply_to) = message.in_reply_to() else { return }; + if replacement.event_id == in_reply_to.event_id { + let in_reply_to = InReplyToDetails { + event_id: in_reply_to.event_id.clone(), + event: TimelineDetails::Ready(Box::new( + RepliedToEvent::from_timeline_item(&new_item), + )), + }; + let new_reply_content = + TimelineItemContent::Message(message.with_in_reply_to(in_reply_to)); + let new_reply_item = + entry.with_kind(event_item.with_content(new_reply_content, None)); + ObservableVectorTransactionEntry::set(&mut entry, new_reply_item); + } + }); + + // Update the event itself. + self.items.set(item_pos, TimelineItem::new(new_item, internal_id)); self.result.items_updated += 1; } } else if let Flow::Remote { position, raw_event, .. } = &self.ctx.flow { @@ -589,7 +614,7 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> { fn apply_msg_edit( &self, item: &EventTimelineItem, - replacement: Replacement, + new_content: RoomMessageEventContentWithoutRelation, edit_json: Option>, ) -> Option { if self.ctx.sender != item.sender() { @@ -609,7 +634,7 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> { }; let mut new_msg = msg.clone(); - new_msg.apply_edit(replacement.new_content); + new_msg.apply_edit(new_content); let mut new_item = item.with_content(TimelineItemContent::Message(new_msg), edit_json); diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/edit.rs b/crates/matrix-sdk-ui/tests/integration/timeline/edit.rs index 0ad3b7353db..a4fed5da95c 100644 --- a/crates/matrix-sdk-ui/tests/integration/timeline/edit.rs +++ b/crates/matrix-sdk-ui/tests/integration/timeline/edit.rs @@ -410,7 +410,7 @@ async fn test_send_reply_edit() { .mount(&server) .await; - timeline + let edited = timeline .edit( &reply_item, EditedContent::RoomMessage(RoomMessageEventContentWithoutRelation::text_plain( @@ -419,6 +419,7 @@ async fn test_send_reply_edit() { ) .await .unwrap(); + assert!(edited); // Let the send queue handle the event. yield_now().await; @@ -444,6 +445,130 @@ async fn test_send_reply_edit() { server.verify().await; } +#[async_test] +async fn test_edit_to_replied_updates_reply() { + let room_id = room_id!("!a98sd12bjh:example.org"); + let (client, server) = logged_in_client_with_server().await; + let sync_settings = SyncSettings::new().timeout(Duration::from_millis(3000)); + + let mut sync_builder = SyncResponseBuilder::new(); + sync_builder.add_joined_room(JoinedRoomBuilder::new(room_id)); + + mock_sync(&server, sync_builder.build_json_sync_response(), None).await; + let _response = client.sync_once(sync_settings.clone()).await.unwrap(); + server.reset().await; + + mock_encryption_state(&server, false).await; + + let room = client.get_room(room_id).unwrap(); + let timeline = room.timeline().await.unwrap(); + let (_, mut timeline_stream) = + timeline.subscribe_filter_map(|item| item.as_event().cloned()).await; + + let f = EventFactory::new(); + let event_id = event_id!("$original_event"); + let user_id = client.user_id().unwrap(); + + // When a room has two messages, one is a reply to the other… + sync_builder.add_joined_room( + JoinedRoomBuilder::new(room_id) + .add_timeline_event(f.text_msg("bonjour").sender(user_id).event_id(event_id)) + .add_timeline_event(f.text_msg("hi back").reply_to(event_id).sender(*ALICE)) + .add_timeline_event(f.text_msg("yo").reply_to(event_id).sender(*BOB)), + ); + + mock_sync(&server, sync_builder.build_json_sync_response(), None).await; + let _response = client.sync_once(sync_settings.clone()).await.unwrap(); + server.reset().await; + + // (I see all the messages in the timeline.) + let replied_to_item = assert_next_matches!(timeline_stream, VectorDiff::PushBack { value } => { + assert_eq!(value.content().as_message().unwrap().body(), "bonjour"); + assert!(value.is_editable()); + value + }); + + assert_next_matches!(timeline_stream, VectorDiff::PushBack { value: reply_item } => { + let reply_message = reply_item.content().as_message().unwrap(); + assert_eq!(reply_message.body(), "hi back"); + + let in_reply_to = reply_message.in_reply_to().unwrap(); + assert_eq!(in_reply_to.event_id, event_id); + + assert_let!(TimelineDetails::Ready(replied_to) = &in_reply_to.event); + assert_eq!(replied_to.content().as_message().unwrap().body(), "bonjour"); + }); + + assert_next_matches!(timeline_stream, VectorDiff::PushBack { value: reply_item } => { + let reply_message = reply_item.content().as_message().unwrap(); + assert_eq!(reply_message.body(), "yo"); + + let in_reply_to = reply_message.in_reply_to().unwrap(); + assert_eq!(in_reply_to.event_id, event_id); + + assert_let!(TimelineDetails::Ready(replied_to) = &in_reply_to.event); + assert_eq!(replied_to.content().as_message().unwrap().body(), "bonjour"); + }); + + mock_encryption_state(&server, false).await; + Mock::given(method("PUT")) + .and(path_regex(r"^/_matrix/client/r0/rooms/.*/send/.*")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({ "event_id": "$edit_event" })), + ) + .expect(1) + .mount(&server) + .await; + + // If I edit the first message,… + let edited = timeline + .edit( + &replied_to_item, + EditedContent::RoomMessage(RoomMessageEventContentWithoutRelation::text_plain( + "hello world", + )), + ) + .await + .unwrap(); + assert!(edited); + + yield_now().await; // let the send queue handle the edit. + + // The reply events are updated with the edited replied-to content. + assert_next_matches!(timeline_stream, VectorDiff::Set { index: 1, value } => { + let reply_message = value.content().as_message().unwrap(); + assert_eq!(reply_message.body(), "hi back"); + assert!(!reply_message.is_edited()); + + let in_reply_to = reply_message.in_reply_to().unwrap(); + assert_eq!(in_reply_to.event_id, event_id); + assert_let!(TimelineDetails::Ready(replied_to) = &in_reply_to.event); + assert_eq!(replied_to.content().as_message().unwrap().body(), "hello world"); + }); + + assert_next_matches!(timeline_stream, VectorDiff::Set { index: 2, value } => { + let reply_message = value.content().as_message().unwrap(); + assert_eq!(reply_message.body(), "yo"); + assert!(!reply_message.is_edited()); + + let in_reply_to = reply_message.in_reply_to().unwrap(); + assert_eq!(in_reply_to.event_id, event_id); + assert_let!(TimelineDetails::Ready(replied_to) = &in_reply_to.event); + assert_eq!(replied_to.content().as_message().unwrap().body(), "hello world"); + }); + + // And the edit happens. + assert_next_matches!(timeline_stream, VectorDiff::Set { index: 0, value } => { + let msg = value.content().as_message().unwrap(); + assert_eq!(msg.body(), "hello world"); + assert!(msg.is_edited()); + }); + + sleep(Duration::from_millis(200)).await; + + server.verify().await; +} + #[async_test] async fn test_send_edit_poll() { let room_id = room_id!("!a98sd12bjh:example.org");