From 548c66750f5e0cd5322da2b2a67eb2ea73fa9e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 20 Sep 2024 13:27:34 +0200 Subject: [PATCH] sdk-ui: Move the event-fetching logic for edit and redact functions to the sdk-ui crate where they can be tested, to the `edit_by_id` and `redact_by_id` functions. Added some tests for those, based on the existing ones. --- bindings/matrix-sdk-ffi/src/timeline/mod.rs | 36 +-- .../src/timeline/controller/mod.rs | 11 +- crates/matrix-sdk-ui/src/timeline/error.rs | 22 +- .../src/timeline/event_item/mod.rs | 34 ++- crates/matrix-sdk-ui/src/timeline/mod.rs | 85 +++++- crates/matrix-sdk-ui/src/timeline/traits.rs | 3 +- .../tests/integration/timeline/edit.rs | 266 +++++++++++++++++- .../tests/integration/timeline/mod.rs | 134 ++++++++- .../tests/integration/timeline/replies.rs | 2 +- 9 files changed, 526 insertions(+), 67 deletions(-) diff --git a/bindings/matrix-sdk-ffi/src/timeline/mod.rs b/bindings/matrix-sdk-ffi/src/timeline/mod.rs index ccfa0f16e..5affb36bd 100644 --- a/bindings/matrix-sdk-ffi/src/timeline/mod.rs +++ b/bindings/matrix-sdk-ffi/src/timeline/mod.rs @@ -489,18 +489,7 @@ impl Timeline { /// Returns whether the edit did happen. It can only return false for /// local events that are being processed. pub async fn edit(&self, id: String, new_content: EditedContent) -> Result { - let event = if let Ok(event_id) = EventId::parse(&id) { - self.inner.item_by_event_id(&event_id).await - } else { - let transaction_id: OwnedTransactionId = id.into(); - self.inner.local_item_by_transaction_id(&transaction_id).await - }; - if let Some(event) = event { - let new_content: SdkEditedContent = new_content.try_into()?; - self.inner.edit(&event, new_content).await.map_err(ClientError::from) - } else { - Ok(false) - } + self.inner.edit_by_id(&(id.into()), new_content.try_into()?).await.map_err(Into::into) } pub async fn send_location( @@ -604,30 +593,13 @@ impl Timeline { /// being sent already. If the event was a remote event, then it will be /// redacted by sending a redaction request to the server. /// - /// Returns whether the redaction did happen. It can only return false for - /// local events that are being processed. + /// Will return an error if the event couldn't be redacted. pub async fn redact_event( &self, id: String, reason: Option, - ) -> Result { - let event = if let Ok(event_id) = EventId::parse(&id) { - self.inner.item_by_event_id(&event_id).await - } else { - let transaction_id: OwnedTransactionId = id.into(); - self.inner.local_item_by_transaction_id(&transaction_id).await - }; - if let Some(event) = event { - let removed = self - .inner - .redact(&event, reason.as_deref()) - .await - .map_err(|err| anyhow::anyhow!(err))?; - - Ok(removed) - } else { - Ok(false) - } + ) -> Result<(), ClientError> { + self.inner.redact_by_id(&(id.into()), reason.as_deref()).await.map_err(Into::into) } /// Load the reply details for the given event id. diff --git a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs index e543f0540..383d18e81 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs @@ -1380,9 +1380,12 @@ impl TimelineController { #[instrument(skip(self))] pub(super) async fn fetch_in_reply_to_details(&self, event_id: &EventId) -> Result<(), Error> { let state = self.state.write().await; - let (index, item) = - rfind_event_by_id(&state.items, event_id).ok_or(Error::RemoteEventNotInTimeline)?; - let remote_item = item.as_remote().ok_or(Error::RemoteEventNotInTimeline)?.clone(); + let (index, item) = rfind_event_by_id(&state.items, event_id) + .ok_or(Error::EventNotInTimeline(TimelineEventItemId::EventId(event_id.to_owned())))?; + let remote_item = item + .as_remote() + .ok_or(Error::EventNotInTimeline(TimelineEventItemId::EventId(event_id.to_owned())))? + .clone(); let TimelineItemContent::Message(message) = item.content().clone() else { debug!("Event is not a message"); @@ -1418,7 +1421,7 @@ impl TimelineController { // changed while waiting for the request. let mut state = self.state.write().await; let (index, item) = rfind_event_by_id(&state.items, &remote_item.event_id) - .ok_or(Error::RemoteEventNotInTimeline)?; + .ok_or(Error::EventNotInTimeline(TimelineEventItemId::EventId(event_id.to_owned())))?; // Check the state of the event again, it might have been redacted while // the request was in-flight. diff --git a/crates/matrix-sdk-ui/src/timeline/error.rs b/crates/matrix-sdk-ui/src/timeline/error.rs index 39f90f8d3..b1a899564 100644 --- a/crates/matrix-sdk-ui/src/timeline/error.rs +++ b/crates/matrix-sdk-ui/src/timeline/error.rs @@ -18,17 +18,18 @@ use matrix_sdk::{ send_queue::RoomSendQueueError, HttpError, }; +use ruma::OwnedTransactionId; use thiserror::Error; -use crate::timeline::pinned_events_loader::PinnedEventsLoaderError; +use crate::timeline::{pinned_events_loader::PinnedEventsLoaderError, TimelineEventItemId}; /// Errors specific to the timeline. #[derive(Error, Debug)] #[non_exhaustive] pub enum Error { - /// The requested event with a remote echo is not in the timeline. - #[error("Event with remote echo not found in timeline")] - RemoteEventNotInTimeline, + /// The requested event is not in the timeline. + #[error("Event not found in timeline: {0:?}")] + EventNotInTimeline(TimelineEventItemId), /// The event is currently unsupported for this use case.. #[error("Unsupported event")] @@ -76,7 +77,18 @@ pub enum Error { /// An error happened while attempting to redact an event. #[error(transparent)] - RedactError(HttpError), + RedactError(RedactError), +} + +#[derive(Error, Debug)] +pub enum RedactError { + /// Local event to redact wasn't found for transaction id + #[error("Local event to redact wasn't found for transaction {0}")] + LocalEventNotFound(OwnedTransactionId), + + /// An error happened while attempting to redact an event. + #[error(transparent)] + HttpError(#[from] HttpError), } #[derive(Error, Debug)] 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 d742dc3be..79a23ee50 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs @@ -99,6 +99,22 @@ pub enum TimelineEventItemId { EventId(OwnedEventId), } +impl From for TimelineEventItemId { + fn from(value: String) -> Self { + value.as_str().into() + } +} + +impl From<&str> for TimelineEventItemId { + fn from(value: &str) -> Self { + if let Ok(event_id) = EventId::parse(value) { + TimelineEventItemId::EventId(event_id) + } else { + TimelineEventItemId::TransactionId(value.into()) + } + } +} + /// An handle that usually allows to perform an action on a timeline event. /// /// If the item represents a remote item, then the event id is usually @@ -251,7 +267,7 @@ impl EventTimelineItem { /// Returns the transaction ID for a local echo item that has not been sent /// and the event ID for a local echo item that has been sent or a /// remote item. - pub(crate) fn identifier(&self) -> TimelineEventItemId { + pub fn identifier(&self) -> TimelineEventItemId { match &self.kind { EventTimelineItemKind::Local(local) => local.identifier(), EventTimelineItemKind::Remote(remote) => { @@ -719,7 +735,7 @@ mod tests { }; use super::{EventTimelineItem, Profile}; - use crate::timeline::TimelineDetails; + use crate::timeline::{TimelineDetails, TimelineEventItemId}; #[async_test] async fn test_latest_message_event_can_be_wrapped_as_a_timeline_item() { @@ -833,6 +849,20 @@ mod tests { ); } + #[test] + fn test_raw_event_id_into_timeline_event_item_id_gets_event_id() { + let raw_id = "$123:example.com"; + let id: TimelineEventItemId = raw_id.into(); + assert_matches!(id, TimelineEventItemId::EventId(_)); + } + + #[test] + fn test_raw_str_into_timeline_event_item_id_gets_transaction_id() { + let raw_id = "something something"; + let id: TimelineEventItemId = raw_id.into(); + assert_matches!(id, TimelineEventItemId::TransactionId(_)); + } + fn member_event( room_id: &RoomId, user_id: &UserId, diff --git a/crates/matrix-sdk-ui/src/timeline/mod.rs b/crates/matrix-sdk-ui/src/timeline/mod.rs index bd501f595..77a3e325a 100644 --- a/crates/matrix-sdk-ui/src/timeline/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/mod.rs @@ -230,6 +230,17 @@ impl Timeline { self.controller.retry_event_decryption(self.room(), None).await; } + /// Get the current timeline item for the given [`TimelineEventItemId`], if + /// any. + async fn event_by_timeline_id(&self, id: &TimelineEventItemId) -> Option { + match id { + TimelineEventItemId::EventId(event_id) => self.item_by_event_id(event_id).await, + TimelineEventItemId::TransactionId(transaction_id) => { + self.item_by_transaction_id(transaction_id).await + } + } + } + /// Get the current timeline item for the given event ID, if any. /// /// Will return a remote event, *or* a local echo that has been sent but not @@ -455,6 +466,21 @@ impl Timeline { Some(found.clone()) } + /// Edit an event given its [`TimelineEventItemId`] and some new content. + /// + /// See [`Self::edit`] for more information. + pub async fn edit_by_id( + &self, + id: &TimelineEventItemId, + new_content: EditedContent, + ) -> Result { + let Some(event) = self.event_by_timeline_id(id).await else { + return Err(Error::EventNotInTimeline(id.clone())); + }; + + self.edit(&event, new_content).await + } + /// Edit an event. /// /// Only supports events for which [`EventTimelineItem::is_editable()`] @@ -568,6 +594,38 @@ impl Timeline { SendAttachment::new(self, path.into(), mime_type, config) } + /// Redact an event given its [`TimelineEventItemId`] and an optional + /// reason. + /// + /// See [`Self::redact`] for more info. + pub async fn redact_by_id( + &self, + id: &TimelineEventItemId, + reason: Option<&str>, + ) -> Result<(), Error> { + match id { + TimelineEventItemId::TransactionId(transaction_id) => { + let Some(event) = self.item_by_transaction_id(transaction_id).await else { + return Err(Error::RedactError(RedactError::LocalEventNotFound( + transaction_id.to_owned(), + ))); + }; + let TimelineItemHandle::Local(handle) = event.handle() else { + panic!("If the item is local, this should never happen"); + }; + handle.abort().await.map_err(RoomSendQueueError::StorageError)?; + } + TimelineEventItemId::EventId(event_id) => { + self.room() + .redact(event_id, reason, None) + .await + .map_err(RedactError::HttpError) + .map_err(Error::RedactError)?; + } + } + Ok(()) + } + /// Redact an event. /// /// # Returns @@ -583,28 +641,27 @@ impl Timeline { reason: Option<&str>, ) -> Result { let event_id = match event.identifier() { - TimelineEventItemId::TransactionId(txn_id) => { + TimelineEventItemId::TransactionId(_) => { // See if we have an up-to-date timeline item with that transaction id. - if let Some(item) = self.item_by_transaction_id(&txn_id).await { - match item.handle() { - TimelineItemHandle::Remote(event_id) => event_id.to_owned(), - TimelineItemHandle::Local(handle) => { - return Ok(handle - .abort() - .await - .map_err(RoomSendQueueError::StorageError)?); - } + match event.handle() { + TimelineItemHandle::Remote(event_id) => event_id.to_owned(), + TimelineItemHandle::Local(handle) => { + return Ok(handle + .abort() + .await + .map_err(RoomSendQueueError::StorageError)?); } - } else { - warn!("Couldn't find the local echo anymore, nor a matching remote echo"); - return Ok(false); } } TimelineEventItemId::EventId(event_id) => event_id, }; - self.room().redact(&event_id, reason, None).await.map_err(Error::RedactError)?; + self.room() + .redact(&event_id, reason, None) + .await + .map_err(RedactError::HttpError) + .map_err(Error::RedactError)?; Ok(true) } diff --git a/crates/matrix-sdk-ui/src/timeline/traits.rs b/crates/matrix-sdk-ui/src/timeline/traits.rs index d8c3bd61d..f1aefb200 100644 --- a/crates/matrix-sdk-ui/src/timeline/traits.rs +++ b/crates/matrix-sdk-ui/src/timeline/traits.rs @@ -36,7 +36,7 @@ use ruma::{ }; use tracing::{debug, error}; -use super::{Profile, TimelineBuilder}; +use super::{Profile, RedactError, TimelineBuilder}; use crate::timeline::{self, pinned_events_loader::PinnedEventsRoom, Timeline}; pub trait RoomExt { @@ -269,6 +269,7 @@ impl RoomDataProvider for Room { let _ = self .redact(event_id, reason, transaction_id) .await + .map_err(RedactError::HttpError) .map_err(super::Error::RedactError)?; Ok(()) } diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/edit.rs b/crates/matrix-sdk-ui/tests/integration/timeline/edit.rs index 8391f7c2d..512ea98b8 100644 --- a/crates/matrix-sdk-ui/tests/integration/timeline/edit.rs +++ b/crates/matrix-sdk-ui/tests/integration/timeline/edit.rs @@ -29,7 +29,9 @@ use matrix_sdk_test::{ async_test, mocks::mock_encryption_state, JoinedRoomBuilder, SyncResponseBuilder, ALICE, BOB, }; use matrix_sdk_ui::{ - timeline::{EventSendState, RoomExt, TimelineDetails, TimelineItemContent}, + timeline::{ + Error, EventSendState, RoomExt, TimelineDetails, TimelineEventItemId, TimelineItemContent, + }, Timeline, }; use ruma::{ @@ -46,7 +48,7 @@ use ruma::{ }, AnyMessageLikeEventContent, AnyTimelineEvent, }, - room_id, + owned_event_id, room_id, serde::Raw, OwnedRoomId, }; @@ -1092,3 +1094,263 @@ async fn test_pending_poll_edit() { // And nothing else. assert!(timeline_stream.next().now_or_never().is_none()); } + +#[async_test] +async fn test_send_edit_by_event_id() { + 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(); + sync_builder.add_joined_room( + JoinedRoomBuilder::new(room_id).add_timeline_event( + f.text_msg("Hello, World!") + .sender(client.user_id().unwrap()) + .event_id(event_id!("$original_event")), + ), + ); + + 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()); + + 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; + + timeline + .edit_by_id( + &hello_world_item.identifier(), + EditedContent::RoomMessage(RoomMessageEventContentWithoutRelation::text_plain( + "Hello, Room!", + )), + ) + .await + .unwrap(); + + // Let the send queue handle the event. + yield_now().await; + + let edit_item = + assert_next_matches!(timeline_stream, VectorDiff::Set { index: 0, value } => value); + + // The event itself is already known to the server. We don't currently have + // a separate edit send state. + assert_matches!(edit_item.send_state(), None); + let edit_message = edit_item.content().as_message().unwrap(); + assert_eq!(edit_message.body(), "Hello, Room!"); + assert!(edit_message.is_edited()); + + // 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; +} + +#[async_test] +async fn test_send_edit_by_non_existing_event_id() { + 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(); + + mock_encryption_state(&server, false).await; + + let error = timeline + .edit_by_id( + &TimelineEventItemId::EventId(owned_event_id!("$123:example.com")), + EditedContent::RoomMessage(RoomMessageEventContentWithoutRelation::text_plain( + "Hello, Room!", + )), + ) + .await + .err() + .unwrap(); + assert_matches!(error, Error::EventNotInTimeline(_)); +} + +#[async_test] +async fn test_edit_local_echo_by_id() { + 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().await; + + 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 mounted_send = Mock::given(method("PUT")) + .and(path_regex(r"^/_matrix/client/r0/rooms/.*/send/.*")) + .and(header("authorization", "Bearer 1234")) + .respond_with(ResponseTemplate::new(413).set_body_json(json!({ + "errcode": "M_TOO_LARGE", + }))) + .expect(1) + .mount_as_scoped(&server) + .await; + + // Redacting a local event works. + timeline.send(RoomMessageEventContent::text_plain("hello, just you").into()).await.unwrap(); + + assert_let!(Some(VectorDiff::PushBack { value: item }) = timeline_stream.next().await); + + let internal_id = item.unique_id(); + + let item = item.as_event().unwrap(); + assert_matches!(item.send_state(), Some(EventSendState::NotSentYet)); + + assert_let!(Some(VectorDiff::PushFront { value: day_divider }) = timeline_stream.next().await); + assert!(day_divider.is_day_divider()); + + // We haven't set a route for sending events, so this will fail. + + assert_let!(Some(VectorDiff::Set { index: 1, value: item }) = timeline_stream.next().await); + + let item = item.as_event().unwrap(); + assert!(item.is_local_echo()); + assert!(item.is_editable()); + + assert_matches!( + item.send_state(), + Some(EventSendState::SendingFailed { is_recoverable: false, .. }) + ); + + assert!(timeline_stream.next().now_or_never().is_none()); + + // Set up the success response before editing, since edit causes an immediate + // retry (the room's send queue is not blocked, since the one event it couldn't + // send failed in an unrecoverable way). + drop(mounted_send); + Mock::given(method("PUT")) + .and(path_regex(r"^/_matrix/client/r0/rooms/.*/send/.*")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "event_id": "$1" }))) + .expect(1) + .mount(&server) + .await; + + // Let's edit the local echo. + let did_edit = timeline + .edit_by_id( + &item.identifier(), + EditedContent::RoomMessage(RoomMessageEventContent::text_plain("hello, world").into()), + ) + .await + .unwrap(); + + // We could edit the local echo, since it was in the failed state. + assert!(did_edit); + + // Observe local echo being replaced. + assert_let!(Some(VectorDiff::Set { index: 1, value: item }) = timeline_stream.next().await); + + assert_eq!(item.unique_id(), internal_id); + + let item = item.as_event().unwrap(); + assert!(item.is_local_echo()); + + // The send state has been reset. + assert_matches!(item.send_state(), Some(EventSendState::NotSentYet)); + + let edit_message = item.content().as_message().unwrap(); + assert_eq!(edit_message.body(), "hello, world"); + + // Observe the event being sent, and replacing the local echo. + assert_let!(Some(VectorDiff::Set { index: 1, value: item }) = timeline_stream.next().await); + + let item = item.as_event().unwrap(); + assert!(item.is_local_echo()); + + let edit_message = item.content().as_message().unwrap(); + assert_eq!(edit_message.body(), "hello, world"); + + // No new updates. + assert!(timeline_stream.next().now_or_never().is_none()); +} + +#[async_test] +async fn test_send_edit_by_non_existing_local_id() { + 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(); + + mock_encryption_state(&server, false).await; + + let error = timeline + .edit_by_id( + &TimelineEventItemId::TransactionId("something".into()), + EditedContent::RoomMessage(RoomMessageEventContentWithoutRelation::text_plain( + "Hello, Room!", + )), + ) + .await + .err() + .unwrap(); + assert_matches!(error, Error::EventNotInTimeline(_)); +} diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/mod.rs b/crates/matrix-sdk-ui/tests/integration/timeline/mod.rs index 460badeb2..0527382eb 100644 --- a/crates/matrix-sdk-ui/tests/integration/timeline/mod.rs +++ b/crates/matrix-sdk-ui/tests/integration/timeline/mod.rs @@ -35,18 +35,14 @@ use matrix_sdk_ui::{ }, RoomListService, Timeline, }; -use ruma::{ - event_id, - events::room::{encryption::RoomEncryptionEventContent, message::RoomMessageEventContent}, - room_id, user_id, MilliSecondsSinceUnixEpoch, -}; +use ruma::{event_id, events::room::{encryption::RoomEncryptionEventContent, message::RoomMessageEventContent}, owned_event_id, room_id, user_id, MilliSecondsSinceUnixEpoch}; use serde_json::json; use stream_assert::{assert_next_matches, assert_pending}; use wiremock::{ matchers::{header, method, path_regex}, Mock, ResponseTemplate, }; - +use matrix_sdk_ui::timeline::{Error, RedactError, TimelineEventItemId}; use crate::mock_sync; mod echo; @@ -307,6 +303,132 @@ async fn test_redact_message() { assert_matches!(timeline_stream.next().await, Some(VectorDiff::Remove { index: 2 })); } +#[async_test] +async fn test_redact_by_id_message() { + 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().await; + + let factory = EventFactory::new(); + factory.set_next_ts(MilliSecondsSinceUnixEpoch::now().get().into()); + + sync_builder.add_joined_room( + JoinedRoomBuilder::new(room_id).add_timeline_event( + factory.sender(user_id!("@a:b.com")).text_msg("buy my bitcoins bro"), + ), + ); + + mock_sync(&server, sync_builder.build_json_sync_response(), None).await; + let _response = client.sync_once(sync_settings.clone()).await.unwrap(); + server.reset().await; + + assert_let!(Some(VectorDiff::PushBack { value: first }) = timeline_stream.next().await); + assert_eq!( + first.as_event().unwrap().content().as_message().unwrap().body(), + "buy my bitcoins bro" + ); + + assert_let!(Some(VectorDiff::PushFront { value: day_divider }) = timeline_stream.next().await); + assert!(day_divider.is_day_divider()); + + // Redacting a remote event works. + mock_redaction(event_id!("$42")).mount(&server).await; + + let event = first.as_event().unwrap(); + + timeline.redact_by_id(&event.identifier(), Some("inapprops")).await.unwrap(); + + // Redacting a local event works. + timeline + .send(RoomMessageEventContent::text_plain("i will disappear soon").into()) + .await + .unwrap(); + + assert_let!(Some(VectorDiff::PushBack { value: second }) = timeline_stream.next().await); + + let second = second.as_event().unwrap(); + assert_matches!(second.send_state(), Some(EventSendState::NotSentYet)); + + // We haven't set a route for sending events, so this will fail. + assert_let!(Some(VectorDiff::Set { index, value: second }) = timeline_stream.next().await); + assert_eq!(index, 2); + + let second = second.as_event().unwrap(); + assert!(second.is_local_echo()); + assert_matches!(second.send_state(), Some(EventSendState::SendingFailed { .. })); + + // Let's redact the local echo. + timeline.redact_by_id(&second.identifier(), None).await.unwrap(); + + // Observe local echo being removed. + assert_matches!(timeline_stream.next().await, Some(VectorDiff::Remove { index: 2 })); +} + +#[async_test] +async fn test_redact_by_id_message_with_no_remote_message_present() { + 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 error = timeline + .redact_by_id(&TimelineEventItemId::EventId(owned_event_id!("$123:example.com")), None) + .await + .err(); + assert_matches!(error, Some(Error::RedactError(RedactError::HttpError(_)))) +} + +#[async_test] +async fn test_redact_by_id_message_with_no_local_message_present() { + 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 error = timeline + .redact_by_id(&TimelineEventItemId::TransactionId("something".into()), None) + .await + .err(); + assert_matches!(error, Some(Error::RedactError(RedactError::LocalEventNotFound(_)))) +} + #[async_test] async fn test_read_marker() { let room_id = room_id!("!a98sd12bjh:example.org"); diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/replies.rs b/crates/matrix-sdk-ui/tests/integration/timeline/replies.rs index e9653b3c8..f53218d85 100644 --- a/crates/matrix-sdk-ui/tests/integration/timeline/replies.rs +++ b/crates/matrix-sdk-ui/tests/integration/timeline/replies.rs @@ -61,7 +61,7 @@ async fn test_in_reply_to_details() { // The event doesn't exist. assert_matches!( timeline.fetch_details_for_event(event_id!("$fakeevent")).await, - Err(TimelineError::RemoteEventNotInTimeline) + Err(TimelineError::EventNotInTimeline(_)) ); // Add an event and a reply to that event to the timeline