Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
41 changes: 39 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,43 @@ 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
.find_event_with_relations(event_id, Some(vec![RelationType::Replacement]))
.await
else {
return Ok(Vec::new());
};

let room = self.room();
let mut revisions = Vec::with_capacity(edit_events.len() + 1);

let original_ts = original_event.timestamp();
if let Some(content) = TimelineItemContent::from_event(room, original_event).await {
revisions.push(EditRevision { content, timestamp: original_ts });
}

for edit_event in edit_events {
let edit_ts = edit_event.timestamp();
if let Some(content) = TimelineItemContent::from_event(room, edit_event).await {
revisions.push(EditRevision { content, timestamp: edit_ts });
}
}

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();
Comment thread
bxdxnn marked this conversation as resolved.
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