diff --git a/bindings/matrix-sdk-ffi/changelog.d/6630.added.md b/bindings/matrix-sdk-ffi/changelog.d/6630.added.md new file mode 100644 index 00000000000..0fbae499b7c --- /dev/null +++ b/bindings/matrix-sdk-ffi/changelog.d/6630.added.md @@ -0,0 +1 @@ +Added a new `edit_revisions` method on `Timeline` that returns the edit history of an event. diff --git a/bindings/matrix-sdk-ffi/src/timeline/mod.rs b/bindings/matrix-sdk-ffi/src/timeline/mod.rs index 2ec184c6859..6a631395c93 100644 --- a/bindings/matrix-sdk-ffi/src/timeline/mod.rs +++ b/bindings/matrix-sdk-ffi/src/timeline/mod.rs @@ -661,6 +661,26 @@ impl Timeline { Ok(item.into()) } + /// Get the edit history for the given event. + /// + /// Returns all revisions of the event, in chronological order. + /// The first entry is the original event content, followed by each + /// edit in the order they were applied. + pub async fn edit_revisions( + &self, + event_id: String, + ) -> Result, ClientError> { + let event_id = EventId::parse(event_id)?; + let revisions = self.inner.edit_revisions(&event_id).await?; + Ok(revisions + .into_iter() + .map(|r| EditRevisionRecord { + content: r.content.into(), + timestamp: r.timestamp.map(|ts| ts.0.into()), + }) + .collect()) + } + /// Redacts an event from the timeline. /// /// Only works for events that exist as timeline items. @@ -1064,6 +1084,12 @@ impl From for Receipt { } } +#[derive(Clone, uniffi::Record)] +pub struct EditRevisionRecord { + content: TimelineItemContent, + timestamp: Option, +} + #[derive(Clone, uniffi::Record)] pub struct EventTimelineItemDebugInfo { model: String, diff --git a/crates/matrix-sdk-ui/changelog.d/6630.added.md b/crates/matrix-sdk-ui/changelog.d/6630.added.md new file mode 100644 index 00000000000..0fbae499b7c --- /dev/null +++ b/crates/matrix-sdk-ui/changelog.d/6630.added.md @@ -0,0 +1 @@ +Added a new `edit_revisions` method on `Timeline` that returns the edit history of an event. diff --git a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs index e5eb6a4fc08..0da0669fa1b 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs @@ -46,7 +46,7 @@ use ruma::{ poll::unstable_start::UnstablePollStartEventContent, reaction::ReactionEventContent, receipt::{Receipt, ReceiptThread, ReceiptType}, - relation::Annotation, + relation::{Annotation, RelationType}, room::message::{MessageType, Relation}, }, room_version_rules::RoomVersionRules, @@ -1783,6 +1783,20 @@ impl TimelineController

