Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bindings/matrix-sdk-ffi/changelog.d/6630.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added a new `edit_revisions` method on `Timeline` that returns the edit history of an event.
26 changes: 26 additions & 0 deletions bindings/matrix-sdk-ffi/src/timeline/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<EditRevisionRecord>, 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.
Expand Down Expand Up @@ -1064,6 +1084,12 @@ impl From<ruma::events::receipt::Receipt> for Receipt {
}
}

#[derive(Clone, uniffi::Record)]
pub struct EditRevisionRecord {
content: TimelineItemContent,
timestamp: Option<u64>,
}

#[derive(Clone, uniffi::Record)]
pub struct EventTimelineItemDebugInfo {
model: String,
Expand Down
1 change: 1 addition & 0 deletions crates/matrix-sdk-ui/changelog.d/6630.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added a new `edit_revisions` method on `Timeline` that returns the edit history of an event.
16 changes: 15 additions & 1 deletion crates/matrix-sdk-ui/src/timeline/controller/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1783,6 +1783,20 @@ impl<P: RoomDataProvider> TimelineController<P> {
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<Vec<RelationType>>,
) -> Result<(TimelineEvent, Vec<TimelineEvent>), Error> {
self.room_data_provider
.load_or_fetch_event_with_relations(event_id, filter)
.await
.map_err(Into::into)
}
}

#[allow(clippy::too_many_arguments)]
Expand Down
4 changes: 4 additions & 0 deletions crates/matrix-sdk-ui/src/timeline/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
12 changes: 12 additions & 0 deletions crates/matrix-sdk-ui/src/timeline/event_item/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MilliSecondsSinceUnixEpoch>,
}

/// 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)]
Expand Down
27 changes: 25 additions & 2 deletions crates/matrix-sdk-ui/src/timeline/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<Vec<EditRevision>, 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<OwnedEventId> {
self.controller.latest_event_id().await
Expand Down
10 changes: 9 additions & 1 deletion crates/matrix-sdk-ui/src/timeline/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -411,4 +411,12 @@ impl RoomDataProvider for TestRoomDataProvider {
async fn load_event<'a>(&'a self, _event_id: &'a EventId) -> matrix_sdk::Result<TimelineEvent> {
unimplemented!();
}

async fn load_or_fetch_event_with_relations<'a>(
&'a self,
_event_id: &'a EventId,
_filter: Option<Vec<RelationType>>,
) -> matrix_sdk::Result<(TimelineEvent, Vec<TimelineEvent>)> {
unimplemented!();
}
}
16 changes: 16 additions & 0 deletions crates/matrix-sdk-ui/src/timeline/traits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ use ruma::{
AnyMessageLikeEventContent,
fully_read::FullyReadEventContent,
receipt::{Receipt, ReceiptThread, ReceiptType},
relation::RelationType,
},
room_version_rules::RoomVersionRules,
};
Expand Down Expand Up @@ -149,6 +150,13 @@ pub(super) trait RoomDataProvider:
&'a self,
event_id: &'a EventId,
) -> impl Future<Output = Result<TimelineEvent>> + 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<Vec<RelationType>>,
) -> impl Future<Output = Result<(TimelineEvent, Vec<TimelineEvent>)>> + SendOutsideWasm + 'a;
}

impl RoomDataProvider for Room {
Expand Down Expand Up @@ -249,4 +257,12 @@ impl RoomDataProvider for Room {
async fn load_event<'a>(&'a self, event_id: &'a EventId) -> Result<TimelineEvent> {
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<Vec<RelationType>>,
) -> Result<(TimelineEvent, Vec<TimelineEvent>)> {
self.load_or_fetch_event_with_relations(event_id, filter, None).await
}
}
156 changes: 156 additions & 0 deletions crates/matrix-sdk-ui/tests/integration/timeline/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it wouldn't better to have edit_revisions on a TimelineItem instead of Timeline? cc @stefanceriu @jmartinesp

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In our case, the timeline items are mapped to record types ASAP, so having to hold to the original item is difficult. It's simpler to have it on Timeline.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've checked a summary from an LLM previously and it seems to be not worth it, but that's up to you as I don't really understand the logic between components ATM.

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!");
}
Loading