diff --git a/bindings/matrix-sdk-ffi/src/timeline/mod.rs b/bindings/matrix-sdk-ffi/src/timeline/mod.rs index 30660a00a7c..2c4798a63a6 100644 --- a/bindings/matrix-sdk-ffi/src/timeline/mod.rs +++ b/bindings/matrix-sdk-ffi/src/timeline/mod.rs @@ -71,8 +71,8 @@ use crate::{ event::EventOrTransactionId, helpers::unwrap_or_clone_arc, ruma::{ - AssetType, AudioInfo, FileInfo, FormattedBody, ImageInfo, PollKind, ThumbnailInfo, - VideoInfo, + AssetType, AudioInfo, FileInfo, FormattedBody, ImageInfo, Mentions, PollKind, + ThumbnailInfo, VideoInfo, }, task_handle::TaskHandle, utils::Timestamp, @@ -1295,22 +1295,32 @@ impl From for ruma::api::client::receipt::create_receipt::v3::Recei #[derive(Clone, uniffi::Enum)] pub enum EditedContent { - RoomMessage { content: Arc }, - MediaCaption { caption: Option, formatted_caption: Option }, - PollStart { poll_data: PollData }, + RoomMessage { + content: Arc, + }, + MediaCaption { + caption: Option, + formatted_caption: Option, + mentions: Option, + }, + PollStart { + poll_data: PollData, + }, } impl TryFrom for SdkEditedContent { type Error = ClientError; + fn try_from(value: EditedContent) -> Result { match value { EditedContent::RoomMessage { content } => { Ok(SdkEditedContent::RoomMessage((*content).clone())) } - EditedContent::MediaCaption { caption, formatted_caption } => { + EditedContent::MediaCaption { caption, formatted_caption, mentions } => { Ok(SdkEditedContent::MediaCaption { caption, formatted_caption: formatted_caption.map(Into::into), + mentions: mentions.map(Into::into), }) } EditedContent::PollStart { poll_data } => { @@ -1332,12 +1342,14 @@ impl TryFrom for SdkEditedContent { fn create_caption_edit( caption: Option, formatted_caption: Option, + mentions: Option, ) -> EditedContent { let formatted_caption = formatted_body_from(caption.as_deref(), formatted_caption.map(Into::into)); EditedContent::MediaCaption { caption, formatted_caption: formatted_caption.as_ref().map(Into::into), + mentions, } } diff --git a/crates/matrix-sdk-ui/src/timeline/mod.rs b/crates/matrix-sdk-ui/src/timeline/mod.rs index 927180093cc..b593fce2283 100644 --- a/crates/matrix-sdk-ui/src/timeline/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/mod.rs @@ -486,9 +486,9 @@ impl Timeline { } } - EditedContent::MediaCaption { caption, formatted_caption } => { + EditedContent::MediaCaption { caption, formatted_caption, mentions } => { if handle - .edit_media_caption(caption, formatted_caption) + .edit_media_caption(caption, formatted_caption, mentions) .await .map_err(RoomSendQueueError::StorageError)? { diff --git a/crates/matrix-sdk/src/room/edit.rs b/crates/matrix-sdk/src/room/edit.rs index 1f9381a334d..e0644ac8fba 100644 --- a/crates/matrix-sdk/src/room/edit.rs +++ b/crates/matrix-sdk/src/room/edit.rs @@ -28,8 +28,8 @@ use ruma::{ RoomMessageEventContentWithoutRelation, }, AnyMessageLikeEvent, AnyMessageLikeEventContent, AnySyncMessageLikeEvent, - AnySyncTimelineEvent, AnyTimelineEvent, MessageLikeEvent, OriginalMessageLikeEvent, - SyncMessageLikeEvent, + AnySyncTimelineEvent, AnyTimelineEvent, Mentions, MessageLikeEvent, + OriginalMessageLikeEvent, SyncMessageLikeEvent, }, EventId, RoomId, UserId, }; @@ -54,6 +54,10 @@ pub enum EditedContent { /// /// Set to `None` to remove an existing formatted caption. formatted_caption: Option, + + /// New set of intentional mentions to be included in the edited + /// caption. + mentions: Option, }, /// The content is a new poll start. @@ -196,7 +200,7 @@ async fn make_edit_event( Ok(replacement.into()) } - EditedContent::MediaCaption { caption, formatted_caption } => { + EditedContent::MediaCaption { caption, formatted_caption, mentions } => { // Handle edits of m.room.message. let AnySyncMessageLikeEvent::RoomMessage(SyncMessageLikeEvent::Original(original)) = message_like_event @@ -207,13 +211,13 @@ async fn make_edit_event( }); }; - let mentions = original.content.mentions.clone(); + let original_mentions = original.content.mentions.clone(); let replied_to_original_room_msg = extract_replied_to(source, room_id, original.content.relates_to.clone()).await; let mut prev_content = original.content; - if !update_media_caption(&mut prev_content, caption, formatted_caption) { + if !update_media_caption(&mut prev_content, caption, formatted_caption, mentions) { return Err(EditError::IncompatibleEditType { target: prev_content.msgtype.msgtype().to_owned(), new_content: "caption for a media room message", @@ -221,7 +225,7 @@ async fn make_edit_event( } let replacement = prev_content.make_replacement( - ReplacementMetadata::new(event_id.to_owned(), mentions), + ReplacementMetadata::new(event_id.to_owned(), original_mentions), replied_to_original_room_msg.as_ref(), ); @@ -282,7 +286,10 @@ pub(crate) fn update_media_caption( content: &mut RoomMessageEventContent, caption: Option, formatted_caption: Option, + mentions: Option, ) -> bool { + content.mentions = mentions; + match &mut content.msgtype { MessageType::Audio(event) => { set_caption!(event, caption); @@ -358,9 +365,9 @@ mod tests { event_id, events::{ room::message::{MessageType, Relation, RoomMessageEventContentWithoutRelation}, - AnyMessageLikeEventContent, AnySyncTimelineEvent, + AnyMessageLikeEventContent, AnySyncTimelineEvent, Mentions, }, - owned_mxc_uri, room_id, + owned_mxc_uri, owned_user_id, room_id, serde::Raw, user_id, EventId, OwnedEventId, }; @@ -506,7 +513,11 @@ mod tests { room_id, own_user_id, event_id, - EditedContent::MediaCaption { caption: Some("yo".to_owned()), formatted_caption: None }, + EditedContent::MediaCaption { + caption: Some("yo".to_owned()), + formatted_caption: None, + mentions: None, + }, ) .await .unwrap_err(); @@ -543,6 +554,7 @@ mod tests { EditedContent::MediaCaption { caption: Some("Best joke ever".to_owned()), formatted_caption: None, + mentions: None, }, ) .await @@ -572,12 +584,12 @@ mod tests { let mut cache = TestEventCache::default(); let f = EventFactory::new(); - let event: SyncTimelineEvent = f + let event = f .image(filename.to_owned(), owned_mxc_uri!("mxc://sdk.rs/rickroll")) .caption(Some("caption".to_owned()), None) .event_id(event_id) .sender(own_user_id) - .into(); + .into_sync(); { // Sanity checks. @@ -602,7 +614,7 @@ mod tests { own_user_id, event_id, // Remove the caption by setting it to None. - EditedContent::MediaCaption { caption: None, formatted_caption: None }, + EditedContent::MediaCaption { caption: None, formatted_caption: None, mentions: None }, ) .await .unwrap(); @@ -621,6 +633,90 @@ mod tests { assert!(new_image.formatted_caption().is_none()); } + #[async_test] + async fn test_add_media_caption_mention() { + let event_id = event_id!("$1"); + let own_user_id = user_id!("@me:saucisse.bzh"); + + let filename = "rickroll.gif"; + + let mut cache = TestEventCache::default(); + let f = EventFactory::new(); + + // Start with a media event that has no mentions. + let event = f + .image(filename.to_owned(), owned_mxc_uri!("mxc://sdk.rs/rickroll")) + .event_id(event_id) + .sender(own_user_id) + .into_sync(); + + { + // Sanity checks. + let event = event.raw().deserialize().unwrap(); + assert_let!(AnySyncTimelineEvent::MessageLike(event) = event); + assert_let!( + AnyMessageLikeEventContent::RoomMessage(msg) = event.original_content().unwrap() + ); + assert_matches!(msg.mentions, None); + } + + cache.events.insert(event_id.to_owned(), event); + + let room_id = room_id!("!galette:saucisse.bzh"); + + // Add an intentional mention in the caption. + let mentioned_user_id = owned_user_id!("@crepe:saucisse.bzh"); + let edit_event = { + let mentions = Mentions::with_user_ids([mentioned_user_id.clone()]); + make_edit_event( + cache, + room_id, + own_user_id, + event_id, + EditedContent::MediaCaption { + caption: None, + formatted_caption: None, + mentions: Some(mentions), + }, + ) + .await + .unwrap() + }; + + assert_let!(AnyMessageLikeEventContent::RoomMessage(msg) = edit_event); + assert_let!(MessageType::Image(image) = msg.msgtype); + + assert!(image.caption().is_none()); + assert!(image.formatted_caption().is_none()); + + // The raw event doesn't contain the mention :( + // TODO: this is a bug in Ruma! When Ruma gets upgraded in the SDK, this test + // may start failing. In this case, remove the following code, and replace it + // with the commented code below. + + assert_matches!(msg.mentions, None); + + /* + // The raw event contains the mention. + assert_let!(Some(mentions) = msg.mentions); + assert!(!mentions.room); + assert_eq!( + mentions.user_ids.into_iter().collect::>(), + vec![mentioned_user_id.clone()] + ); + */ + + assert_let!(Some(Relation::Replacement(repl)) = msg.relates_to); + assert_let!(MessageType::Image(new_image) = repl.new_content.msgtype); + assert!(new_image.caption().is_none()); + assert!(new_image.formatted_caption().is_none()); + + // The replacement contains the mention. + assert_let!(Some(mentions) = repl.new_content.mentions); + assert!(!mentions.room); + assert_eq!(mentions.user_ids.into_iter().collect::>(), vec![mentioned_user_id]); + } + #[async_test] async fn test_make_edit_event_success_with_response() { let event_id = event_id!("$1"); diff --git a/crates/matrix-sdk/src/send_queue.rs b/crates/matrix-sdk/src/send_queue.rs index 1b9aec6d57c..d64fabcc678 100644 --- a/crates/matrix-sdk/src/send_queue.rs +++ b/crates/matrix-sdk/src/send_queue.rs @@ -159,7 +159,7 @@ use ruma::{ message::{FormattedBody, RoomMessageEventContent}, MediaSource, }, - AnyMessageLikeEventContent, EventContent as _, + AnyMessageLikeEventContent, EventContent as _, Mentions, }, serde::Raw, OwnedEventId, OwnedRoomId, OwnedTransactionId, TransactionId, @@ -1996,12 +1996,13 @@ impl SendHandle { &self, caption: Option, formatted_caption: Option, + mentions: Option, ) -> Result { if let Some(new_content) = self .room .inner .queue - .edit_media_caption(&self.transaction_id, caption, formatted_caption) + .edit_media_caption(&self.transaction_id, caption, formatted_caption, mentions) .await? { trace!("successful edit of media caption"); diff --git a/crates/matrix-sdk/src/send_queue/upload.rs b/crates/matrix-sdk/src/send_queue/upload.rs index 7c32c8fa000..4b81f551778 100644 --- a/crates/matrix-sdk/src/send_queue/upload.rs +++ b/crates/matrix-sdk/src/send_queue/upload.rs @@ -26,7 +26,7 @@ use mime::Mime; use ruma::{ events::{ room::message::{FormattedBody, MessageType, RoomMessageEventContent}, - AnyMessageLikeEventContent, + AnyMessageLikeEventContent, Mentions, }, OwnedTransactionId, TransactionId, }; @@ -488,6 +488,7 @@ impl QueueStorage { txn: &TransactionId, caption: Option, formatted_caption: Option, + mentions: Option, ) -> Result, RoomSendQueueStorageError> { // This error will be popular here. use RoomSendQueueStorageError::InvalidMediaCaptionEdit; @@ -522,7 +523,7 @@ impl QueueStorage { return Err(InvalidMediaCaptionEdit); }; - if !update_media_caption(&mut local_echo, caption, formatted_caption) { + if !update_media_caption(&mut local_echo, caption, formatted_caption, mentions) { return Err(InvalidMediaCaptionEdit); } @@ -561,7 +562,7 @@ impl QueueStorage { return Err(InvalidMediaCaptionEdit); }; - if !update_media_caption(&mut content, caption, formatted_caption) { + if !update_media_caption(&mut content, caption, formatted_caption, mentions) { return Err(InvalidMediaCaptionEdit); } diff --git a/crates/matrix-sdk/tests/integration/send_queue.rs b/crates/matrix-sdk/tests/integration/send_queue.rs index 407e595e54e..27b4d3107d9 100644 --- a/crates/matrix-sdk/tests/integration/send_queue.rs +++ b/crates/matrix-sdk/tests/integration/send_queue.rs @@ -2631,7 +2631,8 @@ async fn test_update_caption_while_sending_media() { assert_eq!(local_content.filename(), filename); // We can edit the caption while the file is being uploaded. - let edited = upload_handle.edit_media_caption(Some("caption".to_owned()), None).await.unwrap(); + let edited = + upload_handle.edit_media_caption(Some("caption".to_owned()), None, None).await.unwrap(); assert!(edited); { @@ -2740,7 +2741,8 @@ async fn test_update_caption_before_event_is_sent() { assert!(watch.is_empty()); // We can edit the caption here. - let edited = upload_handle.edit_media_caption(Some("caption".to_owned()), None).await.unwrap(); + let edited = + upload_handle.edit_media_caption(Some("caption".to_owned()), None, None).await.unwrap(); assert!(edited); // The media event is updated with the captions. @@ -2767,6 +2769,105 @@ async fn test_update_caption_before_event_is_sent() { assert!(watch.is_empty()); } +#[async_test] +async fn test_add_mention_to_caption_before_media_sent() { + let mock = MatrixMockServer::new().await; + + // Mark the room as joined. + let room_id = room_id!("!a:b.c"); + let client = mock.client_builder().build().await; + let room = mock.sync_joined_room(&client, room_id).await; + + let q = room.send_queue(); + + let (local_echoes, mut watch) = q.subscribe().await.unwrap(); + assert!(local_echoes.is_empty()); + + // Prepare endpoints. + mock.mock_room_state_encryption().plain().mount().await; + + // File upload will take a second. + mock.mock_upload() + .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(1)).set_body_json( + json!({ + "content_uri": "mxc://sdk.rs/media" + }), + )) + .mock_once() + .named("file upload") + .mount() + .await; + + // Sending of the media event will succeed. + mock.mock_room_send() + .ok(event_id!("$media")) + .mock_once() + .named("send event") + .mock_once() + .mount() + .await; + + // Send the media. + assert!(watch.is_empty()); + + let (upload_handle, filename) = queue_attachment_no_thumbnail(&q).await; + + // Let the upload request start. + sleep(Duration::from_millis(300)).await; + + // Stop the send queue before upload is done. This will stall sending of the + // media event. + q.set_enabled(false); + + let (upload_txn, _send_handle, content) = assert_update!(watch => local echo event); + assert_let!(MessageType::Image(local_content) = content.msgtype); + assert_eq!(local_content.filename(), filename); + + // Wait for the media to be uploaded. + sleep(Duration::from_secs(1)).await; + assert_update!(watch => uploaded { related_to = upload_txn, mxc = mxc_uri!("mxc://sdk.rs/media") }); + + // The media event is updated with the remote MXC ID. + { + let new_content = assert_update!(watch => edit local echo { txn = upload_txn }); + assert_let!(MessageType::Image(image) = new_content.msgtype); + assert_eq!(image.filename(), filename); + assert_eq!(image.caption(), None); + assert!(image.formatted_caption().is_none()); + + let mxc = as_variant!(image.source, MediaSource::Plain).unwrap(); + assert!(!mxc.to_string().starts_with("mxc://send-queue.localhost/"), "{mxc}"); + }; + + assert!(watch.is_empty()); + + // We can edit the caption here. + let mentioned_user_id = owned_user_id!("@damir:rust.sdk"); + let mentions = Mentions::with_user_ids([mentioned_user_id.clone()]); + let edited = upload_handle + .edit_media_caption(Some("caption".to_owned()), None, Some(mentions)) + .await + .unwrap(); + assert!(edited); + + // The media event is updated with the captions, including the mention. + { + let edit_msg = assert_update!(watch => edit local echo { txn = upload_txn }); + assert_let!(Some(mentions) = edit_msg.mentions); + assert!(!mentions.room); + assert_eq!(mentions.user_ids.into_iter().collect::>(), vec![mentioned_user_id]); + } + + // Re-enable the send queue. + q.set_enabled(true); + + // Then the event is sent. + assert_update!(watch => sent { txn = upload_txn, }); + + // That's all, folks! + assert!(watch.is_empty()); +} + #[async_test] async fn test_update_caption_while_sending_media_event() { let mock = MatrixMockServer::new().await; @@ -2855,7 +2956,8 @@ async fn test_update_caption_while_sending_media_event() { }; // We can edit the caption while the event is beint sent. - let edited = upload_handle.edit_media_caption(Some("caption".to_owned()), None).await.unwrap(); + let edited = + upload_handle.edit_media_caption(Some("caption".to_owned()), None, None).await.unwrap(); assert!(edited); // The media event is updated with the captions.