{ pub(super) fn focus(&self) -> &TimelineFocusKind { &self.focus } + + /// Find an event by ID in this timeline, along with its related events. + /// + /// The related events can be filtered by relation type. + pub(in crate::timeline) async fn find_event_with_relations( + &self, + event_id: &EventId, + filter: Option>, + ) -> Result<(TimelineEvent, Vec), Error> { + self.room_data_provider + .load_or_fetch_event_with_relations(event_id, filter) + .await + .map_err(Into::into) + } } #[allow(clippy::too_many_arguments)] diff --git a/crates/matrix-sdk-ui/src/timeline/error.rs b/crates/matrix-sdk-ui/src/timeline/error.rs index 28cbccb2148..b678cbd071c 100644 --- a/crates/matrix-sdk-ui/src/timeline/error.rs +++ b/crates/matrix-sdk-ui/src/timeline/error.rs @@ -52,6 +52,10 @@ pub enum Error { #[error("The room's encryption state is unknown.")] UnknownEncryptionState, + /// An error from the underlying Matrix SDK. + #[error(transparent)] + Sdk(#[from] matrix_sdk::Error), + /// Something went wrong with the room event cache. #[error(transparent)] EventCacheError(#[from] EventCacheError), 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 f2877a47fc9..77b0fb3c1db 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs @@ -124,6 +124,18 @@ pub(crate) enum TimelineItemHandle<'a> { Local(&'a SendHandle), } +/// A single revision in the edit history of a message. +/// +/// Created on-demand by querying the Event Cache for all `m.replace` +/// relations targeting a particular event. +#[derive(Clone, Debug)] +pub struct EditRevision { + /// The timeline item content after this revision. + pub content: TimelineItemContent, + /// The timestamp of the event that created this revision. + pub timestamp: Option, +} + /// A container for temporarily holding onto data that is going to be erased by /// a redaction once the server plays it back. #[derive(Clone, Debug)] diff --git a/crates/matrix-sdk-ui/src/timeline/mod.rs b/crates/matrix-sdk-ui/src/timeline/mod.rs index 9e26f3c0f66..aad39a80516 100644 --- a/crates/matrix-sdk-ui/src/timeline/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/mod.rs @@ -46,7 +46,7 @@ use ruma::{ AnyMessageLikeEventContent, AnySyncTimelineEvent, Mentions, poll::unstable_start::{NewUnstablePollStartEventContent, UnstablePollStartEventContent}, receipt::{Receipt, ReceiptThread}, - relation::Thread, + relation::{RelationType, Thread}, room::message::{ AddMentions, Relation, RelationWithoutReplacement, ReplyWithinThread, RoomMessageEventContentWithoutRelation, TextMessageEventContent, @@ -89,7 +89,7 @@ pub use self::{ error::*, event_filter::{TimelineEventCondition, TimelineEventFilter}, event_item::{ - AnyOtherStateEventContentChange, BeaconInfo, EmbeddedEvent, EncryptedMessage, + AnyOtherStateEventContentChange, BeaconInfo, EditRevision, EmbeddedEvent, EncryptedMessage, EventItemOrigin, EventSendState, EventTimelineItem, InReplyToDetails, LiveLocationState, MediaUploadProgress, MemberProfileChange, MembershipChange, Message, MsgLikeContent, MsgLikeKind, OtherMessageLike, OtherState, PollResult, PollState, Profile, ReactionInfo, @@ -283,6 +283,29 @@ impl Timeline { Some(item.to_owned()) } + /// Get the edit history for the given event. + /// + /// Returns all revisions of the event, in chronological order. + /// The first entry is the original event content, followed by each + /// edit in the order they were applied. + /// + /// This looks up the event and all `m.replace` relations targeting it, + /// first in the event cache and falling back to the homeserver if needed. + /// This works regardless of the timeline's focus kind (live, thread, + /// permalink, or pinned events). + pub async fn edit_revisions(&self, event_id: &EventId) -> Result, Error> { + let Ok((original_event, edit_events)) = self + .controller + for event in iter::once(original_event).chain(edit_events) { + let timestamp = event.timestamp(); + if let Some(content) = TimelineItemContent::from_event(room, event).await { + revisions.push(EditRevision { content, timestamp }); + } + } + + Ok(revisions) + } + /// Get the latest of the timeline's remote event ids. pub async fn latest_event_id(&self) -> Option { self.controller.latest_event_id().await diff --git a/crates/matrix-sdk-ui/src/timeline/tests/mod.rs b/crates/matrix-sdk-ui/src/timeline/tests/mod.rs index 6a08d67649d..69a20ab1b50 100644 --- a/crates/matrix-sdk-ui/src/timeline/tests/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/tests/mod.rs @@ -43,7 +43,7 @@ use ruma::{ AnyMessageLikeEventContent, AnyTimelineEvent, reaction::ReactionEventContent, receipt::{Receipt, ReceiptThread, ReceiptType}, - relation::Annotation, + relation::{Annotation, RelationType}, }, room_id, room_version_rules::RoomVersionRules, @@ -411,4 +411,12 @@ impl RoomDataProvider for TestRoomDataProvider { async fn load_event<'a>(&'a self, _event_id: &'a EventId) -> matrix_sdk::Result { unimplemented!(); } + + async fn load_or_fetch_event_with_relations<'a>( + &'a self, + _event_id: &'a EventId, + _filter: Option>, + ) -> matrix_sdk::Result<(TimelineEvent, Vec)> { + unimplemented!(); + } } diff --git a/crates/matrix-sdk-ui/src/timeline/traits.rs b/crates/matrix-sdk-ui/src/timeline/traits.rs index d4d6c533da5..28cad1e6461 100644 --- a/crates/matrix-sdk-ui/src/timeline/traits.rs +++ b/crates/matrix-sdk-ui/src/timeline/traits.rs @@ -28,6 +28,7 @@ use ruma::{ AnyMessageLikeEventContent, fully_read::FullyReadEventContent, receipt::{Receipt, ReceiptThread, ReceiptType}, + relation::RelationType, }, room_version_rules::RoomVersionRules, }; @@ -149,6 +150,13 @@ pub(super) trait RoomDataProvider: &'a self, event_id: &'a EventId, ) -> impl Future> + SendOutsideWasm + 'a; + + /// Load an event and its relations from cache or network. + fn load_or_fetch_event_with_relations<'a>( + &'a self, + event_id: &'a EventId, + filter: Option>, + ) -> impl Future)>> + SendOutsideWasm + 'a; } impl RoomDataProvider for Room { @@ -249,4 +257,12 @@ impl RoomDataProvider for Room { async fn load_event<'a>(&'a self, event_id: &'a EventId) -> Result { self.load_or_fetch_event(event_id, None).await } + + async fn load_or_fetch_event_with_relations<'a>( + &'a self, + event_id: &'a EventId, + filter: Option>, + ) -> Result<(TimelineEvent, Vec)> { + self.load_or_fetch_event_with_relations(event_id, filter, None).await + } } diff --git a/crates/matrix-sdk-ui/tests/integration/timeline/edit.rs b/crates/matrix-sdk-ui/tests/integration/timeline/edit.rs index 4eaeba8b13d..4642ef21762 100644 --- a/crates/matrix-sdk-ui/tests/integration/timeline/edit.rs +++ b/crates/matrix-sdk-ui/tests/integration/timeline/edit.rs @@ -1112,3 +1112,159 @@ async fn test_send_edit_non_existing_item() { .unwrap(); assert_matches!(error, Error::EventNotInTimeline(_)); } + +#[async_test] +async fn test_edit_revisions_unknown_event() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + let room_id = room_id!("!a98sd12bjh:example.org"); + let room = server.sync_joined_room(&client, room_id).await; + + server.mock_room_state_encryption().plain().mount().await; + + let timeline = room.timeline().await.unwrap(); + + let revisions = timeline.edit_revisions(event_id!("$nonexistent")).await.unwrap(); + assert!(revisions.is_empty()); +} + +#[async_test] +async fn test_edit_revisions_no_edit() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + let room_id = room_id!("!a98sd12bjh:example.org"); + let room = server.sync_joined_room(&client, room_id).await; + + server.mock_room_state_encryption().plain().mount().await; + + let timeline = room.timeline().await.unwrap(); + + let f = EventFactory::new(); + let original_event_id = event_id!("$original"); + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id).add_timeline_event( + f.text_msg("Hello, World!").sender(&ALICE).event_id(original_event_id), + ), + ) + .await; + + let revisions = timeline.edit_revisions(original_event_id).await.unwrap(); + assert_eq!(revisions.len(), 1); + + assert_let!(Some(msg) = revisions[0].content.as_message()); + assert_eq!(msg.body(), "Hello, World!"); +} + +#[async_test] +async fn test_edit_revisions_with_edit() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + let room_id = room_id!("!a98sd12bjh:example.org"); + let room = server.sync_joined_room(&client, room_id).await; + + server.mock_room_state_encryption().plain().mount().await; + + let timeline = room.timeline().await.unwrap(); + + let f = EventFactory::new(); + let original_event_id = event_id!("$original"); + let edit_event_id = event_id!("$edit"); + + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id).add_timeline_event( + f.text_msg("Hello, World!").sender(&ALICE).event_id(original_event_id), + ), + ) + .await; + + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id).add_timeline_event( + f.text_msg("* Hello, Room!").sender(&ALICE).event_id(edit_event_id).edit( + original_event_id, + RoomMessageEventContentWithoutRelation::text_plain("Hello, Room!"), + ), + ), + ) + .await; + + let revisions = timeline.edit_revisions(original_event_id).await.unwrap(); + assert_eq!(revisions.len(), 2); + + assert_let!(Some(msg) = revisions[0].content.as_message()); + assert_eq!(msg.body(), "Hello, World!"); + + assert_let!(Some(msg) = revisions[1].content.as_message()); + assert_eq!(msg.body(), "Hello, Room!"); +} + +#[async_test] +async fn test_edit_revisions_multiple_edits() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + let room_id = room_id!("!a98sd12bjh:example.org"); + let room = server.sync_joined_room(&client, room_id).await; + + server.mock_room_state_encryption().plain().mount().await; + + let timeline = room.timeline().await.unwrap(); + + let f = EventFactory::new(); + let original_event_id = event_id!("$original"); + let edit1_event_id = event_id!("$edit1"); + let edit2_event_id = event_id!("$edit2"); + + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id).add_timeline_event( + f.text_msg("Hello, World!").sender(&ALICE).event_id(original_event_id), + ), + ) + .await; + + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id).add_timeline_event( + f.text_msg("* Hello, Room!").sender(&ALICE).event_id(edit1_event_id).edit( + original_event_id, + RoomMessageEventContentWithoutRelation::text_plain("Hello, Room!"), + ), + ), + ) + .await; + + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id).add_timeline_event( + f.text_msg("* Hello, Everyone!").sender(&ALICE).event_id(edit2_event_id).edit( + original_event_id, + RoomMessageEventContentWithoutRelation::text_plain("Hello, Everyone!"), + ), + ), + ) + .await; + + let revisions = timeline.edit_revisions(original_event_id).await.unwrap(); + assert_eq!(revisions.len(), 3); + + assert_let!(Some(msg) = revisions[0].content.as_message()); + assert_eq!(msg.body(), "Hello, World!"); + + assert_let!(Some(msg) = revisions[1].content.as_message()); + assert_eq!(msg.body(), "Hello, Room!"); + + assert_let!(Some(msg) = revisions[2].content.as_message()); + assert_eq!(msg.body(), "Hello, Everyone!"); +}