diff --git a/bindings/matrix-sdk-ffi/src/timeline/mod.rs b/bindings/matrix-sdk-ffi/src/timeline/mod.rs index e1120b6ab..450ebb24e 100644 --- a/bindings/matrix-sdk-ffi/src/timeline/mod.rs +++ b/bindings/matrix-sdk-ffi/src/timeline/mod.rs @@ -454,10 +454,17 @@ impl Timeline { pub async fn send_reply( &self, msg: Arc, - reply_item: Arc, + event_id: String, ) -> Result<(), ClientError> { + let event_id = EventId::parse(event_id)?; + let replied_to_info = self + .inner + .replied_to_info_from_event_id(&event_id) + .await + .map_err(|err| anyhow::anyhow!(err))?; + self.inner - .send_reply((*msg).clone(), &reply_item.0, ForwardThread::Yes) + .send_reply((*msg).clone(), replied_to_info, ForwardThread::Yes) .await .map_err(|err| anyhow::anyhow!(err))?; Ok(()) @@ -466,10 +473,17 @@ impl Timeline { pub async fn edit( &self, new_content: Arc, - edit_item: Arc, + event_id: String, ) -> Result<(), ClientError> { + let event_id = EventId::parse(event_id)?; + let edit_info = self + .inner + .edit_info_from_event_id(&event_id) + .await + .map_err(|err| anyhow::anyhow!(err))?; + self.inner - .edit((*new_content).clone(), &edit_item.0) + .edit((*new_content).clone(), edit_info) .await .map_err(|err| anyhow::anyhow!(err))?; Ok(()) diff --git a/crates/matrix-sdk-ui/src/timeline/error.rs b/crates/matrix-sdk-ui/src/timeline/error.rs index 150b7a031..d5539e322 100644 --- a/crates/matrix-sdk-ui/src/timeline/error.rs +++ b/crates/matrix-sdk-ui/src/timeline/error.rs @@ -88,6 +88,10 @@ pub struct UnsupportedReplyItem(UnsupportedReplyItemInner); impl UnsupportedReplyItem { pub(super) const MISSING_EVENT_ID: Self = Self(UnsupportedReplyItemInner::MissingEventId); pub(super) const MISSING_JSON: Self = Self(UnsupportedReplyItemInner::MissingJson); + pub(super) const MISSING_EVENT: Self = Self(UnsupportedReplyItemInner::MissingEvent); + pub(super) const FAILED_TO_DESERIALIZE_EVENT: Self = + Self(UnsupportedReplyItemInner::FailedToDeserializeEvent); + pub(super) const STATE_EVENT: Self = Self(UnsupportedReplyItemInner::StateEvent); } #[cfg(not(tarpaulin_include))] @@ -103,6 +107,12 @@ enum UnsupportedReplyItemInner { MissingEventId, #[error("redacted events whose JSON form isn't available can't be replied")] MissingJson, + #[error("event to reply to not found")] + MissingEvent, + #[error("failed to deserialize event to reply to")] + FailedToDeserializeEvent, + #[error("tried to reply to a state event")] + StateEvent, } #[derive(Error)] @@ -114,6 +124,9 @@ impl UnsupportedEditItem { pub(super) const NOT_ROOM_MESSAGE: Self = Self(UnsupportedEditItemInner::NotRoomMessage); pub(super) const NOT_POLL_EVENT: Self = Self(UnsupportedEditItemInner::NotPollEvent); pub(super) const NOT_OWN_EVENT: Self = Self(UnsupportedEditItemInner::NotOwnEvent); + pub(super) const MISSING_EVENT: Self = Self(UnsupportedEditItemInner::MissingEvent); + pub(super) const FAILED_TO_DESERIALIZE_EVENT: Self = + Self(UnsupportedEditItemInner::FailedToDeserializeEvent); } #[cfg(not(tarpaulin_include))] @@ -133,6 +146,10 @@ enum UnsupportedEditItemInner { NotPollEvent, #[error("tried to edit another user's event")] NotOwnEvent, + #[error("event to edit not found")] + MissingEvent, + #[error("failed to deserialize event to edit")] + FailedToDeserializeEvent, } #[derive(Debug, Error)] diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs index 729ffc1ab..ce44fcc25 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs @@ -45,6 +45,7 @@ pub(super) use self::{ local::LocalEventTimelineItem, remote::{RemoteEventOrigin, RemoteEventTimelineItem}, }; +use super::{EditInfo, RepliedToInfo, ReplyContent, UnsupportedEditItem, UnsupportedReplyItem}; /// An item in the timeline that represents at least one event. /// @@ -421,6 +422,47 @@ impl EventTimelineItem { kind, } } + + /// Gives the information needed to reply to the event of the item. + pub fn replied_to_info(&self) -> Result { + let reply_content = match self.content() { + TimelineItemContent::Message(msg) => ReplyContent::Message(msg.to_owned()), + _ => { + let Some(raw_event) = self.latest_json() else { + return Err(UnsupportedReplyItem::MISSING_JSON); + }; + + ReplyContent::Raw(raw_event.clone()) + } + }; + + let Some(event_id) = self.event_id() else { + return Err(UnsupportedReplyItem::MISSING_EVENT_ID); + }; + + Ok(RepliedToInfo { + event_id: event_id.to_owned(), + sender: self.sender().to_owned(), + timestamp: self.timestamp(), + content: reply_content, + }) + } + + /// Gives the information needed to edit the event of the item. + pub fn edit_info(&self) -> Result { + if !self.is_own() { + return Err(UnsupportedEditItem::NOT_OWN_EVENT); + } + // Early returns here must be in sync with + // `EventTimelineItem::can_be_edited` + let Some(event_id) = self.event_id() else { + return Err(UnsupportedEditItem::MISSING_EVENT_ID); + }; + let TimelineItemContent::Message(original_content) = self.content() else { + return Err(UnsupportedEditItem::NOT_ROOM_MESSAGE); + }; + Ok(EditInfo { event_id: event_id.to_owned(), original_message: original_content.clone() }) + } } impl From for EventTimelineItemKind { diff --git a/crates/matrix-sdk-ui/src/timeline/mod.rs b/crates/matrix-sdk-ui/src/timeline/mod.rs index 1c5768776..4396a9a51 100644 --- a/crates/matrix-sdk-ui/src/timeline/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/mod.rs @@ -50,10 +50,11 @@ use ruma::{ }, redaction::RoomRedactionEventContent, }, - AnyMessageLikeEventContent, AnySyncTimelineEvent, + AnyMessageLikeEventContent, AnySyncMessageLikeEvent, AnySyncTimelineEvent, }, - uint, EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedTransactionId, RoomVersionId, - TransactionId, UserId, + serde::Raw, + uint, EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedTransactionId, OwnedUserId, + RoomVersionId, TransactionId, UserId, }; use thiserror::Error; use tracing::{error, instrument, trace, warn}; @@ -110,6 +111,37 @@ use self::{ util::rfind_event_by_id, }; +/// Information needed to edit an event. +#[derive(Debug)] +pub struct EditInfo { + /// The event ID of the event that needs editing. + event_id: OwnedEventId, + /// The original content of the event that needs editing. + original_message: Message, +} + +/// Information needed to reply to an event. +#[derive(Debug)] +pub struct RepliedToInfo { + /// The event ID of the event to reply to. + event_id: OwnedEventId, + /// The sender of the event to reply to. + sender: OwnedUserId, + /// The timestamp of the event to reply to. + timestamp: MilliSecondsSinceUnixEpoch, + /// The content of the event to reply to. + content: ReplyContent, +} + +/// The content of a reply. +#[derive(Debug)] +enum ReplyContent { + /// Content of a message event. + Message(Message), + /// Content of any other kind of event stored as raw. + Raw(Raw), +} + /// A high-level view into a regular¹ room's contents. /// /// ¹ This type is meant to be used in the context of rooms without a @@ -309,69 +341,60 @@ impl Timeline { /// change. Please check [`EventTimelineItem::can_be_replied_to`] to decide /// whether to render a reply button. /// - /// The sender of `reply_item` will be added to the mentions of the reply if - /// and only if `reply_item` has not been written by the sender. + /// The sender will be added to the mentions of the reply if + /// and only if the event has not been written by the sender. /// /// # Arguments /// /// * `content` - The content of the reply /// - /// * `reply_item` - The event item you want to reply to + /// * `replied_to_info` - A wrapper that contains the event ID, sender, + /// content and timestamp of the event to reply to /// /// * `forward_thread` - Usually `Yes`, unless you explicitly want to the /// reply to show up in the main timeline even though the `reply_item` is /// part of a thread - #[instrument(skip(self, content, reply_item))] + #[instrument(skip(self, content, replied_to_info))] pub async fn send_reply( &self, content: RoomMessageEventContentWithoutRelation, - reply_item: &EventTimelineItem, + replied_to_info: RepliedToInfo, forward_thread: ForwardThread, - ) -> Result<(), SendEventError> { - // Error returns here must be in sync with - // `EventTimelineItem::can_be_replied_to` - let Some(event_id) = reply_item.event_id() else { - return Err(UnsupportedReplyItem::MISSING_EVENT_ID.into()); - }; + ) -> Result<(), RoomSendQueueError> { + let event_id = replied_to_info.event_id; // [The specification](https://spec.matrix.org/v1.10/client-server-api/#user-and-room-mentions) says: // // > Users should not add their own Matrix ID to the `m.mentions` property as // > outgoing messages cannot self-notify. // - // If `reply_item` has been written by the current user, let's toggle to + // If the replied to event has been written by the current user, let's toggle to // `AddMentions::No`. - let mention_the_sender = if self.room().own_user_id() == reply_item.sender { + let mention_the_sender = if self.room().own_user_id() == replied_to_info.sender { AddMentions::No } else { AddMentions::Yes }; - let content = match reply_item.content() { - TimelineItemContent::Message(msg) => { + let content = match replied_to_info.content { + ReplyContent::Message(msg) => { let event = OriginalRoomMessageEvent { event_id: event_id.to_owned(), - sender: reply_item.sender().to_owned(), - origin_server_ts: reply_item.timestamp(), + sender: replied_to_info.sender, + origin_server_ts: replied_to_info.timestamp, room_id: self.room().room_id().to_owned(), content: msg.to_content(), unsigned: Default::default(), }; content.make_reply_to(&event, forward_thread, mention_the_sender) } - _ => { - let Some(raw_event) = reply_item.latest_json() else { - return Err(UnsupportedReplyItem::MISSING_JSON.into()); - }; - - content.make_reply_to_raw( - raw_event, - event_id.to_owned(), - self.room().room_id(), - forward_thread, - mention_the_sender, - ) - } + ReplyContent::Raw(raw_event) => content.make_reply_to_raw( + &raw_event, + event_id.to_owned(), + self.room().room_id(), + forward_thread, + mention_the_sender, + ), }; self.send(content.into()).await?; @@ -379,6 +402,63 @@ impl Timeline { Ok(()) } + /// Gives the information needed to reply to an event from an event id. + pub async fn replied_to_info_from_event_id( + &self, + event_id: &EventId, + ) -> Result { + if let Some(timeline_item) = self.item_by_event_id(event_id).await { + return timeline_item.replied_to_info(); + } + + let event = match self.room().event(event_id).await { + Ok(event) => event, + Err(error) => { + error!("Failed to fetch event with ID {event_id} with error: {error}"); + return Err(UnsupportedReplyItem::MISSING_EVENT); + } + }; + + // We need to get the content and we can do that by casting + // the event as a `AnySyncTimelineEvent` which is the same as a + // `AnyTimelineEvent`, but without the `room_id` field. + // The cast is valid because we are just losing track of such field. + let raw_sync_event: Raw = event.event.cast(); + let sync_event = match raw_sync_event.deserialize() { + Ok(event) => event, + Err(error) => { + error!("Failed to deserialize event with ID {event_id} with error: {error}"); + return Err(UnsupportedReplyItem::FAILED_TO_DESERIALIZE_EVENT); + } + }; + + let reply_content = match &sync_event { + AnySyncTimelineEvent::MessageLike(message_like_event) => { + if let AnySyncMessageLikeEvent::RoomMessage(message_event) = message_like_event { + if let Some(original_message) = message_event.as_original() { + ReplyContent::Message(Message::from_event( + original_message.content.clone(), + message_like_event.relations(), + &self.items().await, + )) + } else { + ReplyContent::Raw(raw_sync_event) + } + } else { + ReplyContent::Raw(raw_sync_event) + } + } + AnySyncTimelineEvent::State(_) => return Err(UnsupportedReplyItem::STATE_EVENT), + }; + + Ok(RepliedToInfo { + event_id: event_id.to_owned(), + sender: sync_event.sender().to_owned(), + timestamp: sync_event.origin_server_ts(), + content: reply_content, + }) + } + /// Send an edit to the given event. /// /// Currently only supports `m.room.message` events whose event ID is known. @@ -388,24 +468,16 @@ impl Timeline { /// /// * `new_content` - The content of the reply /// - /// * `edit_item` - The event item you want to edit + /// * `edit_info` - A wrapper that contains the event ID and the content of + /// the event to edit #[instrument(skip(self, new_content))] pub async fn edit( &self, new_content: RoomMessageEventContentWithoutRelation, - edit_item: &EventTimelineItem, - ) -> Result<(), SendEventError> { - // Early returns here must be in sync with `EventTimelineItem::is_editable`. - if !edit_item.is_own() { - return Err(UnsupportedEditItem::NOT_OWN_EVENT.into()); - } - let Some(event_id) = edit_item.event_id() else { - return Err(UnsupportedEditItem::MISSING_EVENT_ID.into()); - }; - - let TimelineItemContent::Message(original_content) = edit_item.content() else { - return Err(UnsupportedEditItem::NOT_ROOM_MESSAGE.into()); - }; + edit_info: EditInfo, + ) -> Result<(), RoomSendQueueError> { + let original_content = edit_info.original_message; + let event_id = edit_info.event_id; let replied_to_message = original_content.in_reply_to().and_then(|details| match &details.event { @@ -437,6 +509,58 @@ impl Timeline { Ok(()) } + /// Give the information needed to edit an event from an event id. + pub async fn edit_info_from_event_id( + &self, + event_id: &EventId, + ) -> Result { + if let Some(timeline_item) = self.item_by_event_id(event_id).await { + return timeline_item.edit_info(); + } + + let event = match self.room().event(event_id).await { + Ok(event) => event, + Err(error) => { + error!("Failed to fetch event with ID {event_id} with error: {error}"); + return Err(UnsupportedEditItem::MISSING_EVENT); + } + }; + + // We need to get the content and we can do that by casting + // the event as a `AnySyncTimelineEvent` which is the same as a + // `AnyTimelineEvent`, but without the `room_id` field. + // The cast is valid because we are just losing track of such field. + let raw_sync_event: Raw = event.event.cast(); + let event = match raw_sync_event.deserialize() { + Ok(event) => event, + Err(error) => { + error!("Failed to deserialize event with ID {event_id} with error: {error}"); + return Err(UnsupportedEditItem::FAILED_TO_DESERIALIZE_EVENT); + } + }; + + if event.sender() != self.room().own_user_id() { + return Err(UnsupportedEditItem::NOT_OWN_EVENT); + }; + + if let AnySyncTimelineEvent::MessageLike(message_like_event) = &event { + if let AnySyncMessageLikeEvent::RoomMessage(message_event) = message_like_event { + if let Some(original_message) = message_event.as_original() { + let message = Message::from_event( + original_message.content.clone(), + message_like_event.relations(), + &self.items().await, + ); + return Ok(EditInfo { + event_id: event_id.to_owned(), + original_message: message, + }); + } + } + } + Err(UnsupportedEditItem::NOT_ROOM_MESSAGE) + } + pub async fn edit_poll( &self, fallback_text: impl Into, diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/edit.rs b/crates/matrix-sdk-ui/tests/integration/timeline/edit.rs index c06b9dc6a..936812002 100644 --- a/crates/matrix-sdk-ui/tests/integration/timeline/edit.rs +++ b/crates/matrix-sdk-ui/tests/integration/timeline/edit.rs @@ -42,7 +42,7 @@ use serde_json::json; use stream_assert::assert_next_matches; use tokio::{task::yield_now, time::sleep}; use wiremock::{ - matchers::{method, path_regex}, + matchers::{header, method, path_regex}, Mock, ResponseTemplate, }; @@ -201,8 +201,9 @@ async fn test_send_edit() { .mount(&server) .await; + let edit_info = hello_world_item.edit_info().unwrap(); timeline - .edit(RoomMessageEventContentWithoutRelation::text_plain("Hello, Room!"), &hello_world_item) + .edit(RoomMessageEventContentWithoutRelation::text_plain("Hello, Room!"), edit_info) .await .unwrap(); @@ -291,8 +292,9 @@ async fn test_send_reply_edit() { .mount(&server) .await; + let edit_info = reply_item.edit_info().unwrap(); timeline - .edit(RoomMessageEventContentWithoutRelation::text_plain("Hello, Room!"), &reply_item) + .edit(RoomMessageEventContentWithoutRelation::text_plain("Hello, Room!"), edit_info) .await .unwrap(); @@ -410,3 +412,89 @@ async fn test_send_edit_poll() { server.verify().await; } + +#[async_test] +async fn test_send_edit_when_timeline_is_clear() { + let room_id = room_id!("!a98sd12bjh:example.org"); + let (client, server) = logged_in_client_with_server().await; + let event_builder = EventBuilder::new(); + 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; + + 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 raw_original_event = event_builder.make_sync_message_event_with_id( + // Same user as the logged_in_client + user_id!("@example:localhost"), + event_id!("$original_event"), + RoomMessageEventContent::text_plain("Hello, World!"), + ); + sync_builder.add_joined_room( + JoinedRoomBuilder::new(room_id).add_timeline_event(raw_original_event.clone()), + ); + + mock_sync(&server, sync_builder.build_json_sync_response(), None).await; + let _response = client.sync_once(sync_settings.clone()).await.unwrap(); + server.reset().await; + + let hello_world_item = + assert_next_matches!(timeline_stream, VectorDiff::PushBack { value } => value); + let hello_world_message = hello_world_item.content().as_message().unwrap(); + assert!(!hello_world_message.is_edited()); + assert!(hello_world_item.is_editable()); + + // Clear the timeline to make sure the old item does not need to be + // available in it for the edit to work. + timeline.clear().await; + assert_next_matches!(timeline_stream, VectorDiff::Clear); + + 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; + + // Since we assume we can't use the timeline item directly in this use case, the + // API will fetch the event from the server directly so we need to mock the + // response. + Mock::given(method("GET")) + .and(path_regex(r"^/_matrix/client/r0/rooms/.*/event/")) + .and(header("authorization", "Bearer 1234")) + .respond_with(ResponseTemplate::new(200).set_body_json(raw_original_event.json())) + .expect(1) + .named("event_1") + .mount(&server) + .await; + + let edit_info = + timeline.edit_info_from_event_id(hello_world_item.event_id().unwrap()).await.unwrap(); + timeline + .edit(RoomMessageEventContentWithoutRelation::text_plain("Hello, Room!"), edit_info) + .await + .unwrap(); + + // Since verifying the content would mean mocking the sliding sync response with + // what we are already expecting, because this test would require to paginate + // again the timeline, testing the content change would not be meaningful. + // Use an integration test for the full case. + + // The response to the mocked endpoint does not generate further timeline + // updates, so just wait for a bit before verifying that the endpoint was + // called. + sleep(Duration::from_millis(200)).await; + + server.verify().await; +} diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/replies.rs b/crates/matrix-sdk-ui/tests/integration/timeline/replies.rs index 4b99bc7e2..35797f645 100644 --- a/crates/matrix-sdk-ui/tests/integration/timeline/replies.rs +++ b/crates/matrix-sdk-ui/tests/integration/timeline/replies.rs @@ -18,6 +18,7 @@ use matrix_sdk_ui::timeline::{ use ruma::{ assign, event_id, events::{ + reaction::RedactedReactionEventContent, relation::InReplyTo, room::message::{ AddMentions, ForwardThread, OriginalRoomMessageEvent, Relation, ReplyWithinThread, @@ -314,10 +315,11 @@ async fn test_send_reply() { .mount(&server) .await; + let replied_to_info = event_from_bob.replied_to_info().unwrap(); timeline .send_reply( RoomMessageEventContentWithoutRelation::text_plain("Replying to Bob"), - &event_from_bob, + replied_to_info, ForwardThread::Yes, ) .await @@ -421,10 +423,11 @@ async fn test_send_reply_to_self() { .mount(&server) .await; + let replied_to_info = event_from_self.replied_to_info().unwrap(); timeline .send_reply( RoomMessageEventContentWithoutRelation::text_plain("Replying to self"), - &event_from_self, + replied_to_info, ForwardThread::Yes, ) .await @@ -511,10 +514,11 @@ async fn test_send_reply_to_threaded() { .mount(&server) .await; + let replied_to_info = hello_world_item.replied_to_info().unwrap(); timeline .send_reply( RoomMessageEventContentWithoutRelation::text_plain("Hello, Bob!"), - &hello_world_item, + replied_to_info, ForwardThread::Yes, ) .await @@ -557,3 +561,230 @@ async fn test_send_reply_to_threaded() { server.verify().await; } + +#[async_test] +async fn test_send_reply_with_event_id() { + let room_id = room_id!("!a98sd12bjh:example.org"); + let (client, server) = logged_in_client_with_server().await; + let event_builder = EventBuilder::new(); + 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; + + 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 event_id_from_bob = event_id!("$event_from_bob"); + let raw_event_from_bob = event_builder.make_sync_message_event_with_id( + &BOB, + event_id_from_bob, + RoomMessageEventContent::text_plain("Hello from Bob"), + ); + sync_builder.add_joined_room( + JoinedRoomBuilder::new(room_id).add_timeline_event(raw_event_from_bob.clone()), + ); + + mock_sync(&server, sync_builder.build_json_sync_response(), None).await; + let _response = client.sync_once(sync_settings.clone()).await.unwrap(); + server.reset().await; + + let event_from_bob = + assert_next_matches!(timeline_stream, VectorDiff::PushBack { value } => value); + assert_eq!(event_from_bob.event_id().unwrap(), event_id_from_bob); + + // Clear the timeline to make sure the old item does not need to be + // available in it for the reply to work. + timeline.clear().await; + assert_next_matches!(timeline_stream, VectorDiff::Clear); + + mock_encryption_state(&server, false).await; + + // Now, let's reply to a message sent by `BOB`. + Mock::given(method("PUT")) + .and(path_regex(r"^/_matrix/client/r0/rooms/.*/send/.*")) + .respond_with(move |req: &Request| { + use ruma::events::room::message::RoomMessageEventContent; + + let reply_event = req + .body_json::() + .expect("Failed to deserialize the event"); + + assert_matches!(reply_event.relates_to, Some(Relation::Reply { in_reply_to: InReplyTo { event_id, .. } }) => { + assert_eq!(event_id, event_id_from_bob); + }); + assert_matches!(reply_event.mentions, Some(Mentions { user_ids, room: false, .. }) => { + assert_eq!(user_ids.len(), 1); + assert!(user_ids.contains(*BOB)); + }); + + ResponseTemplate::new(200).set_body_json(json!({ "event_id": "$reply_event" })) + }) + .expect(1) + .mount(&server) + .await; + + // Since we assume we can't use the timeline item directly in this use case, the + // API will fetch the event from the server directly so we need to mock the + // response. + Mock::given(method("GET")) + .and(path_regex(r"^/_matrix/client/r0/rooms/.*/event/")) + .and(header("authorization", "Bearer 1234")) + .respond_with(ResponseTemplate::new(200).set_body_json(raw_event_from_bob.json())) + .expect(1) + .named("event_1") + .mount(&server) + .await; + + let replied_to_info = timeline.replied_to_info_from_event_id(event_id_from_bob).await.unwrap(); + timeline + .send_reply( + RoomMessageEventContentWithoutRelation::text_plain("Replying to Bob"), + replied_to_info, + ForwardThread::Yes, + ) + .await + .unwrap(); + + // Let the sending queue handle the event. + yield_now().await; + + let reply_item = assert_next_matches!(timeline_stream, VectorDiff::PushBack { value } => value); + + assert_matches!(reply_item.send_state(), Some(EventSendState::NotSentYet)); + let reply_message = reply_item.content().as_message().unwrap(); + assert_eq!(reply_message.body(), "Replying to Bob"); + let in_reply_to = reply_message.in_reply_to().unwrap(); + assert_eq!(in_reply_to.event_id, event_id_from_bob); + + let diff = timeout(timeline_stream.next(), Duration::from_secs(1)).await.unwrap().unwrap(); + assert_let!(VectorDiff::Set { index: 0, value: reply_item_remote_echo } = diff); + + assert_matches!(reply_item_remote_echo.send_state(), Some(EventSendState::Sent { .. })); + let reply_message = reply_item_remote_echo.content().as_message().unwrap(); + assert_eq!(reply_message.body(), "Replying to Bob"); + let in_reply_to = reply_message.in_reply_to().unwrap(); + assert_eq!(in_reply_to.event_id, event_id_from_bob); + + server.verify().await; +} + +#[async_test] +async fn test_send_reply_with_event_id_that_is_redacted() { + // This test checks if is possible to reply to a redacted event that is not in + // the timeline. The event id will go through a process where the event is + // fetched and the content will be extracted and deserialised to be used in + // the reply. + let room_id = room_id!("!a98sd12bjh:example.org"); + let (client, server) = logged_in_client_with_server().await; + let event_builder = EventBuilder::new(); + 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; + + 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 redacted_event_id_from_bob = event_id!("$event_from_bob"); + let raw_redacted_event_from_bob = event_builder.make_sync_redacted_message_event_with_id( + &BOB, + redacted_event_id_from_bob, + RedactedReactionEventContent::new(), + ); + sync_builder.add_joined_room( + JoinedRoomBuilder::new(room_id).add_timeline_event(raw_redacted_event_from_bob.clone()), + ); + + mock_sync(&server, sync_builder.build_json_sync_response(), None).await; + let _response = client.sync_once(sync_settings.clone()).await.unwrap(); + server.reset().await; + + // Clear the timeline to make sure the old item does not need to be + // available in it for the reply to work. + timeline.clear().await; + assert_next_matches!(timeline_stream, VectorDiff::Clear); + + mock_encryption_state(&server, false).await; + + // Now, let's reply to a message sent by `BOB`. + Mock::given(method("PUT")) + .and(path_regex(r"^/_matrix/client/r0/rooms/.*/send/.*")) + .respond_with(move |req: &Request| { + use ruma::events::room::message::RoomMessageEventContent; + + let reply_event = req + .body_json::() + .expect("Failed to deserialize the event"); + + assert_matches!(reply_event.relates_to, Some(Relation::Reply { in_reply_to: InReplyTo { event_id, .. } }) => { + assert_eq!(event_id, redacted_event_id_from_bob); + }); + assert_matches!(reply_event.mentions, Some(Mentions { user_ids, room: false, .. }) => { + assert_eq!(user_ids.len(), 1); + assert!(user_ids.contains(*BOB)); + }); + + ResponseTemplate::new(200).set_body_json(json!({ "event_id": "$reply_event" })) + }) + .expect(1) + .mount(&server) + .await; + + // Since we assume we can't use the timeline item directly in this use case, the + // API will fetch the event from the server directly so we need to mock the + // response. + Mock::given(method("GET")) + .and(path_regex(r"^/_matrix/client/r0/rooms/.*/event/")) + .and(header("authorization", "Bearer 1234")) + .respond_with(ResponseTemplate::new(200).set_body_json(raw_redacted_event_from_bob.json())) + .expect(1) + .named("event_1") + .mount(&server) + .await; + + let replied_to_info = + timeline.replied_to_info_from_event_id(redacted_event_id_from_bob).await.unwrap(); + timeline + .send_reply( + RoomMessageEventContentWithoutRelation::text_plain("Replying to Bob"), + replied_to_info, + ForwardThread::Yes, + ) + .await + .unwrap(); + + // Let the sending queue handle the event. + yield_now().await; + + let reply_item = assert_next_matches!(timeline_stream, VectorDiff::PushBack { value } => value); + + assert_matches!(reply_item.send_state(), Some(EventSendState::NotSentYet)); + let reply_message = reply_item.content().as_message().unwrap(); + assert_eq!(reply_message.body(), "Replying to Bob"); + let in_reply_to = reply_message.in_reply_to().unwrap(); + assert_eq!(in_reply_to.event_id, redacted_event_id_from_bob); + + let diff = timeout(timeline_stream.next(), Duration::from_secs(1)).await.unwrap().unwrap(); + assert_let!(VectorDiff::Set { index: 0, value: reply_item_remote_echo } = diff); + + assert_matches!(reply_item_remote_echo.send_state(), Some(EventSendState::Sent { .. })); + let reply_message = reply_item_remote_echo.content().as_message().unwrap(); + assert_eq!(reply_message.body(), "Replying to Bob"); + let in_reply_to = reply_message.in_reply_to().unwrap(); + assert_eq!(in_reply_to.event_id, redacted_event_id_from_bob); + + server.verify().await; +} diff --git a/testing/matrix-sdk-test/src/event_builder.rs b/testing/matrix-sdk-test/src/event_builder.rs index 8047c9ed8..5c9b480fb 100644 --- a/testing/matrix-sdk-test/src/event_builder.rs +++ b/testing/matrix-sdk-test/src/event_builder.rs @@ -139,6 +139,22 @@ impl EventBuilder { }) } + pub fn make_sync_redacted_message_event_with_id( + &self, + sender: &UserId, + event_id: &EventId, + content: C, + ) -> Raw { + sync_timeline_event!({ + "type": content.event_type(), + "content": content, + "event_id": event_id, + "sender": sender, + "origin_server_ts": self.next_server_ts(), + "unsigned": self.make_redacted_unsigned(sender), + }) + } + pub fn make_sync_state_event( &self, sender: &UserId,